From 3c80e071b65dda4671483aa21e5b2063523cbff8 Mon Sep 17 00:00:00 2001
From: rcarvajalp <166424809+rcarvajalp@users.noreply.github.com>
Date: Sat, 9 May 2026 17:28:06 -0600
Subject: [PATCH 01/31] Modernize Frauden UI
---
index.html | 4 +-
src/App.css | 266 +++++++++++++++++++++++++++++++++++-
src/App.tsx | 266 ++++++++++++++++++++++++++++++++++--
src/assets/frauden-logo.svg | 24 ++++
4 files changed, 545 insertions(+), 15 deletions(-)
create mode 100644 src/assets/frauden-logo.svg
diff --git a/index.html b/index.html
index 1a96360..a929ef2 100644
--- a/index.html
+++ b/index.html
@@ -1,9 +1,9 @@
-
+
- Amplify Storage Browser Sample
+ Frauden | Gestor documental
diff --git a/src/App.css b/src/App.css
index 5bbce99..3834858 100644
--- a/src/App.css
+++ b/src/App.css
@@ -1,11 +1,269 @@
+:root {
+ color-scheme: light;
+ font-family:
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ --frauden-navy: #101936;
+ --frauden-navy-soft: #1f2d5f;
+ --frauden-blue: #26366f;
+ --frauden-silver: #c9ccd1;
+ --frauden-silver-dark: #8e939b;
+ --frauden-ink: #171b26;
+ --frauden-muted: #667085;
+ --frauden-surface: rgba(255, 255, 255, 0.9);
+ --frauden-border: rgba(16, 25, 54, 0.12);
+ --frauden-shadow: 0 24px 70px rgba(16, 25, 54, 0.14);
+
+ --amplify-colors-brand-primary-10: #eef1f8;
+ --amplify-colors-brand-primary-20: #d8ddeb;
+ --amplify-colors-brand-primary-40: #9fa9cd;
+ --amplify-colors-brand-primary-60: #53639b;
+ --amplify-colors-brand-primary-80: var(--frauden-blue);
+ --amplify-colors-brand-primary-90: var(--frauden-navy-soft);
+ --amplify-colors-brand-primary-100: var(--frauden-navy);
+ --amplify-colors-font-primary: var(--frauden-ink);
+ --amplify-colors-font-secondary: var(--frauden-muted);
+ --amplify-colors-border-primary: var(--frauden-border);
+ --amplify-radii-small: 0.65rem;
+ --amplify-radii-medium: 0.95rem;
+ --amplify-radii-large: 1.4rem;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ min-width: 320px;
+ min-height: 100vh;
+ margin: 0;
+ color: var(--frauden-ink);
+ background:
+ radial-gradient(circle at top left, rgba(201, 204, 209, 0.48), transparent 34rem),
+ linear-gradient(135deg, #f8fafc 0%, #eef1f6 47%, #fdfdfd 100%);
+}
+
+body::before {
+ position: fixed;
+ inset: 0;
+ z-index: -1;
+ pointer-events: none;
+ content: "";
+ background:
+ linear-gradient(115deg, transparent 0 62%, rgba(16, 25, 54, 0.08) 62% 100%),
+ radial-gradient(circle at 86% 10%, rgba(38, 54, 111, 0.14), transparent 20rem);
+}
+
#root {
+ width: 100%;
+ min-height: 100vh;
+}
+
+.app-shell {
+ width: min(1440px, 100%);
+ min-height: 100vh;
margin: 0 auto;
- padding: 2rem;
- text-align: center;
+ padding: 1.5rem;
}
-.header {
+.site-header {
display: flex;
+ gap: 1.5rem;
+ align-items: center;
justify-content: space-between;
+ padding: 1rem 1.25rem;
+ background: rgba(255, 255, 255, 0.82);
+ border: 1px solid var(--frauden-border);
+ border-radius: 1.5rem;
+ box-shadow: 0 18px 50px rgba(16, 25, 54, 0.1);
+ backdrop-filter: blur(18px);
+}
+
+.brand {
+ display: inline-flex;
+ align-items: center;
+ min-width: 0;
+}
+
+.brand-logo {
+ display: block;
+ width: clamp(14rem, 34vw, 25rem);
+ max-height: 5.75rem;
+ object-fit: contain;
+}
+
+.header-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.85rem;
align-items: center;
-}
\ No newline at end of file
+ justify-content: flex-end;
+}
+
+.user-card {
+ min-width: min(15rem, 100%);
+ padding: 0.75rem 1rem;
+ text-align: right;
+ background: linear-gradient(135deg, rgba(16, 25, 54, 0.06), rgba(201, 204, 209, 0.18));
+ border: 1px solid rgba(16, 25, 54, 0.08);
+ border-radius: 1rem;
+}
+
+.user-card strong {
+ display: block;
+ overflow: hidden;
+ color: var(--frauden-navy);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.eyebrow {
+ display: inline-flex;
+ margin-bottom: 0.35rem;
+ color: var(--frauden-silver-dark);
+ font-size: 0.72rem;
+ font-weight: 700;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+}
+
+.sign-out-button.amplify-button {
+ min-height: 3rem;
+ padding-inline: 1.25rem;
+ font-weight: 700;
+ background: linear-gradient(135deg, var(--frauden-navy), var(--frauden-blue));
+ border: 0;
+ box-shadow: 0 14px 30px rgba(16, 25, 54, 0.25);
+}
+
+.hero-card {
+ position: relative;
+ overflow: hidden;
+ margin: 1.25rem 0;
+ padding: clamp(1.5rem, 4vw, 3.75rem);
+ color: white;
+ background:
+ linear-gradient(135deg, rgba(16, 25, 54, 0.94), rgba(31, 45, 95, 0.88)),
+ linear-gradient(90deg, rgba(201, 204, 209, 0.22), transparent);
+ border-radius: 2rem;
+ box-shadow: var(--frauden-shadow);
+}
+
+.hero-card::after {
+ position: absolute;
+ top: -38%;
+ right: -10%;
+ width: 27rem;
+ height: 27rem;
+ content: "";
+ background: radial-gradient(circle, rgba(255, 255, 255, 0.2), transparent 62%);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 999px;
+}
+
+.hero-card > div {
+ position: relative;
+ z-index: 1;
+ max-width: 50rem;
+}
+
+.hero-card .eyebrow {
+ color: var(--frauden-silver);
+}
+
+.hero-card h1 {
+ max-width: 48rem;
+ margin: 0;
+ font-size: clamp(2rem, 5vw, 4.75rem);
+ line-height: 0.96;
+ letter-spacing: -0.06em;
+}
+
+.hero-card p {
+ max-width: 43rem;
+ margin: 1.1rem 0 0;
+ color: rgba(255, 255, 255, 0.78);
+ font-size: clamp(1rem, 2vw, 1.2rem);
+ line-height: 1.7;
+}
+
+.storage-card {
+ padding: clamp(0.85rem, 2vw, 1.4rem);
+ background: var(--frauden-surface);
+ border: 1px solid var(--frauden-border);
+ border-radius: 2rem;
+ box-shadow: var(--frauden-shadow);
+ backdrop-filter: blur(18px);
+}
+
+.storage-card :is(.amplify-button, button) {
+ border-radius: 0.85rem;
+}
+
+.storage-card :is(.amplify-button--primary, [data-variation="primary"]) {
+ background: linear-gradient(135deg, var(--frauden-navy), var(--frauden-blue));
+ border-color: transparent;
+}
+
+.storage-card :is(table, .amplify-table) {
+ overflow: hidden;
+ border-radius: 1rem;
+}
+
+.storage-card :is(th, .amplify-table__th) {
+ color: var(--frauden-navy);
+ background: #f4f6f9;
+}
+
+.amplify-authenticator {
+ min-height: 100vh;
+ background:
+ radial-gradient(circle at 18% 10%, rgba(201, 204, 209, 0.5), transparent 22rem),
+ linear-gradient(135deg, #f8fafc, #eef1f6);
+}
+
+.amplify-authenticator [data-amplify-router] {
+ overflow: hidden;
+ border: 1px solid var(--frauden-border);
+ border-radius: 1.5rem;
+ box-shadow: var(--frauden-shadow);
+}
+
+@media (max-width: 760px) {
+ .app-shell {
+ padding: 0.85rem;
+ }
+
+ .site-header,
+ .header-actions {
+ align-items: stretch;
+ }
+
+ .site-header {
+ flex-direction: column;
+ }
+
+ .brand-logo {
+ width: min(100%, 22rem);
+ }
+
+ .header-actions,
+ .sign-out-button.amplify-button {
+ width: 100%;
+ }
+
+ .user-card {
+ flex: 1;
+ text-align: left;
+ }
+}
+
+.auth-brand {
+ display: flex;
+ justify-content: center;
+ padding: 2rem 2rem 0.5rem;
+}
+
+.auth-brand-logo {
+ width: min(20rem, 82vw);
+ height: auto;
+}
diff --git a/src/App.tsx b/src/App.tsx
index e2f45be..f4c9d4e 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -2,29 +2,277 @@ import {
createAmplifyAuthAdapter,
createStorageBrowser,
} from '@aws-amplify/ui-react-storage/browser';
+import '@aws-amplify/ui-react/styles.css';
import '@aws-amplify/ui-react-storage/styles.css';
import './App.css';
import config from '../amplify_outputs.json';
import { Amplify } from 'aws-amplify';
-import { Authenticator, Button } from '@aws-amplify/ui-react';
+import { I18n } from 'aws-amplify/utils';
+import { Authenticator, Button, translations } from '@aws-amplify/ui-react';
+import fraudenLogo from './assets/frauden-logo.svg';
+
Amplify.configure(config);
+I18n.putVocabularies(translations);
+I18n.setLanguage('es');
const { StorageBrowser } = createStorageBrowser({
config: createAmplifyAuthAdapter(),
});
+type StorageBrowserDisplayText = NonNullable[0]['displayText']>;
+
+const authenticatorComponents = {
+ Header() {
+ return (
+
+
+
+ );
+ },
+};
+
+const storageBrowserDisplayText: StorageBrowserDisplayText = {
+ LocationsView: {
+ title: 'Inicio',
+ searchPlaceholder: 'Filtrar carpetas y archivos',
+ searchSubmitLabel: 'Buscar',
+ searchClearLabel: 'Limpiar búsqueda',
+ loadingIndicatorLabel: 'Cargando',
+ tableColumnBucketHeader: 'Bucket',
+ tableColumnFolderHeader: 'Carpeta',
+ tableColumnPermissionsHeader: 'Permisos',
+ tableColumnActionsHeader: 'Acciones',
+ getPermissionName: (permissions) => {
+ const canRead = permissions.includes('get') || permissions.includes('list');
+ const canWrite = permissions.includes('write') || permissions.includes('delete');
+
+ if (canRead && canWrite) return 'Lectura/Escritura';
+ if (canRead) return 'Lectura';
+ if (canWrite) return 'Escritura';
+
+ return permissions.join('/');
+ },
+ getDownloadLabel: (fileName) => `Descargar ${fileName}`,
+ getListLocationsResultMessage: (data) => {
+ const { isLoading, items, hasExhaustedSearch, hasError = false, message } = data ?? {};
+
+ if (isLoading) return undefined;
+ if (hasError) {
+ return {
+ type: 'error',
+ content: message ?? 'Ocurrió un error al cargar las ubicaciones.',
+ };
+ }
+ if (items?.length === 0 && !hasExhaustedSearch) {
+ return { type: 'info', content: 'No hay carpetas ni archivos.' };
+ }
+ if (hasExhaustedSearch) {
+ return {
+ type: 'info',
+ content: 'Se muestran resultados de hasta los primeros 10,000 elementos.',
+ };
+ }
+
+ return undefined;
+ },
+ },
+ LocationDetailView: {
+ loadingIndicatorLabel: 'Cargando',
+ searchPlaceholder: 'Buscar en la carpeta actual',
+ searchSubmitLabel: 'Buscar',
+ searchClearLabel: 'Limpiar búsqueda',
+ searchSubfoldersToggleLabel: 'Incluir subcarpetas',
+ selectFileLabel: 'Seleccionar archivo',
+ selectAllFilesLabel: 'Seleccionar todos los archivos',
+ tableColumnLastModifiedHeader: 'Última modificación',
+ tableColumnNameHeader: 'Nombre',
+ tableColumnSizeHeader: 'Tamaño',
+ tableColumnTypeHeader: 'Tipo',
+ getActionListItemLabel: (key = '') => {
+ const labels: Record = {
+ Copy: 'Copiar',
+ Delete: 'Eliminar',
+ 'Create folder': 'Crear carpeta',
+ Upload: 'Subir',
+ };
+
+ return labels[key] ?? key;
+ },
+ getListItemsResultMessage: (data) => {
+ const { items, hasExhaustedSearch, hasError = false, message, isLoading } = data ?? {};
+
+ if (isLoading) return undefined;
+ if (hasError) {
+ return {
+ type: 'error',
+ content: message ?? 'Ocurrió un error al cargar los elementos.',
+ };
+ }
+ if (!items?.length && hasExhaustedSearch) {
+ return {
+ type: 'info',
+ content: 'No se encontraron resultados en los primeros 10,000 elementos.',
+ };
+ }
+ if (!items?.length) return { type: 'info', content: 'No hay archivos.' };
+ if (hasExhaustedSearch) {
+ return {
+ type: 'info',
+ content: 'Se muestran resultados de hasta los primeros 10,000 elementos.',
+ };
+ }
+
+ return undefined;
+ },
+ },
+ UploadView: {
+ title: 'Subir',
+ actionStartLabel: 'Subir',
+ actionCancelLabel: 'Cancelar',
+ actionExitLabel: 'Salir',
+ addFilesLabel: 'Agregar archivos',
+ addFolderLabel: 'Agregar carpeta',
+ overwriteToggleLabel: 'Sobrescribir archivos existentes',
+ statusDisplayCanceledLabel: 'Cancelado',
+ statusDisplayCompletedLabel: 'Completado',
+ statusDisplayFailedLabel: 'Fallido',
+ statusDisplayInProgressLabel: 'En progreso',
+ statusDisplayOverwritePreventedLabel: 'Sobrescritura evitada',
+ statusDisplayQueuedLabel: 'Sin iniciar',
+ statusDisplayTotalLabel: 'Total',
+ tableColumnFolderHeader: 'Carpeta',
+ tableColumnNameHeader: 'Nombre',
+ tableColumnTypeHeader: 'Tipo',
+ tableColumnSizeHeader: 'Tamaño',
+ tableColumnStatusHeader: 'Estado',
+ tableColumnProgressHeader: 'Progreso',
+ getActionCompleteMessage: () => ({
+ content: 'Proceso de carga finalizado.',
+ type: 'success',
+ }),
+ },
+ DeleteView: {
+ title: 'Eliminar',
+ actionStartLabel: 'Eliminar',
+ actionCancelLabel: 'Cancelar',
+ actionExitLabel: 'Salir',
+ statusDisplayCanceledLabel: 'Cancelado',
+ statusDisplayCompletedLabel: 'Completado',
+ statusDisplayFailedLabel: 'Fallido',
+ statusDisplayInProgressLabel: 'En progreso',
+ statusDisplayQueuedLabel: 'Sin iniciar',
+ statusDisplayTotalLabel: 'Total',
+ tableColumnFolderHeader: 'Carpeta',
+ tableColumnNameHeader: 'Nombre',
+ tableColumnTypeHeader: 'Tipo',
+ tableColumnSizeHeader: 'Tamaño',
+ tableColumnStatusHeader: 'Estado',
+ getActionCompleteMessage: () => ({
+ content: 'Proceso de eliminación finalizado.',
+ type: 'success',
+ }),
+ },
+ CopyView: {
+ title: 'Copiar',
+ actionStartLabel: 'Copiar',
+ actionCancelLabel: 'Cancelar',
+ actionExitLabel: 'Salir',
+ actionDestinationLabel: 'Destino de la copia',
+ loadingIndicatorLabel: 'Cargando' as 'Loading',
+ overwriteWarningMessage:
+ 'Los archivos copiados sobrescribirán archivos existentes en el destino seleccionado.',
+ searchPlaceholder: 'Buscar carpetas',
+ searchSubmitLabel: 'Buscar',
+ searchClearLabel: 'Limpiar búsqueda',
+ statusDisplayCanceledLabel: 'Cancelado',
+ statusDisplayCompletedLabel: 'Completado',
+ statusDisplayFailedLabel: 'Fallido',
+ statusDisplayInProgressLabel: 'En progreso',
+ statusDisplayQueuedLabel: 'Sin iniciar',
+ statusDisplayTotalLabel: 'Total',
+ tableColumnFolderHeader: 'Carpeta',
+ tableColumnNameHeader: 'Nombre',
+ tableColumnTypeHeader: 'Tipo',
+ tableColumnSizeHeader: 'Tamaño',
+ tableColumnStatusHeader: 'Estado',
+ tableColumnProgressHeader: 'Progreso',
+ getListFoldersResultsMessage: ({ folders, query, hasError, message, hasExhaustedSearch }) => {
+ if (!folders?.length) {
+ return {
+ content: query
+ ? `No se encontraron carpetas que coincidan con "${query}".`
+ : 'No se encontraron subcarpetas en la carpeta seleccionada.',
+ type: 'info',
+ };
+ }
+ if ((message && query) || hasError)
+ return { content: 'Error al cargar carpetas.', type: 'error' };
+ if (hasExhaustedSearch) {
+ return {
+ content: 'Se muestran resultados de hasta los primeros 10,000 elementos.',
+ type: 'info',
+ };
+ }
+
+ return undefined;
+ },
+ getActionCompleteMessage: () => ({
+ content: 'Proceso de copia finalizado.',
+ type: 'success',
+ }),
+ },
+ CreateFolderView: {
+ title: 'Crear carpeta',
+ actionStartLabel: 'Crear carpeta',
+ actionCancelLabel: 'Cancelar',
+ actionExitLabel: 'Salir',
+ actionDestinationLabel: 'Destino',
+ folderNameLabel: 'Nombre de la carpeta',
+ folderNamePlaceholder: 'No puede contener "/" ni empezar o terminar con "."',
+ getValidationMessage: () => 'El nombre no puede contener "/" ni empezar o terminar con "."',
+ getActionCompleteMessage: () => ({
+ content: 'Carpeta creada.',
+ type: 'success',
+ }),
+ },
+};
+
function App() {
return (
-
+
{({ signOut, user }) => (
- <>
-
-
{`Hello ${user?.username}`}
- Sign out
-
-
- >
+
+
+
+
+
+
Panel seguro
+
Gestor documental antifraude
+
+ Organiza, consulta y administra archivos críticos con una experiencia clara,
+ profesional y alineada a la identidad de Frauden.
+
+
+
+
+
+
)}
);
diff --git a/src/assets/frauden-logo.svg b/src/assets/frauden-logo.svg
new file mode 100644
index 0000000..1a7d2c9
--- /dev/null
+++ b/src/assets/frauden-logo.svg
@@ -0,0 +1,24 @@
+
+ Frauden
+ Logo de Frauden con texto plateado y azul marino
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ FRAUD
+ EN
+
+ Fraude empresarial y en los negocios
+
From 8f2f915457714a4db6ebfecb6d2ee8ac6c5866bb Mon Sep 17 00:00:00 2001
From: rcarvajalp <166424809+rcarvajalp@users.noreply.github.com>
Date: Sat, 9 May 2026 20:39:29 -0600
Subject: [PATCH 02/31] Update hero subtitle
---
amplify/backend.ts | 14 ++++++++++----
src/App.tsx | 8 ++++----
2 files changed, 14 insertions(+), 8 deletions(-)
diff --git a/amplify/backend.ts b/amplify/backend.ts
index 032938d..3f569c7 100644
--- a/amplify/backend.ts
+++ b/amplify/backend.ts
@@ -2,12 +2,18 @@ import { defineBackend } from '@aws-amplify/backend';
import { auth } from './auth/resource';
import { storage, secondaryStorage } from './storage/resource';
-
/**
* @see https://docs.amplify.aws/react/build-a-backend/ to add storage, functions, and more
*/
-defineBackend({
+const backend = defineBackend({
auth,
- storage,
- secondaryStorage
+ storage,
+ secondaryStorage,
});
+
+const { cfnUserPool } = backend.auth.resources.cfnResources;
+
+cfnUserPool.adminCreateUserConfig = {
+ ...cfnUserPool.adminCreateUserConfig,
+ allowAdminCreateUserOnly: true,
+};
diff --git a/src/App.tsx b/src/App.tsx
index f4c9d4e..437f94b 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -240,7 +240,7 @@ const storageBrowserDisplayText: StorageBrowserDisplayText = {
function App() {
return (
-
+
{({ signOut, user }) => (
@@ -261,10 +261,10 @@ function App() {
Panel seguro
-
Gestor documental antifraude
+
Gestor documental de Frauden
- Organiza, consulta y administra archivos críticos con una experiencia clara,
- profesional y alineada a la identidad de Frauden.
+ carga archivos y organiza de forma segura y sencilla el archivo documental de
+ Frauden.
From 0fbda646a74690250241db0b3fbb9e5e50e86ad6 Mon Sep 17 00:00:00 2001
From: rcarvajalp <166424809+rcarvajalp@users.noreply.github.com>
Date: Mon, 11 May 2026 11:38:39 -0600
Subject: [PATCH 03/31] Grant delete where storage write is allowed
---
README.md | 4 +--
amplify/storage/resource.ts | 52 ++++++++++++++++---------------------
2 files changed, 25 insertions(+), 31 deletions(-)
diff --git a/README.md b/README.md
index 3bfe9f0..c797c37 100644
--- a/README.md
+++ b/README.md
@@ -11,8 +11,8 @@ This template equips you with a foundational React application integrated with A
- **Authentication**: Setup with Amazon Cognito for secure user authentication with email login.
- More info on how to setup and configuration option: https://docs.amplify.aws/react/build-a-backend/auth/set-up-auth/
- **Storage**: Configured with multiple S3 buckets and granular access controls. The sample is configured with
- - Default storage bucket with public, admin, and private access paths
- - Secondary storage bucket with separate backup paths.
+ - Default `frauden-bucket` storage bucket with `doctrina`, `medios`, `jurisprudencia`, and `legislacion` access paths for authenticated readers and admin read/write/delete access.
+ - Secondary `frauden-expedientes` storage bucket with `publico`, `confidencial`, and owner-scoped `privado/{entity_id}` access paths, including delete permissions wherever write access is granted.
- More info on how to setup : https://docs.amplify.aws/react/build-a-backend/storage/set-up-storage/#building-your-storage-backend
- **UI Components**: Pre-integrated Amplify UI React components including:
- Authenticator for sign-in/sign-up flows
diff --git a/amplify/storage/resource.ts b/amplify/storage/resource.ts
index 62d6340..8dd2ab6 100644
--- a/amplify/storage/resource.ts
+++ b/amplify/storage/resource.ts
@@ -1,39 +1,33 @@
import { defineStorage } from '@aws-amplify/backend';
export const storage = defineStorage({
- name: 'myStorageBucket',
+ name: 'frauden-bucket',
isDefault: true,
- access: (allow) => ({
- 'public/*': [
- allow.guest.to(['read', 'write']),
- allow.authenticated.to(['read', 'write', 'delete']),
+ access: (allow) => ({
+ 'doctrina/*': [
+ allow.authenticated.to(['read']),
+ allow.groups(['admin']).to(['read', 'write', 'delete']),
],
- 'admin/*': [
- allow.groups(['admin']).to(['read', 'write', 'delete']),
- allow.authenticated.to(['read'])
+ 'medios/*': [
+ allow.authenticated.to(['read']),
+ allow.groups(['admin']).to(['read', 'write', 'delete']),
],
- 'private/{entity_id}/*': [
- allow.entity('identity').to(['read', 'write', 'delete'])
- ]
- })
-});
-
-export const secondaryStorage = defineStorage({
- name: 'mySecondaryStorageBucket',
- access: (allow) => ({
- 'backup_public/*': [
- allow.guest.to(['read', 'write']),
- allow.authenticated.to(['read', 'write', 'delete']),
+ 'jurisprudencia/*': [
+ allow.authenticated.to(['read']),
+ allow.groups(['admin']).to(['read', 'write', 'delete']),
],
- 'backup_admin/*': [
- allow.groups(['admin']).to(['read', 'write', 'delete']),
- allow.authenticated.to(['read'])
+ 'legislacion/*': [
+ allow.authenticated.to(['read']),
+ allow.groups(['admin']).to(['read', 'write', 'delete']),
],
- 'backup_private/{entity_id}/*': [
- allow.entity('identity').to(['read', 'write', 'delete'])
- ]
- })
+ }),
});
-
-
+export const secondaryStorage = defineStorage({
+ name: 'frauden-expedientes',
+ access: (allow) => ({
+ 'publico/*': [allow.authenticated.to(['read', 'write', 'delete'])],
+ 'confidencial/*': [allow.groups(['admin']).to(['read', 'write', 'delete'])],
+ 'privado/{entity_id}/*': [allow.entity('identity').to(['read', 'write', 'delete'])],
+ }),
+});
From 23dca19decd6212a1ff53cff642067e6e68c62f5 Mon Sep 17 00:00:00 2001
From: rcarvajalp <166424809+rcarvajalp@users.noreply.github.com>
Date: Mon, 11 May 2026 13:36:37 -0600
Subject: [PATCH 04/31] Use resource-defined S3 bucket names
---
amplify/backend.ts | 10 +++++++++-
amplify/storage/resource.ts | 7 +++++--
2 files changed, 14 insertions(+), 3 deletions(-)
diff --git a/amplify/backend.ts b/amplify/backend.ts
index 3f569c7..0d0e6a6 100644
--- a/amplify/backend.ts
+++ b/amplify/backend.ts
@@ -1,6 +1,11 @@
import { defineBackend } from '@aws-amplify/backend';
import { auth } from './auth/resource';
-import { storage, secondaryStorage } from './storage/resource';
+import {
+ secondaryStorage,
+ secondaryStorageBucketName,
+ storage,
+ storageBucketName,
+} from './storage/resource';
/**
* @see https://docs.amplify.aws/react/build-a-backend/ to add storage, functions, and more
@@ -11,6 +16,9 @@ const backend = defineBackend({
secondaryStorage,
});
+backend.storage.resources.cfnResources.cfnBucket.bucketName = storageBucketName;
+backend.secondaryStorage.resources.cfnResources.cfnBucket.bucketName = secondaryStorageBucketName;
+
const { cfnUserPool } = backend.auth.resources.cfnResources;
cfnUserPool.adminCreateUserConfig = {
diff --git a/amplify/storage/resource.ts b/amplify/storage/resource.ts
index 8dd2ab6..fa1eef0 100644
--- a/amplify/storage/resource.ts
+++ b/amplify/storage/resource.ts
@@ -1,7 +1,10 @@
import { defineStorage } from '@aws-amplify/backend';
+export const storageBucketName = 'frauden-bucket';
+export const secondaryStorageBucketName = 'frauden-expedientes';
+
export const storage = defineStorage({
- name: 'frauden-bucket',
+ name: storageBucketName,
isDefault: true,
access: (allow) => ({
'doctrina/*': [
@@ -24,7 +27,7 @@ export const storage = defineStorage({
});
export const secondaryStorage = defineStorage({
- name: 'frauden-expedientes',
+ name: secondaryStorageBucketName,
access: (allow) => ({
'publico/*': [allow.authenticated.to(['read', 'write', 'delete'])],
'confidencial/*': [allow.groups(['admin']).to(['read', 'write', 'delete'])],
From b5f6ebec472d67761b01bf422830e1e2bf080b30 Mon Sep 17 00:00:00 2001
From: rcarvajalp <166424809+rcarvajalp@users.noreply.github.com>
Date: Mon, 11 May 2026 13:56:14 -0600
Subject: [PATCH 05/31] Avoid physical bucket rename collision
---
amplify/storage/resource.ts | 7 +++--
src/App.tsx | 54 +++++++++++++++++++++++++++++++++++++
2 files changed, 59 insertions(+), 2 deletions(-)
diff --git a/amplify/storage/resource.ts b/amplify/storage/resource.ts
index 8dd2ab6..fa1eef0 100644
--- a/amplify/storage/resource.ts
+++ b/amplify/storage/resource.ts
@@ -1,7 +1,10 @@
import { defineStorage } from '@aws-amplify/backend';
+export const storageBucketName = 'frauden-bucket';
+export const secondaryStorageBucketName = 'frauden-expedientes';
+
export const storage = defineStorage({
- name: 'frauden-bucket',
+ name: storageBucketName,
isDefault: true,
access: (allow) => ({
'doctrina/*': [
@@ -24,7 +27,7 @@ export const storage = defineStorage({
});
export const secondaryStorage = defineStorage({
- name: 'frauden-expedientes',
+ name: secondaryStorageBucketName,
access: (allow) => ({
'publico/*': [allow.authenticated.to(['read', 'write', 'delete'])],
'confidencial/*': [allow.groups(['admin']).to(['read', 'write', 'delete'])],
diff --git a/src/App.tsx b/src/App.tsx
index 437f94b..50c7f44 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,4 +1,5 @@
import {
+ componentsDefault,
createAmplifyAuthAdapter,
createStorageBrowser,
} from '@aws-amplify/ui-react-storage/browser';
@@ -11,12 +12,64 @@ import { Amplify } from 'aws-amplify';
import { I18n } from 'aws-amplify/utils';
import { Authenticator, Button, translations } from '@aws-amplify/ui-react';
import fraudenLogo from './assets/frauden-logo.svg';
+import type { ComponentProps } from 'react';
Amplify.configure(config);
I18n.putVocabularies(translations);
I18n.setLanguage('es');
+const resourceBucketNames = ['frauden-bucket', 'frauden-expedientes'] as const;
+
+const getResourceBucketName = (bucketName: string) => {
+ const comparableBucketName = bucketName.toLowerCase().replace(/[^a-z0-9]/g, '');
+
+ return (
+ resourceBucketNames.find((resourceBucketName) => {
+ const comparableResourceBucketName = resourceBucketName
+ .toLowerCase()
+ .replace(/[^a-z0-9]/g, '');
+
+ return (
+ bucketName === resourceBucketName ||
+ comparableBucketName.includes(comparableResourceBucketName)
+ );
+ }) ?? bucketName
+ );
+};
+
+const DefaultDataTable = componentsDefault.DataTable;
+
+type DataTableProps = ComponentProps>;
+
+const ResourceBucketDataTable = ({ headers, rows, ...props }: DataTableProps) => {
+ const bucketColumnIndex = headers.findIndex(({ key }) => key === 'bucket');
+ const displayRows =
+ bucketColumnIndex === -1
+ ? rows
+ : rows.map((row) => ({
+ ...row,
+ content: row.content.map((cell, index) =>
+ index === bucketColumnIndex && cell.type === 'text'
+ ? {
+ ...cell,
+ content: {
+ ...cell.content,
+ text: getResourceBucketName(cell.content.text ?? ''),
+ },
+ }
+ : cell
+ ),
+ }));
+
+ return DefaultDataTable ? (
+
+ ) : null;
+};
+
const { StorageBrowser } = createStorageBrowser({
+ components: {
+ DataTable: ResourceBucketDataTable,
+ },
config: createAmplifyAuthAdapter(),
});
@@ -89,6 +142,7 @@ const storageBrowserDisplayText: StorageBrowserDisplayText = {
tableColumnNameHeader: 'Nombre',
tableColumnSizeHeader: 'Tamaño',
tableColumnTypeHeader: 'Tipo',
+ getTitle: ({ current, key }) => key || getResourceBucketName(current?.bucket ?? ''),
getActionListItemLabel: (key = '') => {
const labels: Record = {
Copy: 'Copiar',
From 3ff587f05d51faf8c68fb1d909994ab4a5b82f7d Mon Sep 17 00:00:00 2001
From: rcarvajalp <166424809+rcarvajalp@users.noreply.github.com>
Date: Mon, 11 May 2026 14:39:57 -0600
Subject: [PATCH 06/31] Remove reusable bucket name constants
---
amplify/backend.ts | 3 +++
src/App.tsx | 54 ++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 57 insertions(+)
diff --git a/amplify/backend.ts b/amplify/backend.ts
index 3f569c7..0c49596 100644
--- a/amplify/backend.ts
+++ b/amplify/backend.ts
@@ -11,6 +11,9 @@ const backend = defineBackend({
secondaryStorage,
});
+// Do not override cfnBucket.bucketName here. S3 bucket names are globally unique,
+// and Amplify's generated physical names avoid collisions during deployments.
+
const { cfnUserPool } = backend.auth.resources.cfnResources;
cfnUserPool.adminCreateUserConfig = {
diff --git a/src/App.tsx b/src/App.tsx
index 437f94b..50c7f44 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,4 +1,5 @@
import {
+ componentsDefault,
createAmplifyAuthAdapter,
createStorageBrowser,
} from '@aws-amplify/ui-react-storage/browser';
@@ -11,12 +12,64 @@ import { Amplify } from 'aws-amplify';
import { I18n } from 'aws-amplify/utils';
import { Authenticator, Button, translations } from '@aws-amplify/ui-react';
import fraudenLogo from './assets/frauden-logo.svg';
+import type { ComponentProps } from 'react';
Amplify.configure(config);
I18n.putVocabularies(translations);
I18n.setLanguage('es');
+const resourceBucketNames = ['frauden-bucket', 'frauden-expedientes'] as const;
+
+const getResourceBucketName = (bucketName: string) => {
+ const comparableBucketName = bucketName.toLowerCase().replace(/[^a-z0-9]/g, '');
+
+ return (
+ resourceBucketNames.find((resourceBucketName) => {
+ const comparableResourceBucketName = resourceBucketName
+ .toLowerCase()
+ .replace(/[^a-z0-9]/g, '');
+
+ return (
+ bucketName === resourceBucketName ||
+ comparableBucketName.includes(comparableResourceBucketName)
+ );
+ }) ?? bucketName
+ );
+};
+
+const DefaultDataTable = componentsDefault.DataTable;
+
+type DataTableProps = ComponentProps>;
+
+const ResourceBucketDataTable = ({ headers, rows, ...props }: DataTableProps) => {
+ const bucketColumnIndex = headers.findIndex(({ key }) => key === 'bucket');
+ const displayRows =
+ bucketColumnIndex === -1
+ ? rows
+ : rows.map((row) => ({
+ ...row,
+ content: row.content.map((cell, index) =>
+ index === bucketColumnIndex && cell.type === 'text'
+ ? {
+ ...cell,
+ content: {
+ ...cell.content,
+ text: getResourceBucketName(cell.content.text ?? ''),
+ },
+ }
+ : cell
+ ),
+ }));
+
+ return DefaultDataTable ? (
+
+ ) : null;
+};
+
const { StorageBrowser } = createStorageBrowser({
+ components: {
+ DataTable: ResourceBucketDataTable,
+ },
config: createAmplifyAuthAdapter(),
});
@@ -89,6 +142,7 @@ const storageBrowserDisplayText: StorageBrowserDisplayText = {
tableColumnNameHeader: 'Nombre',
tableColumnSizeHeader: 'Tamaño',
tableColumnTypeHeader: 'Tipo',
+ getTitle: ({ current, key }) => key || getResourceBucketName(current?.bucket ?? ''),
getActionListItemLabel: (key = '') => {
const labels: Record = {
Copy: 'Copiar',
From d3098d1207caabde3c86e75f65a4bdede08517cd Mon Sep 17 00:00:00 2001
From: rcarvajalp
Date: Mon, 11 May 2026 20:05:32 -0600
Subject: [PATCH 07/31] Revert "Merge pull request #5 from
rcarvajalp/codex/locate-buckets-without-prefixes-or-suffixes-r8f952"
This reverts commit 094eac468a9548964f52455cf36ca062ac304b57, reversing
changes made to 2b5b6eb3af63cba188a2c9021730ffe313625bf5.
---
src/App.tsx | 54 -----------------------------------------------------
1 file changed, 54 deletions(-)
diff --git a/src/App.tsx b/src/App.tsx
index 50c7f44..437f94b 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,5 +1,4 @@
import {
- componentsDefault,
createAmplifyAuthAdapter,
createStorageBrowser,
} from '@aws-amplify/ui-react-storage/browser';
@@ -12,64 +11,12 @@ import { Amplify } from 'aws-amplify';
import { I18n } from 'aws-amplify/utils';
import { Authenticator, Button, translations } from '@aws-amplify/ui-react';
import fraudenLogo from './assets/frauden-logo.svg';
-import type { ComponentProps } from 'react';
Amplify.configure(config);
I18n.putVocabularies(translations);
I18n.setLanguage('es');
-const resourceBucketNames = ['frauden-bucket', 'frauden-expedientes'] as const;
-
-const getResourceBucketName = (bucketName: string) => {
- const comparableBucketName = bucketName.toLowerCase().replace(/[^a-z0-9]/g, '');
-
- return (
- resourceBucketNames.find((resourceBucketName) => {
- const comparableResourceBucketName = resourceBucketName
- .toLowerCase()
- .replace(/[^a-z0-9]/g, '');
-
- return (
- bucketName === resourceBucketName ||
- comparableBucketName.includes(comparableResourceBucketName)
- );
- }) ?? bucketName
- );
-};
-
-const DefaultDataTable = componentsDefault.DataTable;
-
-type DataTableProps = ComponentProps>;
-
-const ResourceBucketDataTable = ({ headers, rows, ...props }: DataTableProps) => {
- const bucketColumnIndex = headers.findIndex(({ key }) => key === 'bucket');
- const displayRows =
- bucketColumnIndex === -1
- ? rows
- : rows.map((row) => ({
- ...row,
- content: row.content.map((cell, index) =>
- index === bucketColumnIndex && cell.type === 'text'
- ? {
- ...cell,
- content: {
- ...cell.content,
- text: getResourceBucketName(cell.content.text ?? ''),
- },
- }
- : cell
- ),
- }));
-
- return DefaultDataTable ? (
-
- ) : null;
-};
-
const { StorageBrowser } = createStorageBrowser({
- components: {
- DataTable: ResourceBucketDataTable,
- },
config: createAmplifyAuthAdapter(),
});
@@ -142,7 +89,6 @@ const storageBrowserDisplayText: StorageBrowserDisplayText = {
tableColumnNameHeader: 'Nombre',
tableColumnSizeHeader: 'Tamaño',
tableColumnTypeHeader: 'Tipo',
- getTitle: ({ current, key }) => key || getResourceBucketName(current?.bucket ?? ''),
getActionListItemLabel: (key = '') => {
const labels: Record = {
Copy: 'Copiar',
From 47fef5c40b7bb22146717d17b44b6ff740fcecc5 Mon Sep 17 00:00:00 2001
From: rcarvajalp
Date: Mon, 11 May 2026 20:14:45 -0600
Subject: [PATCH 08/31] Revert "Merge pull request #4 from
rcarvajalp/codex/locate-buckets-without-prefixes-or-suffixes"
This reverts commit 2b5b6eb3af63cba188a2c9021730ffe313625bf5, reversing
changes made to 512a08da91d5b9c6c787fc4ebb3bb71ba97d94bd.
---
amplify/backend.ts | 7 +------
amplify/storage/resource.ts | 7 ++-----
2 files changed, 3 insertions(+), 11 deletions(-)
diff --git a/amplify/backend.ts b/amplify/backend.ts
index 6e9e694..0c49596 100644
--- a/amplify/backend.ts
+++ b/amplify/backend.ts
@@ -1,11 +1,6 @@
import { defineBackend } from '@aws-amplify/backend';
import { auth } from './auth/resource';
-import {
- secondaryStorage,
- secondaryStorageBucketName,
- storage,
- storageBucketName,
-} from './storage/resource';
+import { storage, secondaryStorage } from './storage/resource';
/**
* @see https://docs.amplify.aws/react/build-a-backend/ to add storage, functions, and more
diff --git a/amplify/storage/resource.ts b/amplify/storage/resource.ts
index fa1eef0..8dd2ab6 100644
--- a/amplify/storage/resource.ts
+++ b/amplify/storage/resource.ts
@@ -1,10 +1,7 @@
import { defineStorage } from '@aws-amplify/backend';
-export const storageBucketName = 'frauden-bucket';
-export const secondaryStorageBucketName = 'frauden-expedientes';
-
export const storage = defineStorage({
- name: storageBucketName,
+ name: 'frauden-bucket',
isDefault: true,
access: (allow) => ({
'doctrina/*': [
@@ -27,7 +24,7 @@ export const storage = defineStorage({
});
export const secondaryStorage = defineStorage({
- name: secondaryStorageBucketName,
+ name: 'frauden-expedientes',
access: (allow) => ({
'publico/*': [allow.authenticated.to(['read', 'write', 'delete'])],
'confidencial/*': [allow.groups(['admin']).to(['read', 'write', 'delete'])],
From 7fa26ae5567371ab55dc8bee63af7a81b89a107e Mon Sep 17 00:00:00 2001
From: rcarvajalp
Date: Mon, 11 May 2026 20:19:38 -0600
Subject: [PATCH 09/31] quitar la palabra clave bucket del nombre del storage
---
amplify/storage/resource.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/amplify/storage/resource.ts b/amplify/storage/resource.ts
index 8dd2ab6..5ad5951 100644
--- a/amplify/storage/resource.ts
+++ b/amplify/storage/resource.ts
@@ -1,7 +1,7 @@
import { defineStorage } from '@aws-amplify/backend';
export const storage = defineStorage({
- name: 'frauden-bucket',
+ name: 'frauden',
isDefault: true,
access: (allow) => ({
'doctrina/*': [
From dd647df551322a7c8ff0aa25388b2a35bb290c1b Mon Sep 17 00:00:00 2001
From: rcarvajalp <166424809+rcarvajalp@users.noreply.github.com>
Date: Mon, 11 May 2026 20:41:50 -0600
Subject: [PATCH 10/31] Tighten hero title layout
---
src/App.css | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/src/App.css b/src/App.css
index 3834858..82fec7c 100644
--- a/src/App.css
+++ b/src/App.css
@@ -138,8 +138,8 @@ body::before {
.hero-card {
position: relative;
overflow: hidden;
- margin: 1.25rem 0;
- padding: clamp(1.5rem, 4vw, 3.75rem);
+ margin: 1rem 0;
+ padding: clamp(1rem, 2.5vw, 2.25rem) clamp(1.25rem, 4vw, 3.75rem);
color: white;
background:
linear-gradient(135deg, rgba(16, 25, 54, 0.94), rgba(31, 45, 95, 0.88)),
@@ -163,7 +163,7 @@ body::before {
.hero-card > div {
position: relative;
z-index: 1;
- max-width: 50rem;
+ width: 100%;
}
.hero-card .eyebrow {
@@ -171,7 +171,8 @@ body::before {
}
.hero-card h1 {
- max-width: 48rem;
+ width: 100%;
+ max-width: none;
margin: 0;
font-size: clamp(2rem, 5vw, 4.75rem);
line-height: 0.96;
From 648c17a13a2e6bed91f645834c07b0ced30c01d5 Mon Sep 17 00:00:00 2001
From: rcarvajalp <166424809+rcarvajalp@users.noreply.github.com>
Date: Mon, 11 May 2026 22:08:35 -0600
Subject: [PATCH 11/31] Show storage bucket friendly names
---
amplify/backend.ts | 7 +----
src/App.css | 23 ++++++++++------
src/App.tsx | 66 ++++++++++++++++++++++++++--------------------
3 files changed, 53 insertions(+), 43 deletions(-)
diff --git a/amplify/backend.ts b/amplify/backend.ts
index 6e9e694..4b1250e 100644
--- a/amplify/backend.ts
+++ b/amplify/backend.ts
@@ -1,11 +1,6 @@
import { defineBackend } from '@aws-amplify/backend';
import { auth } from './auth/resource';
-import {
- secondaryStorage,
- secondaryStorageBucketName,
- storage,
- storageBucketName,
-} from './storage/resource';
+import { secondaryStorage, storage } from './storage/resource';
/**
* @see https://docs.amplify.aws/react/build-a-backend/ to add storage, functions, and more
diff --git a/src/App.css b/src/App.css
index 3834858..0564c8e 100644
--- a/src/App.css
+++ b/src/App.css
@@ -1,7 +1,13 @@
:root {
color-scheme: light;
font-family:
- Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ Inter,
+ ui-sans-serif,
+ system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ 'Segoe UI',
+ sans-serif;
--frauden-navy: #101936;
--frauden-navy-soft: #1f2d5f;
--frauden-blue: #26366f;
@@ -47,7 +53,7 @@ body::before {
inset: 0;
z-index: -1;
pointer-events: none;
- content: "";
+ content: '';
background:
linear-gradient(115deg, transparent 0 62%, rgba(16, 25, 54, 0.08) 62% 100%),
radial-gradient(circle at 86% 10%, rgba(38, 54, 111, 0.14), transparent 20rem);
@@ -138,8 +144,8 @@ body::before {
.hero-card {
position: relative;
overflow: hidden;
- margin: 1.25rem 0;
- padding: clamp(1.5rem, 4vw, 3.75rem);
+ margin: 1rem 0;
+ padding: clamp(1rem, 2.5vw, 2.25rem) clamp(1.25rem, 4vw, 3.75rem);
color: white;
background:
linear-gradient(135deg, rgba(16, 25, 54, 0.94), rgba(31, 45, 95, 0.88)),
@@ -154,7 +160,7 @@ body::before {
right: -10%;
width: 27rem;
height: 27rem;
- content: "";
+ content: '';
background: radial-gradient(circle, rgba(255, 255, 255, 0.2), transparent 62%);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 999px;
@@ -163,7 +169,7 @@ body::before {
.hero-card > div {
position: relative;
z-index: 1;
- max-width: 50rem;
+ width: 100%;
}
.hero-card .eyebrow {
@@ -171,7 +177,8 @@ body::before {
}
.hero-card h1 {
- max-width: 48rem;
+ width: 100%;
+ max-width: none;
margin: 0;
font-size: clamp(2rem, 5vw, 4.75rem);
line-height: 0.96;
@@ -199,7 +206,7 @@ body::before {
border-radius: 0.85rem;
}
-.storage-card :is(.amplify-button--primary, [data-variation="primary"]) {
+.storage-card :is(.amplify-button--primary, [data-variation='primary']) {
background: linear-gradient(135deg, var(--frauden-navy), var(--frauden-blue));
border-color: transparent;
}
diff --git a/src/App.tsx b/src/App.tsx
index 50c7f44..a8739c0 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -18,23 +18,13 @@ Amplify.configure(config);
I18n.putVocabularies(translations);
I18n.setLanguage('es');
-const resourceBucketNames = ['frauden-bucket', 'frauden-expedientes'] as const;
-
-const getResourceBucketName = (bucketName: string) => {
- const comparableBucketName = bucketName.toLowerCase().replace(/[^a-z0-9]/g, '');
-
- return (
- resourceBucketNames.find((resourceBucketName) => {
- const comparableResourceBucketName = resourceBucketName
- .toLowerCase()
- .replace(/[^a-z0-9]/g, '');
-
- return (
- bucketName === resourceBucketName ||
- comparableBucketName.includes(comparableResourceBucketName)
- );
- }) ?? bucketName
+const getBucketFriendlyName = (bucketName: string) => {
+ const buckets = Amplify.getConfig().Storage?.S3?.buckets;
+ const bucketEntry = Object.entries(buckets ?? {}).find(
+ ([, { bucketName: configuredBucketName }]) => configuredBucketName === bucketName
);
+
+ return bucketEntry?.[0] ?? bucketName;
};
const DefaultDataTable = componentsDefault.DataTable;
@@ -43,22 +33,40 @@ type DataTableProps = ComponentProps>;
const ResourceBucketDataTable = ({ headers, rows, ...props }: DataTableProps) => {
const bucketColumnIndex = headers.findIndex(({ key }) => key === 'bucket');
+ const folderColumnIndex = headers.findIndex(({ key }) => key === 'folder');
const displayRows =
- bucketColumnIndex === -1
+ bucketColumnIndex === -1 && folderColumnIndex === -1
? rows
: rows.map((row) => ({
...row,
- content: row.content.map((cell, index) =>
- index === bucketColumnIndex && cell.type === 'text'
- ? {
- ...cell,
- content: {
- ...cell.content,
- text: getResourceBucketName(cell.content.text ?? ''),
- },
- }
- : cell
- ),
+ content: row.content.map((cell, index) => {
+ const isBucketColumn = index === bucketColumnIndex;
+ const isFolderColumn = index === folderColumnIndex;
+
+ if (!isBucketColumn && !isFolderColumn) return cell;
+
+ if (cell.type === 'text') {
+ return {
+ ...cell,
+ content: {
+ ...cell.content,
+ text: getBucketFriendlyName(cell.content.text ?? ''),
+ },
+ };
+ }
+
+ if (cell.type === 'button') {
+ return {
+ ...cell,
+ content: {
+ ...cell.content,
+ label: getBucketFriendlyName(cell.content.label ?? ''),
+ },
+ };
+ }
+
+ return cell;
+ }),
}));
return DefaultDataTable ? (
@@ -142,7 +150,7 @@ const storageBrowserDisplayText: StorageBrowserDisplayText = {
tableColumnNameHeader: 'Nombre',
tableColumnSizeHeader: 'Tamaño',
tableColumnTypeHeader: 'Tipo',
- getTitle: ({ current, key }) => key || getResourceBucketName(current?.bucket ?? ''),
+ getTitle: ({ current, key }) => key || getBucketFriendlyName(current?.bucket ?? ''),
getActionListItemLabel: (key = '') => {
const labels: Record = {
Copy: 'Copiar',
From 1db3610757386831a35a1e2b685e15b1c1970693 Mon Sep 17 00:00:00 2001
From: rcarvajalp <166424809+rcarvajalp@users.noreply.github.com>
Date: Mon, 11 May 2026 22:25:56 -0600
Subject: [PATCH 12/31] Fix storage browser data table override
---
src/App.tsx | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/src/App.tsx b/src/App.tsx
index 0ecafd6..48218ee 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,10 +1,12 @@
import {
+ componentsDefault,
createAmplifyAuthAdapter,
createStorageBrowser,
} from '@aws-amplify/ui-react-storage/browser';
import '@aws-amplify/ui-react/styles.css';
import '@aws-amplify/ui-react-storage/styles.css';
import './App.css';
+import type { ComponentProps } from 'react';
import config from '../amplify_outputs.json';
import { Amplify } from 'aws-amplify';
@@ -74,6 +76,10 @@ const ResourceBucketDataTable = ({ headers, rows, ...props }: DataTableProps) =>
const { StorageBrowser } = createStorageBrowser({
config: createAmplifyAuthAdapter(),
+ components: {
+ ...componentsDefault,
+ DataTable: ResourceBucketDataTable,
+ },
});
type StorageBrowserDisplayText = NonNullable[0]['displayText']>;
From a3884c767525c461d9f768b0a4e24d238a17f651 Mon Sep 17 00:00:00 2001
From: rcarvajalp <166424809+rcarvajalp@users.noreply.github.com>
Date: Mon, 11 May 2026 23:00:55 -0600
Subject: [PATCH 13/31] Fix storage browser friendly bucket labels
---
src/App.tsx | 101 +++++++++++++++++++++++++++++++++++++---------------
1 file changed, 72 insertions(+), 29 deletions(-)
diff --git a/src/App.tsx b/src/App.tsx
index 48218ee..cc97e0a 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -18,56 +18,99 @@ Amplify.configure(config);
I18n.putVocabularies(translations);
I18n.setLanguage('es');
+type AmplifyBucketInfo = {
+ bucketName?: string;
+};
+
const getBucketFriendlyName = (bucketName: string) => {
- const buckets = Amplify.getConfig().Storage?.S3?.buckets;
- const bucketEntry = Object.entries(buckets ?? {}).find(
- ([, { bucketName: configuredBucketName }]) => configuredBucketName === bucketName
+ if (!bucketName) return bucketName;
+
+ const storageConfig = Amplify.getConfig().Storage?.S3;
+ const buckets = storageConfig?.buckets ?? {};
+ const bucketEntry = Object.entries(buckets).find(
+ ([, bucketInfo]: [string, AmplifyBucketInfo]) => bucketInfo?.bucketName === bucketName
);
+ // Amplify Gen 2 stores the user-facing storage resource name in the
+ // `amplify:friendly-name` tag and emits that same value as the key in
+ // `Storage.S3.buckets` inside amplify_outputs.json. Fall back to the real
+ // bucket name if the bucket is not present in the generated outputs.
return bucketEntry?.[0] ?? bucketName;
};
const DefaultDataTable = componentsDefault.DataTable;
type DataTableProps = ComponentProps>;
+type DataTableRow = DataTableProps['rows'][number];
+type DataTableCell = DataTableRow['content'][number];
+
+const getTextCellValue = (cell: DataTableCell | undefined) => {
+ if (cell?.type === 'text') return cell.content.text;
+ if (cell?.type === 'button') return cell.content.label;
+
+ return undefined;
+};
+
+const withCellDisplayText = (cell: DataTableCell, displayText: string): DataTableCell => {
+ if (cell.type === 'text') {
+ return {
+ ...cell,
+ content: {
+ ...cell.content,
+ text: displayText,
+ },
+ };
+ }
+
+ if (cell.type === 'button') {
+ return {
+ ...cell,
+ content: {
+ ...cell.content,
+ label: displayText,
+ },
+ };
+ }
+
+ return cell;
+};
const ResourceBucketDataTable = ({ headers, rows, ...props }: DataTableProps) => {
const bucketColumnIndex = headers.findIndex(({ key }) => key === 'bucket');
const folderColumnIndex = headers.findIndex(({ key }) => key === 'folder');
- const displayRows =
- bucketColumnIndex === -1 && folderColumnIndex === -1
- ? rows
- : rows.map((row) => ({
+ const isLocationsTable =
+ bucketColumnIndex !== -1 &&
+ folderColumnIndex !== -1 &&
+ headers.some(({ key }) => key === 'permission');
+
+ const displayRows = !isLocationsTable
+ ? rows
+ : rows.map((row) => {
+ const bucketName = getTextCellValue(row.content[bucketColumnIndex]);
+ const friendlyBucketName = getBucketFriendlyName(bucketName ?? '');
+
+ return {
...row,
content: row.content.map((cell, index) => {
- const isBucketColumn = index === bucketColumnIndex;
- const isFolderColumn = index === folderColumnIndex;
-
- if (!isBucketColumn && !isFolderColumn) return cell;
-
- if (cell.type === 'text') {
- return {
- ...cell,
- content: {
- ...cell.content,
- text: getBucketFriendlyName(cell.content.text ?? ''),
- },
- };
+ if (index === bucketColumnIndex) {
+ return withCellDisplayText(cell, friendlyBucketName);
}
- if (cell.type === 'button') {
- return {
- ...cell,
- content: {
- ...cell.content,
- label: getBucketFriendlyName(cell.content.label ?? ''),
- },
- };
+ const folderDisplayValue = getTextCellValue(cell);
+
+ // In the Locations view, Amplify uses the real bucket name as the
+ // folder button label only for bucket-level locations where there is
+ // no prefix. Prefix rows such as `doctrina/` must stay untouched;
+ // otherwise the destination/location data can become visually
+ // misleading and hard to navigate.
+ if (index === folderColumnIndex && folderDisplayValue === bucketName) {
+ return withCellDisplayText(cell, friendlyBucketName);
}
return cell;
}),
- }));
+ };
+ });
return DefaultDataTable ? (
From b9ff508cf462b26cd8d32c9f58059327dc024ce7 Mon Sep 17 00:00:00 2001
From: rcarvajalp <166424809+rcarvajalp@users.noreply.github.com>
Date: Mon, 11 May 2026 23:18:09 -0600
Subject: [PATCH 14/31] Show real bucket names in storage browser
---
src/App.tsx | 108 +---------------------------------------------------
1 file changed, 2 insertions(+), 106 deletions(-)
diff --git a/src/App.tsx b/src/App.tsx
index cc97e0a..fe9f635 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -6,8 +6,6 @@ import {
import '@aws-amplify/ui-react/styles.css';
import '@aws-amplify/ui-react-storage/styles.css';
import './App.css';
-import type { ComponentProps } from 'react';
-
import config from '../amplify_outputs.json';
import { Amplify } from 'aws-amplify';
import { I18n } from 'aws-amplify/utils';
@@ -18,111 +16,9 @@ Amplify.configure(config);
I18n.putVocabularies(translations);
I18n.setLanguage('es');
-type AmplifyBucketInfo = {
- bucketName?: string;
-};
-
-const getBucketFriendlyName = (bucketName: string) => {
- if (!bucketName) return bucketName;
-
- const storageConfig = Amplify.getConfig().Storage?.S3;
- const buckets = storageConfig?.buckets ?? {};
- const bucketEntry = Object.entries(buckets).find(
- ([, bucketInfo]: [string, AmplifyBucketInfo]) => bucketInfo?.bucketName === bucketName
- );
-
- // Amplify Gen 2 stores the user-facing storage resource name in the
- // `amplify:friendly-name` tag and emits that same value as the key in
- // `Storage.S3.buckets` inside amplify_outputs.json. Fall back to the real
- // bucket name if the bucket is not present in the generated outputs.
- return bucketEntry?.[0] ?? bucketName;
-};
-
-const DefaultDataTable = componentsDefault.DataTable;
-
-type DataTableProps = ComponentProps>;
-type DataTableRow = DataTableProps['rows'][number];
-type DataTableCell = DataTableRow['content'][number];
-
-const getTextCellValue = (cell: DataTableCell | undefined) => {
- if (cell?.type === 'text') return cell.content.text;
- if (cell?.type === 'button') return cell.content.label;
-
- return undefined;
-};
-
-const withCellDisplayText = (cell: DataTableCell, displayText: string): DataTableCell => {
- if (cell.type === 'text') {
- return {
- ...cell,
- content: {
- ...cell.content,
- text: displayText,
- },
- };
- }
-
- if (cell.type === 'button') {
- return {
- ...cell,
- content: {
- ...cell.content,
- label: displayText,
- },
- };
- }
-
- return cell;
-};
-
-const ResourceBucketDataTable = ({ headers, rows, ...props }: DataTableProps) => {
- const bucketColumnIndex = headers.findIndex(({ key }) => key === 'bucket');
- const folderColumnIndex = headers.findIndex(({ key }) => key === 'folder');
- const isLocationsTable =
- bucketColumnIndex !== -1 &&
- folderColumnIndex !== -1 &&
- headers.some(({ key }) => key === 'permission');
-
- const displayRows = !isLocationsTable
- ? rows
- : rows.map((row) => {
- const bucketName = getTextCellValue(row.content[bucketColumnIndex]);
- const friendlyBucketName = getBucketFriendlyName(bucketName ?? '');
-
- return {
- ...row,
- content: row.content.map((cell, index) => {
- if (index === bucketColumnIndex) {
- return withCellDisplayText(cell, friendlyBucketName);
- }
-
- const folderDisplayValue = getTextCellValue(cell);
-
- // In the Locations view, Amplify uses the real bucket name as the
- // folder button label only for bucket-level locations where there is
- // no prefix. Prefix rows such as `doctrina/` must stay untouched;
- // otherwise the destination/location data can become visually
- // misleading and hard to navigate.
- if (index === folderColumnIndex && folderDisplayValue === bucketName) {
- return withCellDisplayText(cell, friendlyBucketName);
- }
-
- return cell;
- }),
- };
- });
-
- return DefaultDataTable ? (
-
- ) : null;
-};
-
const { StorageBrowser } = createStorageBrowser({
config: createAmplifyAuthAdapter(),
- components: {
- ...componentsDefault,
- DataTable: ResourceBucketDataTable,
- },
+ components: componentsDefault,
});
type StorageBrowserDisplayText = NonNullable[0]['displayText']>;
@@ -194,7 +90,7 @@ const storageBrowserDisplayText: StorageBrowserDisplayText = {
tableColumnNameHeader: 'Nombre',
tableColumnSizeHeader: 'Tamaño',
tableColumnTypeHeader: 'Tipo',
- getTitle: ({ current, key }) => key || getBucketFriendlyName(current?.bucket ?? ''),
+ getTitle: ({ current, key }) => key || current?.bucket || '',
getActionListItemLabel: (key = '') => {
const labels: Record = {
Copy: 'Copiar',
From fa516b89e59d74f61ccbfb6a4eeafbb6f7f63f96 Mon Sep 17 00:00:00 2001
From: rcarvajalp <166424809+rcarvajalp@users.noreply.github.com>
Date: Tue, 12 May 2026 10:00:30 -0600
Subject: [PATCH 15/31] Hide bucket column in locations table
---
src/App.css | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/src/App.css b/src/App.css
index 0564c8e..fd8fec8 100644
--- a/src/App.css
+++ b/src/App.css
@@ -221,6 +221,10 @@ body::before {
background: #f4f6f9;
}
+.storage-card [data-testid='LOCATIONS_VIEW'] .storage-browser__table :is(th, td):nth-child(2) {
+ display: none;
+}
+
.amplify-authenticator {
min-height: 100vh;
background:
From bed39c18a1f2e861a64a4c37c4afa72d78f3c5cc Mon Sep 17 00:00:00 2001
From: rcarvajalp <166424809+rcarvajalp@users.noreply.github.com>
Date: Tue, 12 May 2026 10:26:46 -0600
Subject: [PATCH 16/31] Hide bucket column in folders table
---
src/App.css | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/App.css b/src/App.css
index fd8fec8..9ec5d70 100644
--- a/src/App.css
+++ b/src/App.css
@@ -221,7 +221,8 @@ body::before {
background: #f4f6f9;
}
-.storage-card [data-testid='LOCATIONS_VIEW'] .storage-browser__table :is(th, td):nth-child(2) {
+.storage-card [data-testid='LOCATIONS_VIEW'] :is(.amplify-storage-browser__table, .storage-browser__table)
+ :is(th, td):nth-child(2) {
display: none;
}
From a2a9d2aec6f89d6a884a99417f4afc5b7ee27de6 Mon Sep 17 00:00:00 2001
From: rcarvajalp <166424809+rcarvajalp@users.noreply.github.com>
Date: Tue, 12 May 2026 10:53:02 -0600
Subject: [PATCH 17/31] Add knowledge sync button
---
src/App.css | 60 +++++++++++++++++++++++++++++++++
src/App.tsx | 96 +++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 156 insertions(+)
diff --git a/src/App.css b/src/App.css
index 9ec5d70..605fc59 100644
--- a/src/App.css
+++ b/src/App.css
@@ -193,6 +193,66 @@ body::before {
line-height: 1.7;
}
+.knowledge-sync-panel {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 1rem;
+ padding: 1rem 1.1rem;
+ background: linear-gradient(135deg, rgba(16, 25, 54, 0.06), rgba(201, 204, 209, 0.18));
+ border: 1px solid rgba(16, 25, 54, 0.08);
+ border-radius: 1.25rem;
+}
+
+.knowledge-sync-panel p {
+ margin: 0;
+ color: var(--frauden-silver-dark);
+ line-height: 1.5;
+}
+
+.knowledge-sync-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ align-items: center;
+ justify-content: flex-end;
+}
+
+.knowledge-sync-button.amplify-button {
+ min-height: 3rem;
+ padding-inline: 1.25rem;
+ font-weight: 700;
+ box-shadow: 0 14px 30px rgba(16, 25, 54, 0.18);
+}
+
+.knowledge-sync-status {
+ max-width: 28rem;
+ padding: 0.7rem 0.85rem;
+ font-size: 0.92rem;
+ font-weight: 700;
+ border-radius: 0.85rem;
+}
+
+.knowledge-sync-status--error {
+ color: #8a1f11;
+ background: #fff0ed;
+ border: 1px solid #f3b3a8;
+}
+
+.knowledge-sync-status--info {
+ color: var(--frauden-navy);
+ background: #eef4ff;
+ border: 1px solid #c7d8f8;
+}
+
+.knowledge-sync-status--success {
+ color: #115f35;
+ background: #edfff5;
+ border: 1px solid #a8e2c0;
+}
+
.storage-card {
padding: clamp(0.85rem, 2vw, 1.4rem);
background: var(--frauden-surface);
diff --git a/src/App.tsx b/src/App.tsx
index fe9f635..c0c776f 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -6,8 +6,10 @@ import {
import '@aws-amplify/ui-react/styles.css';
import '@aws-amplify/ui-react-storage/styles.css';
import './App.css';
+import { useState } from 'react';
import config from '../amplify_outputs.json';
import { Amplify } from 'aws-amplify';
+import { fetchAuthSession } from 'aws-amplify/auth';
import { I18n } from 'aws-amplify/utils';
import { Authenticator, Button, translations } from '@aws-amplify/ui-react';
import fraudenLogo from './assets/frauden-logo.svg';
@@ -23,6 +25,13 @@ const { StorageBrowser } = createStorageBrowser({
type StorageBrowserDisplayText = NonNullable[0]['displayText']>;
+type KnowledgeSyncStatus = {
+ message: string;
+ type: 'error' | 'info' | 'success';
+} | null;
+
+const syncKnowledgeEndpoint = import.meta.env.VITE_SYNC_KNOWLEDGE_LAMBDA_URL?.trim();
+
const authenticatorComponents = {
Header() {
return (
@@ -33,6 +42,92 @@ const authenticatorComponents = {
},
};
+function KnowledgeSyncButton() {
+ const [isSyncing, setIsSyncing] = useState(false);
+ const [syncStatus, setSyncStatus] = useState(null);
+
+ const handleKnowledgeSync = async () => {
+ if (!syncKnowledgeEndpoint) {
+ setSyncStatus({
+ type: 'error',
+ message:
+ 'Configura VITE_SYNC_KNOWLEDGE_LAMBDA_URL con la URL de la Lambda para sincronizar.',
+ });
+ return;
+ }
+
+ setIsSyncing(true);
+ setSyncStatus({ type: 'info', message: 'Solicitando sincronización de conocimiento...' });
+
+ try {
+ const session = await fetchAuthSession();
+ const idToken = session.tokens?.idToken?.toString();
+
+ if (!idToken) {
+ throw new Error('No se encontró una sesión autenticada para invocar la Lambda.');
+ }
+
+ const response = await fetch(syncKnowledgeEndpoint, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${idToken}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ action: 'syncKnowledgeBase' }),
+ });
+
+ if (!response.ok) {
+ const errorMessage = await response.text();
+ throw new Error(
+ errorMessage || `La Lambda respondió con estado ${response.status}. Intenta nuevamente.`
+ );
+ }
+
+ setSyncStatus({
+ type: 'success',
+ message: 'Sincronización de conocimiento iniciada correctamente.',
+ });
+ } catch (error) {
+ setSyncStatus({
+ type: 'error',
+ message:
+ error instanceof Error
+ ? error.message
+ : 'No se pudo iniciar la sincronización de conocimiento.',
+ });
+ } finally {
+ setIsSyncing(false);
+ }
+ };
+
+ return (
+
+
+
Base de conocimiento
+
Invoca la Lambda de sincronización usando tu sesión autenticada.
+
+
+
+ {isSyncing ? 'Sincronizando...' : 'Sincronizar conocimiento'}
+
+ {syncStatus ? (
+
+ {syncStatus.message}
+
+ ) : null}
+
+
+ );
+}
+
const storageBrowserDisplayText: StorageBrowserDisplayText = {
LocationsView: {
title: 'Inicio',
@@ -272,6 +367,7 @@ function App() {
From 7d66a4d4cc349cdc7d3917c5d52246719aaafe90 Mon Sep 17 00:00:00 2001
From: rcarvajalp <166424809+rcarvajalp@users.noreply.github.com>
Date: Sat, 16 May 2026 18:55:57 -0600
Subject: [PATCH 18/31] Add knowledge sync button tooltip
---
src/App.tsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/App.tsx b/src/App.tsx
index c0c776f..5873c0d 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -31,6 +31,8 @@ type KnowledgeSyncStatus = {
} | null;
const syncKnowledgeEndpoint = import.meta.env.VITE_SYNC_KNOWLEDGE_LAMBDA_URL?.trim();
+const syncKnowledgeTooltip =
+ 'Cuando agregues nuevos documentos o elimines debes sincronizar la base de conocimiento para cargar la nueva información';
const authenticatorComponents = {
Header() {
@@ -104,12 +106,12 @@ function KnowledgeSyncButton() {
Base de conocimiento
-
Invoca la Lambda de sincronización usando tu sesión autenticada.
From 6568376defb3904131ea9e02426bf6ad408d90e1 Mon Sep 17 00:00:00 2001
From: rcarvajalp <166424809+rcarvajalp@users.noreply.github.com>
Date: Sat, 16 May 2026 19:17:56 -0600
Subject: [PATCH 19/31] Restrict file deletion to eliminadores group
---
amplify/auth/resource.ts | 2 +-
amplify/storage/resource.ts | 27 ++++++++++++++++++++-------
2 files changed, 21 insertions(+), 8 deletions(-)
diff --git a/amplify/auth/resource.ts b/amplify/auth/resource.ts
index 8bbd191..e10a2e5 100644
--- a/amplify/auth/resource.ts
+++ b/amplify/auth/resource.ts
@@ -8,5 +8,5 @@ export const auth = defineAuth({
loginWith: {
email: true,
},
- groups: ['admin']
+ groups: ['admin', 'eliminadores'],
});
diff --git a/amplify/storage/resource.ts b/amplify/storage/resource.ts
index 5ad5951..a31485c 100644
--- a/amplify/storage/resource.ts
+++ b/amplify/storage/resource.ts
@@ -6,19 +6,23 @@ export const storage = defineStorage({
access: (allow) => ({
'doctrina/*': [
allow.authenticated.to(['read']),
- allow.groups(['admin']).to(['read', 'write', 'delete']),
+ allow.groups(['admin']).to(['read', 'write']),
+ allow.groups(['eliminadores']).to(['delete']),
],
'medios/*': [
allow.authenticated.to(['read']),
- allow.groups(['admin']).to(['read', 'write', 'delete']),
+ allow.groups(['admin']).to(['read', 'write']),
+ allow.groups(['eliminadores']).to(['delete']),
],
'jurisprudencia/*': [
allow.authenticated.to(['read']),
- allow.groups(['admin']).to(['read', 'write', 'delete']),
+ allow.groups(['admin']).to(['read', 'write']),
+ allow.groups(['eliminadores']).to(['delete']),
],
'legislacion/*': [
allow.authenticated.to(['read']),
- allow.groups(['admin']).to(['read', 'write', 'delete']),
+ allow.groups(['admin']).to(['read', 'write']),
+ allow.groups(['eliminadores']).to(['delete']),
],
}),
});
@@ -26,8 +30,17 @@ export const storage = defineStorage({
export const secondaryStorage = defineStorage({
name: 'frauden-expedientes',
access: (allow) => ({
- 'publico/*': [allow.authenticated.to(['read', 'write', 'delete'])],
- 'confidencial/*': [allow.groups(['admin']).to(['read', 'write', 'delete'])],
- 'privado/{entity_id}/*': [allow.entity('identity').to(['read', 'write', 'delete'])],
+ 'publico/*': [
+ allow.authenticated.to(['read', 'write']),
+ allow.groups(['eliminadores']).to(['delete']),
+ ],
+ 'confidencial/*': [
+ allow.groups(['admin']).to(['read', 'write']),
+ allow.groups(['eliminadores']).to(['delete']),
+ ],
+ 'privado/{entity_id}/*': [
+ allow.entity('identity').to(['read', 'write']),
+ allow.groups(['eliminadores']).to(['delete']),
+ ],
}),
});
From ff5b06f18814bc8c78251a800a2b3ad852a4a6e0 Mon Sep 17 00:00:00 2001
From: rcarvajalp
Date: Wed, 22 Jul 2026 03:38:23 -0600
Subject: [PATCH 20/31] Update Amplify branch linker runtime to Node.js 24
---
amplify/backend.ts | 15 ++++++++++++++-
1 file changed, 14 insertions(+), 1 deletion(-)
diff --git a/amplify/backend.ts b/amplify/backend.ts
index 0c49596..cda1063 100644
--- a/amplify/backend.ts
+++ b/amplify/backend.ts
@@ -1,6 +1,9 @@
import { defineBackend } from '@aws-amplify/backend';
+import { Aspects, IAspect } from 'aws-cdk-lib';
+import { CfnFunction } from 'aws-cdk-lib/aws-lambda';
+import { IConstruct } from 'constructs';
import { auth } from './auth/resource';
-import { storage, secondaryStorage } from './storage/resource';
+import { secondaryStorage, storage } from './storage/resource';
/**
* @see https://docs.amplify.aws/react/build-a-backend/ to add storage, functions, and more
@@ -14,6 +17,16 @@ const backend = defineBackend({
// Do not override cfnBucket.bucketName here. S3 bucket names are globally unique,
// and Amplify's generated physical names avoid collisions during deployments.
+class AmplifyBranchLinkerNodeRuntime implements IAspect {
+ visit(node: IConstruct) {
+ if (node instanceof CfnFunction && node.node.path.includes('AmplifyBranchLinker')) {
+ node.runtime = 'nodejs24.x';
+ }
+ }
+}
+
+Aspects.of(backend.stack).add(new AmplifyBranchLinkerNodeRuntime());
+
const { cfnUserPool } = backend.auth.resources.cfnResources;
cfnUserPool.adminCreateUserConfig = {
From 711516e4e9edfa7f46b8acef5d2075169d238e54 Mon Sep 17 00:00:00 2001
From: rcarvajalp
Date: Wed, 22 Jul 2026 04:10:04 -0600
Subject: [PATCH 21/31] Update knowledge sync and storage access
---
README.md | 2 +-
amplify/storage/resource.ts | 8 --------
src/App.tsx | 19 ++++++++++---------
3 files changed, 11 insertions(+), 18 deletions(-)
diff --git a/README.md b/README.md
index c797c37..c2ca9f6 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@ This template equips you with a foundational React application integrated with A
- More info on how to setup and configuration option: https://docs.amplify.aws/react/build-a-backend/auth/set-up-auth/
- **Storage**: Configured with multiple S3 buckets and granular access controls. The sample is configured with
- Default `frauden-bucket` storage bucket with `doctrina`, `medios`, `jurisprudencia`, and `legislacion` access paths for authenticated readers and admin read/write/delete access.
- - Secondary `frauden-expedientes` storage bucket with `publico`, `confidencial`, and owner-scoped `privado/{entity_id}` access paths, including delete permissions wherever write access is granted.
+ - Secondary `frauden-expedientes` storage bucket with owner-scoped `privado/{entity_id}` access paths, including delete permissions for the `eliminadores` group.
- More info on how to setup : https://docs.amplify.aws/react/build-a-backend/storage/set-up-storage/#building-your-storage-backend
- **UI Components**: Pre-integrated Amplify UI React components including:
- Authenticator for sign-in/sign-up flows
diff --git a/amplify/storage/resource.ts b/amplify/storage/resource.ts
index a31485c..9577154 100644
--- a/amplify/storage/resource.ts
+++ b/amplify/storage/resource.ts
@@ -30,14 +30,6 @@ export const storage = defineStorage({
export const secondaryStorage = defineStorage({
name: 'frauden-expedientes',
access: (allow) => ({
- 'publico/*': [
- allow.authenticated.to(['read', 'write']),
- allow.groups(['eliminadores']).to(['delete']),
- ],
- 'confidencial/*': [
- allow.groups(['admin']).to(['read', 'write']),
- allow.groups(['eliminadores']).to(['delete']),
- ],
'privado/{entity_id}/*': [
allow.entity('identity').to(['read', 'write']),
allow.groups(['eliminadores']).to(['delete']),
diff --git a/src/App.tsx b/src/App.tsx
index 5873c0d..114e555 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -33,6 +33,9 @@ type KnowledgeSyncStatus = {
const syncKnowledgeEndpoint = import.meta.env.VITE_SYNC_KNOWLEDGE_LAMBDA_URL?.trim();
const syncKnowledgeTooltip =
'Cuando agregues nuevos documentos o elimines debes sincronizar la base de conocimiento para cargar la nueva información';
+const syncKnowledgeRequestSentMessage =
+ 'Solicitud de sincronización enviada para todas las fuentes de datos de las 5 bases de conocimiento. Esto puede tardar un rato.';
+const syncKnowledgeButtonCooldownMs = 3000;
const authenticatorComponents = {
Header() {
@@ -69,7 +72,7 @@ function KnowledgeSyncButton() {
throw new Error('No se encontró una sesión autenticada para invocar la Lambda.');
}
- const response = await fetch(syncKnowledgeEndpoint, {
+ const syncRequest = fetch(syncKnowledgeEndpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${idToken}`,
@@ -78,17 +81,16 @@ function KnowledgeSyncButton() {
body: JSON.stringify({ action: 'syncKnowledgeBase' }),
});
- if (!response.ok) {
- const errorMessage = await response.text();
- throw new Error(
- errorMessage || `La Lambda respondió con estado ${response.status}. Intenta nuevamente.`
- );
- }
+ void syncRequest.catch((error) => {
+ console.warn('No se pudo leer la respuesta de sincronización.', error);
+ });
setSyncStatus({
type: 'success',
- message: 'Sincronización de conocimiento iniciada correctamente.',
+ message: syncKnowledgeRequestSentMessage,
});
+
+ window.setTimeout(() => setIsSyncing(false), syncKnowledgeButtonCooldownMs);
} catch (error) {
setSyncStatus({
type: 'error',
@@ -97,7 +99,6 @@ function KnowledgeSyncButton() {
? error.message
: 'No se pudo iniciar la sincronización de conocimiento.',
});
- } finally {
setIsSyncing(false);
}
};
From ba2cfee4b533f305645fe87ac876bea6f57950af Mon Sep 17 00:00:00 2001
From: rcarvajalp
Date: Wed, 22 Jul 2026 09:52:28 -0600
Subject: [PATCH 22/31] Restrict private expediente storage to gexpedientes
---
README.md | 2 +-
amplify/auth/resource.ts | 2 +-
amplify/backend.ts | 48 ++++++++++++++++++++++++++++++++++++++++
3 files changed, 50 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index c2ca9f6..133b61a 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@ This template equips you with a foundational React application integrated with A
- More info on how to setup and configuration option: https://docs.amplify.aws/react/build-a-backend/auth/set-up-auth/
- **Storage**: Configured with multiple S3 buckets and granular access controls. The sample is configured with
- Default `frauden-bucket` storage bucket with `doctrina`, `medios`, `jurisprudencia`, and `legislacion` access paths for authenticated readers and admin read/write/delete access.
- - Secondary `frauden-expedientes` storage bucket with owner-scoped `privado/{entity_id}` access paths, including delete permissions for the `eliminadores` group.
+ - Secondary `frauden-expedientes` storage bucket with owner-scoped `privado/{entity_id}` access paths. Read/write access is limited to users in the `gexpedientes` group for their own identity folder, with delete permissions for the `eliminadores` group.
- More info on how to setup : https://docs.amplify.aws/react/build-a-backend/storage/set-up-storage/#building-your-storage-backend
- **UI Components**: Pre-integrated Amplify UI React components including:
- Authenticator for sign-in/sign-up flows
diff --git a/amplify/auth/resource.ts b/amplify/auth/resource.ts
index e10a2e5..6374a83 100644
--- a/amplify/auth/resource.ts
+++ b/amplify/auth/resource.ts
@@ -8,5 +8,5 @@ export const auth = defineAuth({
loginWith: {
email: true,
},
- groups: ['admin', 'eliminadores'],
+ groups: ['admin', 'eliminadores', 'gexpedientes'],
});
diff --git a/amplify/backend.ts b/amplify/backend.ts
index cda1063..03bafee 100644
--- a/amplify/backend.ts
+++ b/amplify/backend.ts
@@ -1,5 +1,6 @@
import { defineBackend } from '@aws-amplify/backend';
import { Aspects, IAspect } from 'aws-cdk-lib';
+import { Effect, PolicyStatement } from 'aws-cdk-lib/aws-iam';
import { CfnFunction } from 'aws-cdk-lib/aws-lambda';
import { IConstruct } from 'constructs';
import { auth } from './auth/resource';
@@ -27,6 +28,53 @@ class AmplifyBranchLinkerNodeRuntime implements IAspect {
Aspects.of(backend.stack).add(new AmplifyBranchLinkerNodeRuntime());
+const expedientesBucket = backend.secondaryStorage.resources.bucket;
+const privateIdentityPrefix = 'privado/${cognito-identity.amazonaws.com:sub}';
+
+// Storage group rules replace {entity_id} with a wildcard. Keep the owner-scoped
+// storage output and enforce the gexpedientes gate on the IAM roles instead.
+backend.auth.resources.groups.gexpedientes.role.addToPrincipalPolicy(
+ new PolicyStatement({
+ effect: Effect.ALLOW,
+ actions: ['s3:GetObject', 's3:PutObject'],
+ resources: [`${expedientesBucket.bucketArn}/${privateIdentityPrefix}/*`],
+ })
+);
+
+backend.auth.resources.groups.gexpedientes.role.addToPrincipalPolicy(
+ new PolicyStatement({
+ effect: Effect.ALLOW,
+ actions: ['s3:ListBucket'],
+ resources: [expedientesBucket.bucketArn],
+ conditions: {
+ StringLike: {
+ 's3:prefix': [`${privateIdentityPrefix}/*`, `${privateIdentityPrefix}/`],
+ },
+ },
+ })
+);
+
+backend.auth.resources.authenticatedUserIamRole.addToPrincipalPolicy(
+ new PolicyStatement({
+ effect: Effect.DENY,
+ actions: ['s3:GetObject', 's3:PutObject'],
+ resources: [`${expedientesBucket.bucketArn}/privado/*`],
+ })
+);
+
+backend.auth.resources.authenticatedUserIamRole.addToPrincipalPolicy(
+ new PolicyStatement({
+ effect: Effect.DENY,
+ actions: ['s3:ListBucket'],
+ resources: [expedientesBucket.bucketArn],
+ conditions: {
+ StringLike: {
+ 's3:prefix': ['privado/*', 'privado/'],
+ },
+ },
+ })
+);
+
const { cfnUserPool } = backend.auth.resources.cfnResources;
cfnUserPool.adminCreateUserConfig = {
From 3c099eb167f8dadc137bae89340cbdd247ce0533 Mon Sep 17 00:00:00 2001
From: rcarvajalp
Date: Wed, 22 Jul 2026 10:07:43 -0600
Subject: [PATCH 23/31] Move expediente access restrictions to bucket policy
---
amplify/backend.ts | 22 ++++++++++++++++------
1 file changed, 16 insertions(+), 6 deletions(-)
diff --git a/amplify/backend.ts b/amplify/backend.ts
index 03bafee..7caeca0 100644
--- a/amplify/backend.ts
+++ b/amplify/backend.ts
@@ -1,6 +1,6 @@
import { defineBackend } from '@aws-amplify/backend';
import { Aspects, IAspect } from 'aws-cdk-lib';
-import { Effect, PolicyStatement } from 'aws-cdk-lib/aws-iam';
+import { ArnPrincipal, Effect, PolicyStatement } from 'aws-cdk-lib/aws-iam';
import { CfnFunction } from 'aws-cdk-lib/aws-lambda';
import { IConstruct } from 'constructs';
import { auth } from './auth/resource';
@@ -30,20 +30,28 @@ Aspects.of(backend.stack).add(new AmplifyBranchLinkerNodeRuntime());
const expedientesBucket = backend.secondaryStorage.resources.bucket;
const privateIdentityPrefix = 'privado/${cognito-identity.amazonaws.com:sub}';
+const gexpedientesPrincipal = new ArnPrincipal(
+ backend.auth.resources.groups.gexpedientes.role.roleArn
+);
+const authenticatedUsersPrincipal = new ArnPrincipal(
+ backend.auth.resources.authenticatedUserIamRole.roleArn
+);
// Storage group rules replace {entity_id} with a wildcard. Keep the owner-scoped
-// storage output and enforce the gexpedientes gate on the IAM roles instead.
-backend.auth.resources.groups.gexpedientes.role.addToPrincipalPolicy(
+// storage output and enforce the gexpedientes gate from the bucket policy.
+expedientesBucket.addToResourcePolicy(
new PolicyStatement({
effect: Effect.ALLOW,
+ principals: [gexpedientesPrincipal],
actions: ['s3:GetObject', 's3:PutObject'],
resources: [`${expedientesBucket.bucketArn}/${privateIdentityPrefix}/*`],
})
);
-backend.auth.resources.groups.gexpedientes.role.addToPrincipalPolicy(
+expedientesBucket.addToResourcePolicy(
new PolicyStatement({
effect: Effect.ALLOW,
+ principals: [gexpedientesPrincipal],
actions: ['s3:ListBucket'],
resources: [expedientesBucket.bucketArn],
conditions: {
@@ -54,17 +62,19 @@ backend.auth.resources.groups.gexpedientes.role.addToPrincipalPolicy(
})
);
-backend.auth.resources.authenticatedUserIamRole.addToPrincipalPolicy(
+expedientesBucket.addToResourcePolicy(
new PolicyStatement({
effect: Effect.DENY,
+ principals: [authenticatedUsersPrincipal],
actions: ['s3:GetObject', 's3:PutObject'],
resources: [`${expedientesBucket.bucketArn}/privado/*`],
})
);
-backend.auth.resources.authenticatedUserIamRole.addToPrincipalPolicy(
+expedientesBucket.addToResourcePolicy(
new PolicyStatement({
effect: Effect.DENY,
+ principals: [authenticatedUsersPrincipal],
actions: ['s3:ListBucket'],
resources: [expedientesBucket.bucketArn],
conditions: {
From 5bdbaabe7126f9da7b2d28c10b582e832e9068a4 Mon Sep 17 00:00:00 2001
From: rcarvajalp
Date: Wed, 22 Jul 2026 10:22:37 -0600
Subject: [PATCH 24/31] Isolate expediente access policies in custom stack
---
amplify/backend.ts | 92 +++++++++++++++++++++-------------------------
1 file changed, 42 insertions(+), 50 deletions(-)
diff --git a/amplify/backend.ts b/amplify/backend.ts
index 7caeca0..997c7f6 100644
--- a/amplify/backend.ts
+++ b/amplify/backend.ts
@@ -1,6 +1,6 @@
import { defineBackend } from '@aws-amplify/backend';
import { Aspects, IAspect } from 'aws-cdk-lib';
-import { ArnPrincipal, Effect, PolicyStatement } from 'aws-cdk-lib/aws-iam';
+import { Effect, Policy, PolicyStatement } from 'aws-cdk-lib/aws-iam';
import { CfnFunction } from 'aws-cdk-lib/aws-lambda';
import { IConstruct } from 'constructs';
import { auth } from './auth/resource';
@@ -29,61 +29,53 @@ class AmplifyBranchLinkerNodeRuntime implements IAspect {
Aspects.of(backend.stack).add(new AmplifyBranchLinkerNodeRuntime());
const expedientesBucket = backend.secondaryStorage.resources.bucket;
+const expedientesAccessStack = backend.createStack('expedientesAccess');
const privateIdentityPrefix = 'privado/${cognito-identity.amazonaws.com:sub}';
-const gexpedientesPrincipal = new ArnPrincipal(
- backend.auth.resources.groups.gexpedientes.role.roleArn
-);
-const authenticatedUsersPrincipal = new ArnPrincipal(
- backend.auth.resources.authenticatedUserIamRole.roleArn
-);
// Storage group rules replace {entity_id} with a wildcard. Keep the owner-scoped
-// storage output and enforce the gexpedientes gate from the bucket policy.
-expedientesBucket.addToResourcePolicy(
- new PolicyStatement({
- effect: Effect.ALLOW,
- principals: [gexpedientesPrincipal],
- actions: ['s3:GetObject', 's3:PutObject'],
- resources: [`${expedientesBucket.bucketArn}/${privateIdentityPrefix}/*`],
- })
-);
-
-expedientesBucket.addToResourcePolicy(
- new PolicyStatement({
- effect: Effect.ALLOW,
- principals: [gexpedientesPrincipal],
- actions: ['s3:ListBucket'],
- resources: [expedientesBucket.bucketArn],
- conditions: {
- StringLike: {
- 's3:prefix': [`${privateIdentityPrefix}/*`, `${privateIdentityPrefix}/`],
+// storage output and enforce the gexpedientes gate from a separate stack to
+// avoid a direct Auth <-> Storage dependency cycle.
+new Policy(expedientesAccessStack, 'GExpedientesPrivateFolderAccess', {
+ roles: [backend.auth.resources.groups.gexpedientes.role],
+ statements: [
+ new PolicyStatement({
+ effect: Effect.ALLOW,
+ actions: ['s3:GetObject', 's3:PutObject'],
+ resources: [`${expedientesBucket.bucketArn}/${privateIdentityPrefix}/*`],
+ }),
+ new PolicyStatement({
+ effect: Effect.ALLOW,
+ actions: ['s3:ListBucket'],
+ resources: [expedientesBucket.bucketArn],
+ conditions: {
+ StringLike: {
+ 's3:prefix': [`${privateIdentityPrefix}/*`, `${privateIdentityPrefix}/`],
+ },
},
- },
- })
-);
-
-expedientesBucket.addToResourcePolicy(
- new PolicyStatement({
- effect: Effect.DENY,
- principals: [authenticatedUsersPrincipal],
- actions: ['s3:GetObject', 's3:PutObject'],
- resources: [`${expedientesBucket.bucketArn}/privado/*`],
- })
-);
+ }),
+ ],
+});
-expedientesBucket.addToResourcePolicy(
- new PolicyStatement({
- effect: Effect.DENY,
- principals: [authenticatedUsersPrincipal],
- actions: ['s3:ListBucket'],
- resources: [expedientesBucket.bucketArn],
- conditions: {
- StringLike: {
- 's3:prefix': ['privado/*', 'privado/'],
+new Policy(expedientesAccessStack, 'AuthenticatedPrivateFolderDeny', {
+ roles: [backend.auth.resources.authenticatedUserIamRole],
+ statements: [
+ new PolicyStatement({
+ effect: Effect.DENY,
+ actions: ['s3:GetObject', 's3:PutObject'],
+ resources: [`${expedientesBucket.bucketArn}/privado/*`],
+ }),
+ new PolicyStatement({
+ effect: Effect.DENY,
+ actions: ['s3:ListBucket'],
+ resources: [expedientesBucket.bucketArn],
+ conditions: {
+ StringLike: {
+ 's3:prefix': ['privado/*', 'privado/'],
+ },
},
- },
- })
-);
+ }),
+ ],
+});
const { cfnUserPool } = backend.auth.resources.cfnResources;
From a1f7fd314cbcc7d180a8124d8c317dd061dba658 Mon Sep 17 00:00:00 2001
From: rcarvajalp <166424809+rcarvajalp@users.noreply.github.com>
Date: Wed, 22 Jul 2026 10:42:05 -0600
Subject: [PATCH 25/31] Stop recreating existing gexpedientes group
---
amplify/auth/resource.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/amplify/auth/resource.ts b/amplify/auth/resource.ts
index 6374a83..e10a2e5 100644
--- a/amplify/auth/resource.ts
+++ b/amplify/auth/resource.ts
@@ -8,5 +8,5 @@ export const auth = defineAuth({
loginWith: {
email: true,
},
- groups: ['admin', 'eliminadores', 'gexpedientes'],
+ groups: ['admin', 'eliminadores'],
});
From 756e523f59ccee876977fb2b785f96aab9f12e5e Mon Sep 17 00:00:00 2001
From: rcarvajalp <166424809+rcarvajalp@users.noreply.github.com>
Date: Wed, 22 Jul 2026 10:42:25 -0600
Subject: [PATCH 26/31] Link existing gexpedientes group role
---
amplify/backend.ts | 60 ++++++++++++++++++++++++++++++++++++++++------
1 file changed, 53 insertions(+), 7 deletions(-)
diff --git a/amplify/backend.ts b/amplify/backend.ts
index 997c7f6..3db1a7d 100644
--- a/amplify/backend.ts
+++ b/amplify/backend.ts
@@ -1,6 +1,11 @@
import { defineBackend } from '@aws-amplify/backend';
import { Aspects, IAspect } from 'aws-cdk-lib';
-import { Effect, Policy, PolicyStatement } from 'aws-cdk-lib/aws-iam';
+import {
+ AwsCustomResource,
+ AwsCustomResourcePolicy,
+ PhysicalResourceId,
+} from 'aws-cdk-lib/custom-resources';
+import { Effect, FederatedPrincipal, Policy, PolicyStatement, Role } from 'aws-cdk-lib/aws-iam';
import { CfnFunction } from 'aws-cdk-lib/aws-lambda';
import { IConstruct } from 'constructs';
import { auth } from './auth/resource';
@@ -18,25 +23,66 @@ const backend = defineBackend({
// Do not override cfnBucket.bucketName here. S3 bucket names are globally unique,
// and Amplify's generated physical names avoid collisions during deployments.
-class AmplifyBranchLinkerNodeRuntime implements IAspect {
+class GeneratedNodeRuntime implements IAspect {
visit(node: IConstruct) {
- if (node instanceof CfnFunction && node.node.path.includes('AmplifyBranchLinker')) {
+ if (
+ node instanceof CfnFunction &&
+ (node.runtime === 'nodejs18.x' || node.node.path.includes('AmplifyBranchLinker'))
+ ) {
node.runtime = 'nodejs24.x';
}
}
}
-Aspects.of(backend.stack).add(new AmplifyBranchLinkerNodeRuntime());
+Aspects.of(backend.stack).add(new GeneratedNodeRuntime());
const expedientesBucket = backend.secondaryStorage.resources.bucket;
const expedientesAccessStack = backend.createStack('expedientesAccess');
+const gexpedientesGroupName = 'gexpedientes';
const privateIdentityPrefix = 'privado/${cognito-identity.amazonaws.com:sub}';
// Storage group rules replace {entity_id} with a wildcard. Keep the owner-scoped
-// storage output and enforce the gexpedientes gate from a separate stack to
-// avoid a direct Auth <-> Storage dependency cycle.
+// storage output and enforce the gexpedientes gate from a separate stack. The
+// Cognito group already exists in the deployed User Pool, so link it to this
+// role instead of asking CloudFormation to create the group again.
+const gexpedientesRole = new Role(expedientesAccessStack, 'GExpedientesGroupRole', {
+ assumedBy: new FederatedPrincipal(
+ 'cognito-identity.amazonaws.com',
+ {
+ StringEquals: {
+ 'cognito-identity.amazonaws.com:aud': backend.auth.resources.identityPoolId,
+ },
+ 'ForAnyValue:StringLike': {
+ 'cognito-identity.amazonaws.com:amr': 'authenticated',
+ },
+ },
+ 'sts:AssumeRoleWithWebIdentity'
+ ),
+});
+
+const linkGExpedientesGroupRoleCall = {
+ service: 'cognito-identity-provider',
+ action: 'UpdateGroup',
+ parameters: {
+ GroupName: gexpedientesGroupName,
+ UserPoolId: backend.auth.resources.userPool.userPoolId,
+ RoleArn: gexpedientesRole.roleArn,
+ Precedence: 2,
+ },
+ physicalResourceId: PhysicalResourceId.of('gexpedientes-group-role-link'),
+};
+
+new AwsCustomResource(expedientesAccessStack, 'LinkGExpedientesGroupRole', {
+ onCreate: linkGExpedientesGroupRoleCall,
+ onUpdate: linkGExpedientesGroupRoleCall,
+ installLatestAwsSdk: false,
+ policy: AwsCustomResourcePolicy.fromSdkCalls({
+ resources: [backend.auth.resources.userPool.userPoolArn],
+ }),
+});
+
new Policy(expedientesAccessStack, 'GExpedientesPrivateFolderAccess', {
- roles: [backend.auth.resources.groups.gexpedientes.role],
+ roles: [gexpedientesRole],
statements: [
new PolicyStatement({
effect: Effect.ALLOW,
From 022929b6f485f8f1d018dd2c1e53a49fed42da63 Mon Sep 17 00:00:00 2001
From: rcarvajalp
Date: Wed, 22 Jul 2026 11:00:19 -0600
Subject: [PATCH 27/31] Allow linking gexpedientes group role
---
amplify/backend.ts | 15 ++++++++++++---
1 file changed, 12 insertions(+), 3 deletions(-)
diff --git a/amplify/backend.ts b/amplify/backend.ts
index 3db1a7d..5732ad0 100644
--- a/amplify/backend.ts
+++ b/amplify/backend.ts
@@ -76,9 +76,18 @@ new AwsCustomResource(expedientesAccessStack, 'LinkGExpedientesGroupRole', {
onCreate: linkGExpedientesGroupRoleCall,
onUpdate: linkGExpedientesGroupRoleCall,
installLatestAwsSdk: false,
- policy: AwsCustomResourcePolicy.fromSdkCalls({
- resources: [backend.auth.resources.userPool.userPoolArn],
- }),
+ policy: AwsCustomResourcePolicy.fromStatements([
+ new PolicyStatement({
+ effect: Effect.ALLOW,
+ actions: ['cognito-idp:UpdateGroup'],
+ resources: [backend.auth.resources.userPool.userPoolArn],
+ }),
+ new PolicyStatement({
+ effect: Effect.ALLOW,
+ actions: ['iam:PassRole'],
+ resources: [gexpedientesRole.roleArn],
+ }),
+ ]),
});
new Policy(expedientesAccessStack, 'GExpedientesPrivateFolderAccess', {
From d6710022414bddc779528d5025c5cd0c9bddb8f1 Mon Sep 17 00:00:00 2001
From: rcarvajalp
Date: Mon, 17 Aug 2026 04:29:59 -0600
Subject: [PATCH 28/31] Configure expedientes bucket SSE-KMS
---
amplify/backend.ts | 23 +++++++++++++++++++++++
1 file changed, 23 insertions(+)
diff --git a/amplify/backend.ts b/amplify/backend.ts
index 5732ad0..cddb309 100644
--- a/amplify/backend.ts
+++ b/amplify/backend.ts
@@ -6,7 +6,9 @@ import {
PhysicalResourceId,
} from 'aws-cdk-lib/custom-resources';
import { Effect, FederatedPrincipal, Policy, PolicyStatement, Role } from 'aws-cdk-lib/aws-iam';
+import { Key } from 'aws-cdk-lib/aws-kms';
import { CfnFunction } from 'aws-cdk-lib/aws-lambda';
+import { CfnBucket } from 'aws-cdk-lib/aws-s3';
import { IConstruct } from 'constructs';
import { auth } from './auth/resource';
import { secondaryStorage, storage } from './storage/resource';
@@ -37,7 +39,26 @@ class GeneratedNodeRuntime implements IAspect {
Aspects.of(backend.stack).add(new GeneratedNodeRuntime());
const expedientesBucket = backend.secondaryStorage.resources.bucket;
+const expedientesKmsKeyArn = 'arn:aws:kms:us-east-1:953290282809:key/e0b3229c-c603-4e8f-9157-8abbed28943f';
+const expedientesCfnBucket = expedientesBucket.node.defaultChild as CfnBucket;
+
+expedientesCfnBucket.bucketEncryption = {
+ serverSideEncryptionConfiguration: [
+ {
+ serverSideEncryptionByDefault: {
+ sseAlgorithm: 'aws:kms',
+ kmsMasterKeyId: expedientesKmsKeyArn,
+ },
+ },
+ ],
+};
+
const expedientesAccessStack = backend.createStack('expedientesAccess');
+const expedientesKmsKey = Key.fromKeyArn(
+ expedientesAccessStack,
+ 'ExpedientesKmsKey',
+ expedientesKmsKeyArn
+);
const gexpedientesGroupName = 'gexpedientes';
const privateIdentityPrefix = 'privado/${cognito-identity.amazonaws.com:sub}';
@@ -60,6 +81,8 @@ const gexpedientesRole = new Role(expedientesAccessStack, 'GExpedientesGroupRole
),
});
+expedientesKmsKey.grantEncryptDecrypt(gexpedientesRole);
+
const linkGExpedientesGroupRoleCall = {
service: 'cognito-identity-provider',
action: 'UpdateGroup',
From eb5a8573deb214d6f5c767fe1272128f657426f9 Mon Sep 17 00:00:00 2001
From: rcarvajalp
Date: Tue, 8 Sep 2026 21:34:19 -0600
Subject: [PATCH 29/31] Customize storage browser presentation and add tests
---
package.json | 1 +
src/App.css | 68 +++++-
src/App.tsx | 192 +++++++++++++----
src/listLocationItemsPage.ts | 97 +++++++++
src/storageBrowserComponents.tsx | 199 ++++++++++++++++++
src/storageBrowserPresentation.ts | 235 +++++++++++++++++++++
tests/storageBrowserPresentation.test.ts | 251 +++++++++++++++++++++++
7 files changed, 1001 insertions(+), 42 deletions(-)
create mode 100644 src/listLocationItemsPage.ts
create mode 100644 src/storageBrowserComponents.tsx
create mode 100644 src/storageBrowserPresentation.ts
create mode 100644 tests/storageBrowserPresentation.test.ts
diff --git a/package.json b/package.json
index 962c5f9..15ce3f6 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,7 @@
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
+ "test": "tsx --test tests/storageBrowserPresentation.test.ts",
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\""
},
"dependencies": {
diff --git a/src/App.css b/src/App.css
index 605fc59..1b6842d 100644
--- a/src/App.css
+++ b/src/App.css
@@ -281,9 +281,63 @@ body::before {
background: #f4f6f9;
}
-.storage-card [data-testid='LOCATIONS_VIEW'] :is(.amplify-storage-browser__table, .storage-browser__table)
- :is(th, td):nth-child(2) {
- display: none;
+.storage-navigation {
+ display: flex;
+ gap: 0.8rem;
+ align-items: center;
+ min-width: 0;
+ margin-bottom: 0.75rem;
+}
+
+.storage-navigation__actions {
+ display: inline-flex;
+ flex: 0 0 auto;
+ gap: 0.45rem;
+}
+
+.storage-navigation__button {
+ display: inline-grid;
+ width: 2.65rem;
+ height: 2.65rem;
+ padding: 0;
+ color: var(--frauden-navy);
+ cursor: pointer;
+ background: #fff;
+ border: 1px solid rgba(16, 25, 54, 0.2);
+ border-radius: 0.8rem;
+ box-shadow: 0 6px 18px rgba(16, 25, 54, 0.08);
+ place-items: center;
+}
+
+.storage-navigation__button:hover:not(:disabled) {
+ color: #fff;
+ background: var(--frauden-navy);
+ border-color: var(--frauden-navy);
+}
+
+.storage-navigation__button:focus-visible {
+ outline: 3px solid rgba(83, 99, 155, 0.4);
+ outline-offset: 2px;
+}
+
+.storage-navigation__button:disabled {
+ cursor: not-allowed;
+ opacity: 0.45;
+}
+
+.storage-navigation__button svg {
+ width: 1.25rem;
+ height: 1.25rem;
+ fill: none;
+ stroke: currentColor;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: 2;
+}
+
+.storage-navigation__breadcrumbs {
+ min-width: 0;
+ overflow-x: auto;
}
.amplify-authenticator {
@@ -327,6 +381,14 @@ body::before {
flex: 1;
text-align: left;
}
+
+ .storage-navigation {
+ align-items: flex-start;
+ }
+
+ .storage-navigation__breadcrumbs {
+ padding-top: 0.35rem;
+ }
}
.auth-brand {
diff --git a/src/App.tsx b/src/App.tsx
index 114e555..c93c4bf 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -6,23 +6,75 @@ import {
import '@aws-amplify/ui-react/styles.css';
import '@aws-amplify/ui-react-storage/styles.css';
import './App.css';
-import { useState } from 'react';
+import { useEffect, useMemo, useState, type ComponentProps } from 'react';
import config from '../amplify_outputs.json';
import { Amplify } from 'aws-amplify';
-import { fetchAuthSession } from 'aws-amplify/auth';
+import { fetchAuthSession, fetchUserAttributes } from 'aws-amplify/auth';
import { I18n } from 'aws-amplify/utils';
import { Authenticator, Button, translations } from '@aws-amplify/ui-react';
import fraudenLogo from './assets/frauden-logo.svg';
+import { listLocationItemsPage } from './listLocationItemsPage';
+import {
+ StorageActionDestination,
+ StorageDataTable,
+ StorageNavigation,
+ StoragePagination,
+ StoragePresentationProvider,
+} from './storageBrowserComponents';
+import {
+ createBucketLabelMap,
+ createListAllLocationItemsHandler,
+ formatLocationTitle,
+ resolveUserDisplayName,
+ USER_FALLBACK_LABEL,
+ type AmplifyBucketConfig,
+} from './storageBrowserPresentation';
Amplify.configure(config);
I18n.putVocabularies(translations);
I18n.setLanguage('es');
+const bucketLabels = createBucketLabelMap(
+ Amplify.getConfig().Storage?.S3?.buckets as AmplifyBucketConfig | undefined
+);
+
const { StorageBrowser } = createStorageBrowser({
config: createAmplifyAuthAdapter(),
- components: componentsDefault,
+ actions: {
+ default: {
+ listLocationItems: createListAllLocationItemsHandler(listLocationItemsPage),
+ },
+ },
+ components: {
+ ...componentsDefault,
+ ActionDestination: StorageActionDestination,
+ DataTable: StorageDataTable,
+ Navigation: StorageNavigation,
+ Pagination: StoragePagination,
+ },
});
+type LocationDetailViewProps = ComponentProps;
+type LocationDetailViewWithInitialValuesProps = LocationDetailViewProps & {
+ initialValues?: {
+ delimiter?: string;
+ pageSize?: number;
+ };
+};
+
+const LocationDetailViewWithInitialValues = StorageBrowser.LocationDetailView as unknown as (
+ props: LocationDetailViewWithInitialValuesProps
+) => ReturnType;
+
+function AllItemsLocationDetailView(props: LocationDetailViewProps) {
+ return (
+
+ );
+}
+
type StorageBrowserDisplayText = NonNullable[0]['displayText']>;
type KnowledgeSyncStatus = {
@@ -131,7 +183,7 @@ function KnowledgeSyncButton() {
);
}
-const storageBrowserDisplayText: StorageBrowserDisplayText = {
+const storageBrowserDisplayTextBase: StorageBrowserDisplayText = {
LocationsView: {
title: 'Inicio',
searchPlaceholder: 'Filtrar carpetas y archivos',
@@ -188,7 +240,7 @@ const storageBrowserDisplayText: StorageBrowserDisplayText = {
tableColumnNameHeader: 'Nombre',
tableColumnSizeHeader: 'Tamaño',
tableColumnTypeHeader: 'Tipo',
- getTitle: ({ current, key }) => key || current?.bucket || '',
+ getTitle: () => '',
getActionListItemLabel: (key = '') => {
const labels: Record = {
Copy: 'Copiar',
@@ -338,43 +390,105 @@ const storageBrowserDisplayText: StorageBrowserDisplayText = {
},
};
+const createStorageBrowserDisplayText = (userLabel: string): StorageBrowserDisplayText => ({
+ ...storageBrowserDisplayTextBase,
+ LocationDetailView: {
+ ...storageBrowserDisplayTextBase.LocationDetailView,
+ getTitle: ({ current, key }) =>
+ formatLocationTitle(
+ {
+ bucket: current?.bucket,
+ key,
+ prefix: current?.prefix,
+ },
+ bucketLabels,
+ userLabel
+ ),
+ },
+});
+
+function useUserDisplayName() {
+ const [userLabel, setUserLabel] = useState(USER_FALLBACK_LABEL);
+
+ useEffect(() => {
+ let isCurrent = true;
+
+ void fetchUserAttributes()
+ .then((attributes) => {
+ if (isCurrent) {
+ setUserLabel(resolveUserDisplayName(attributes));
+ }
+ })
+ .catch(() => {
+ if (isCurrent) {
+ setUserLabel(USER_FALLBACK_LABEL);
+ }
+ });
+
+ return () => {
+ isCurrent = false;
+ };
+ }, []);
+
+ return userLabel;
+}
+
+type AuthenticatedContentProps = {
+ signOut?: () => void;
+ username?: string;
+};
+
+function AuthenticatedContent({ signOut, username }: AuthenticatedContentProps) {
+ const userLabel = useUserDisplayName();
+ const storageBrowserDisplayText = useMemo(
+ () => createStorageBrowserDisplayText(userLabel),
+ [userLabel]
+ );
+
+ return (
+
+
+
+
+
+
Panel seguro
+
Gestor documental de Frauden
+
+ carga archivos y organiza de forma segura y sencilla el archivo documental de Frauden.
+
+
+
+
+
+
+ );
+}
+
function App() {
return (
- {({ signOut, user }) => (
-
-
-
-
-
-
Panel seguro
-
Gestor documental de Frauden
-
- carga archivos y organiza de forma segura y sencilla el archivo documental de
- Frauden.
-
-
-
-
-
-
- )}
+ {({ signOut, user }) => }
);
}
diff --git a/src/listLocationItemsPage.ts b/src/listLocationItemsPage.ts
new file mode 100644
index 0000000..fe839c4
--- /dev/null
+++ b/src/listLocationItemsPage.ts
@@ -0,0 +1,97 @@
+import { list, type ListOutput } from '@aws-amplify/storage/internals';
+import {
+ S3_LIST_PAGE_SIZE,
+ type ListLocationItemsHandler,
+ type LocationItem,
+} from './storageBrowserPresentation';
+
+const parseFiles = (items: ListOutput['items'], excludedPath: string): LocationItem[] =>
+ items
+ .filter(({ path }) => path !== excludedPath)
+ .map(({ path: key, lastModified, size, eTag }) => {
+ const id = crypto.randomUUID();
+
+ if (size === 0 && key.endsWith('/')) {
+ return { id, key, type: 'FOLDER' };
+ }
+
+ return {
+ eTag,
+ id,
+ key,
+ lastModified: lastModified as Date,
+ size: size as number,
+ type: 'FILE',
+ };
+ });
+
+const parseFolders = (paths: ListOutput['excludedSubpaths']): LocationItem[] =>
+ paths?.map((key) => ({ id: crypto.randomUUID(), key, type: 'FOLDER' })) ?? [];
+
+const filterDotItems = (items: LocationItem[], prefix: string) =>
+ items.filter((item) => {
+ const key = (item.key.startsWith(prefix) ? item.key.substring(prefix.length) : item.key).trim();
+
+ return !['/', './', '../', '.', '..'].includes(key);
+ });
+
+const parseResult = (output: ListOutput, prefix: string) =>
+ filterDotItems(
+ [...parseFolders(output.excludedSubpaths), ...parseFiles(output.items, prefix)],
+ prefix
+ );
+
+// Storage Browser 3.9.1 does not export its default list handler. This keeps
+// that version's parsing and Access Grants credential contract while allowing
+// the configured wrapper to request every continuation page.
+export const listLocationItemsPage: ListLocationItemsHandler = async ({
+ config,
+ prefix,
+ options,
+}) => {
+ const { bucket: bucketName, credentials, customEndpoint, region, accountId } = config;
+ const {
+ exclude,
+ delimiter,
+ nextToken,
+ pageSize: requestedPageSize = S3_LIST_PAGE_SIZE,
+ } = options ?? {};
+ const bucket = { bucketName, region };
+ const hasRootOffset = !nextToken;
+ const boundedPageSize = Math.min(Math.max(requestedPageSize, 1), S3_LIST_PAGE_SIZE);
+ const pageSize = Math.min(boundedPageSize + (hasRootOffset ? 1 : 0), S3_LIST_PAGE_SIZE);
+ const items: LocationItem[] = [];
+ const seenTokens = new Set();
+ let continuationToken = nextToken;
+
+ do {
+ if (continuationToken) {
+ if (seenTokens.has(continuationToken)) {
+ throw new Error('Se detectó un token de continuación repetido al listar la carpeta.');
+ }
+ seenTokens.add(continuationToken);
+ }
+
+ const output = await list({
+ path: prefix,
+ options: {
+ bucket,
+ customEndpoint,
+ expectedBucketOwner: accountId,
+ locationCredentialsProvider: credentials,
+ nextToken: continuationToken,
+ pageSize,
+ subpathStrategy: {
+ delimiter,
+ strategy: delimiter ? 'exclude' : 'include',
+ },
+ },
+ });
+ continuationToken = output.nextToken;
+
+ const pageItems = parseResult(output, prefix);
+ items.push(...(exclude ? pageItems.filter((item) => item.type !== exclude) : pageItems));
+ } while (continuationToken && items.length < boundedPageSize);
+
+ return { items, nextToken: continuationToken };
+};
diff --git a/src/storageBrowserComponents.tsx b/src/storageBrowserComponents.tsx
new file mode 100644
index 0000000..08db06a
--- /dev/null
+++ b/src/storageBrowserComponents.tsx
@@ -0,0 +1,199 @@
+import { createContext, useContext, type ComponentProps, type ReactNode } from 'react';
+import { componentsDefault } from '@aws-amplify/ui-react-storage/browser';
+import {
+ formatActionDestinationRootLabel,
+ formatLocationEntryLabel,
+ formatNavigationRootLabel,
+ getNavigationCallbacks,
+ USER_FALLBACK_LABEL,
+ type BucketLabelMap,
+} from './storageBrowserPresentation';
+
+type PresentationContextValue = {
+ bucketLabels: BucketLabelMap;
+ userLabel: string;
+};
+
+const EMPTY_BUCKET_LABELS = new Map();
+
+const StoragePresentationContext = createContext({
+ bucketLabels: EMPTY_BUCKET_LABELS,
+ userLabel: USER_FALLBACK_LABEL,
+});
+
+export function StoragePresentationProvider({
+ bucketLabels,
+ children,
+ userLabel,
+}: PresentationContextValue & { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+const DefaultNavigation = componentsDefault.Navigation;
+type NavigationProps = ComponentProps>;
+
+function UpIcon() {
+ return (
+
+
+
+ );
+}
+
+function HomeIcon() {
+ return (
+
+
+
+
+ );
+}
+
+export function StorageNavigation({ items }: NavigationProps) {
+ const { bucketLabels, userLabel } = useContext(StoragePresentationContext);
+
+ if (!items.length || !DefaultNavigation) {
+ return null;
+ }
+
+ const { home, parent } = getNavigationCallbacks(items);
+ const breadcrumbs = items.slice(1).map((item, index) =>
+ index === 0
+ ? {
+ ...item,
+ name: formatNavigationRootLabel(item.name ?? '', bucketLabels, userLabel),
+ }
+ : item
+ );
+
+ return (
+
+ );
+}
+
+const DefaultActionDestination = componentsDefault.ActionDestination;
+type ActionDestinationProps = ComponentProps>;
+
+export function StorageActionDestination({ items, ...props }: ActionDestinationProps) {
+ const { bucketLabels, userLabel } = useContext(StoragePresentationContext);
+
+ if (!DefaultActionDestination) {
+ return null;
+ }
+
+ const displayItems = items.map((item, index) =>
+ index === 0
+ ? {
+ ...item,
+ name: formatActionDestinationRootLabel(item.name ?? '', bucketLabels, userLabel),
+ }
+ : item
+ );
+
+ return ;
+}
+
+const DefaultDataTable = componentsDefault.DataTable;
+type DataTableProps = ComponentProps>;
+type DataTableCell = DataTableProps['rows'][number]['content'][number];
+
+const getCellLabel = (cell: DataTableCell | undefined) => {
+ if (cell?.type === 'text') return cell.content.text ?? '';
+ if (cell?.type === 'button') return cell.content.label ?? '';
+ return '';
+};
+
+const withCellLabel = (cell: DataTableCell, label: string): DataTableCell => {
+ if (cell.type === 'text') {
+ return { ...cell, content: { ...cell.content, text: label } };
+ }
+
+ if (cell.type === 'button') {
+ return { ...cell, content: { ...cell.content, label } };
+ }
+
+ return cell;
+};
+
+export function StorageDataTable({ headers, rows, ...props }: DataTableProps) {
+ const { bucketLabels, userLabel } = useContext(StoragePresentationContext);
+ const bucketColumnIndex = headers.findIndex(({ key }) => key === 'bucket');
+ const folderColumnIndex = headers.findIndex(({ key }) => key === 'folder');
+ const isLocationsTable =
+ bucketColumnIndex !== -1 &&
+ folderColumnIndex !== -1 &&
+ headers.some(({ key }) => key === 'permission');
+
+ if (!DefaultDataTable) {
+ return null;
+ }
+
+ if (!isLocationsTable) {
+ return ;
+ }
+
+ const displayHeaders = headers.filter((_, index) => index !== bucketColumnIndex);
+ const displayRows = rows.map((row) => {
+ const physicalBucketName = getCellLabel(row.content[bucketColumnIndex]);
+ const folderLabel = getCellLabel(row.content[folderColumnIndex]);
+
+ return {
+ ...row,
+ content: row.content
+ .map((cell, index) =>
+ index === folderColumnIndex
+ ? withCellLabel(
+ cell,
+ formatLocationEntryLabel(physicalBucketName, folderLabel, bucketLabels, userLabel)
+ )
+ : cell
+ )
+ .filter((_, index) => index !== bucketColumnIndex),
+ };
+ });
+
+ return ;
+}
+
+const DefaultPagination = componentsDefault.Pagination;
+type PaginationProps = ComponentProps>;
+
+export function StoragePagination(props: PaginationProps) {
+ const highestPage = props.highestPageVisited ?? props.page ?? 1;
+
+ if (!DefaultPagination || (!props.hasNextPage && highestPage <= 1)) {
+ return null;
+ }
+
+ return ;
+}
diff --git a/src/storageBrowserPresentation.ts b/src/storageBrowserPresentation.ts
new file mode 100644
index 0000000..398ed67
--- /dev/null
+++ b/src/storageBrowserPresentation.ts
@@ -0,0 +1,235 @@
+import type { LocationCredentialsProvider } from '@aws-amplify/storage/internals';
+
+export const NEUTRAL_STORAGE_LABEL = 'Almacenamiento';
+export const PRIVATE_STORAGE_PREFIX = 'privado';
+export const S3_LIST_PAGE_SIZE = 1000;
+export const USER_FALLBACK_LABEL = 'Usuario';
+
+export type BucketLabelMap = ReadonlyMap;
+
+export type AmplifyBucketConfig = Record<
+ string,
+ {
+ bucketName?: string;
+ }
+>;
+
+export type NavigationItem = {
+ isCurrent?: boolean;
+ name?: string;
+ onNavigate?: () => void;
+};
+
+export type LocationItem =
+ | {
+ id: string;
+ key: string;
+ type: 'FOLDER';
+ }
+ | {
+ eTag?: string;
+ id: string;
+ key: string;
+ lastModified: Date;
+ size: number;
+ type: 'FILE';
+ };
+
+export type ListLocationItemsHandlerInput = {
+ config: {
+ accountId?: string;
+ bucket: string;
+ credentials: LocationCredentialsProvider;
+ customEndpoint?: string;
+ region: string;
+ };
+ options?: {
+ delimiter?: string;
+ exclude?: LocationItem['type'];
+ nextToken?: string;
+ pageSize?: number;
+ };
+ prefix: string;
+};
+
+export type ListLocationItemsHandlerOutput = {
+ items: LocationItem[];
+ nextToken: string | undefined;
+};
+
+export type ListLocationItemsHandler = (
+ input: ListLocationItemsHandlerInput
+) => Promise;
+
+const trimPath = (path: string) => path.replace(/^\/+|\/+$/g, '');
+
+const getPathParts = (path: string) => {
+ const normalizedPath = trimPath(path.trim());
+ return normalizedPath ? normalizedPath.split('/') : [];
+};
+
+const isConfiguredLogicalName = (value: string, bucketLabels: BucketLabelMap) =>
+ Array.from(bucketLabels.values()).includes(value);
+
+export function createBucketLabelMap(buckets?: AmplifyBucketConfig): Map {
+ const bucketLabels = new Map();
+
+ for (const [logicalName, bucket] of Object.entries(buckets ?? {})) {
+ const physicalName = bucket?.bucketName?.trim();
+ const displayName = logicalName.trim();
+
+ if (physicalName && displayName) {
+ bucketLabels.set(physicalName, displayName);
+ }
+ }
+
+ return bucketLabels;
+}
+
+export function getBucketDisplayName(physicalName: string, bucketLabels: BucketLabelMap): string {
+ return bucketLabels.get(physicalName) ?? NEUTRAL_STORAGE_LABEL;
+}
+
+export function resolveUserDisplayName(attributes?: { email?: string; name?: string }): string {
+ return attributes?.name?.trim() || attributes?.email?.trim() || USER_FALLBACK_LABEL;
+}
+
+export function formatPrivatePath(path: string, userLabel: string): string | undefined {
+ const parts = getPathParts(path);
+
+ if (parts[0] !== PRIVATE_STORAGE_PREFIX || !parts[1]) {
+ return undefined;
+ }
+
+ return [userLabel, ...parts.slice(2)].join('/');
+}
+
+export function formatNavigationRootLabel(
+ rawLabel: string,
+ bucketLabels: BucketLabelMap,
+ userLabel: string
+): string {
+ const parts = getPathParts(rawLabel);
+ const bucketName = parts[0] ?? '';
+ const physicalBucketLabel = bucketLabels.get(bucketName);
+ const logicalBucketLabel =
+ physicalBucketLabel ??
+ (isConfiguredLogicalName(bucketName, bucketLabels) ? bucketName : NEUTRAL_STORAGE_LABEL);
+ const prefix = parts.slice(1).join('/');
+ const privateLabel = formatPrivatePath(prefix, userLabel);
+
+ if (privateLabel) {
+ return privateLabel;
+ }
+
+ return prefix ? `${logicalBucketLabel}/${prefix}` : logicalBucketLabel;
+}
+
+export function formatActionDestinationRootLabel(
+ rawLabel: string,
+ bucketLabels: BucketLabelMap,
+ userLabel: string
+): string {
+ const normalizedLabel = trimPath(rawLabel.trim());
+ const privateLabel = formatPrivatePath(normalizedLabel, userLabel);
+
+ if (privateLabel) {
+ return privateLabel;
+ }
+
+ const [firstPart] = getPathParts(normalizedLabel);
+ if (bucketLabels.has(firstPart) || isConfiguredLogicalName(firstPart, bucketLabels)) {
+ return formatNavigationRootLabel(normalizedLabel, bucketLabels, userLabel);
+ }
+
+ return normalizedLabel;
+}
+
+export function formatLocationEntryLabel(
+ physicalBucketName: string,
+ rawLabel: string,
+ bucketLabels: BucketLabelMap,
+ userLabel: string
+): string {
+ const normalizedLabel = trimPath(rawLabel.trim());
+
+ if (!normalizedLabel || normalizedLabel === physicalBucketName) {
+ return getBucketDisplayName(physicalBucketName, bucketLabels);
+ }
+
+ if (normalizedLabel.startsWith(`${physicalBucketName}/`)) {
+ return formatNavigationRootLabel(normalizedLabel, bucketLabels, userLabel);
+ }
+
+ return formatPrivatePath(normalizedLabel, userLabel) ?? normalizedLabel;
+}
+
+export function formatLocationTitle(
+ location: {
+ bucket?: string;
+ key?: string;
+ prefix?: string;
+ },
+ bucketLabels: BucketLabelMap,
+ userLabel: string
+): string {
+ const key = trimPath(location.key?.trim() ?? '');
+ const prefix = trimPath(location.prefix?.trim() ?? '');
+ const visiblePath = key || prefix;
+
+ if (visiblePath) {
+ return formatPrivatePath(visiblePath, userLabel) ?? visiblePath;
+ }
+
+ return getBucketDisplayName(location.bucket ?? '', bucketLabels);
+}
+
+export function getNavigationCallbacks(items: NavigationItem[]): {
+ home?: () => void;
+ parent?: () => void;
+} {
+ const home = items[0]?.onNavigate;
+ const parent = items.length > 2 ? items[items.length - 2]?.onNavigate : home;
+
+ return { home, parent: parent ?? home };
+}
+
+export function createListAllLocationItemsHandler(
+ listPage: ListLocationItemsHandler
+): ListLocationItemsHandler {
+ return async (input) => {
+ if (input.options?.exclude === 'FILE') {
+ return listPage(input);
+ }
+
+ const items: ListLocationItemsHandlerOutput['items'] = [];
+ const seenTokens = new Set();
+ let nextToken = input.options?.nextToken;
+
+ while (true) {
+ if (nextToken) {
+ if (seenTokens.has(nextToken)) {
+ throw new Error('Se detectó un token de continuación repetido al listar la carpeta.');
+ }
+ seenTokens.add(nextToken);
+ }
+
+ const page = await listPage({
+ ...input,
+ options: {
+ ...input.options,
+ nextToken,
+ pageSize: S3_LIST_PAGE_SIZE,
+ },
+ });
+
+ items.push(...page.items);
+
+ if (!page.nextToken) {
+ return { items, nextToken: undefined };
+ }
+
+ nextToken = page.nextToken;
+ }
+ };
+}
diff --git a/tests/storageBrowserPresentation.test.ts b/tests/storageBrowserPresentation.test.ts
new file mode 100644
index 0000000..bece7fd
--- /dev/null
+++ b/tests/storageBrowserPresentation.test.ts
@@ -0,0 +1,251 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ createBucketLabelMap,
+ createListAllLocationItemsHandler,
+ formatActionDestinationRootLabel,
+ formatLocationEntryLabel,
+ formatLocationTitle,
+ formatNavigationRootLabel,
+ formatPrivatePath,
+ getBucketDisplayName,
+ getNavigationCallbacks,
+ NEUTRAL_STORAGE_LABEL,
+ resolveUserDisplayName,
+ S3_LIST_PAGE_SIZE,
+ USER_FALLBACK_LABEL,
+ type ListLocationItemsHandler,
+} from '../src/storageBrowserPresentation';
+
+const PRIMARY_BUCKET = 'frauden-physical-bucket-123';
+const SECONDARY_BUCKET = 'frauden-expedientes-physical-bucket-456';
+const IDENTITY_ID = 'us-east-1:11111111-2222-3333-4444-555555555555';
+
+const bucketLabels = createBucketLabelMap({
+ frauden: { bucketName: PRIMARY_BUCKET },
+ 'frauden-expedientes': { bucketName: SECONDARY_BUCKET },
+});
+
+test('mapea los dos buckets solo por coincidencia física exacta', () => {
+ assert.equal(getBucketDisplayName(PRIMARY_BUCKET, bucketLabels), 'frauden');
+ assert.equal(getBucketDisplayName(SECONDARY_BUCKET, bucketLabels), 'frauden-expedientes');
+ assert.equal(
+ getBucketDisplayName(`${PRIMARY_BUCKET}-similar`, bucketLabels),
+ NEUTRAL_STORAGE_LABEL
+ );
+
+ const normalRoute = formatNavigationRootLabel(
+ `${PRIMARY_BUCKET}/doctrina`,
+ bucketLabels,
+ 'Ana Pérez'
+ );
+ const unknownRoute = formatNavigationRootLabel(
+ `${PRIMARY_BUCKET}-similar/doctrina`,
+ bucketLabels,
+ 'Ana Pérez'
+ );
+
+ assert.equal(normalRoute, 'frauden/doctrina');
+ assert.equal(unknownRoute, `${NEUTRAL_STORAGE_LABEL}/doctrina`);
+ assert.equal(
+ formatLocationTitle({ bucket: `${PRIMARY_BUCKET}-similar` }, bucketLabels, 'Ana Pérez'),
+ NEUTRAL_STORAGE_LABEL
+ );
+ assert.ok(!normalRoute.includes(PRIMARY_BUCKET));
+ assert.ok(!unknownRoute.includes(PRIMARY_BUCKET));
+});
+
+test('convierte la carpeta privada a nombre, correo o Usuario', () => {
+ const privatePath = `privado/${IDENTITY_ID}/expediente`;
+
+ assert.equal(
+ resolveUserDisplayName({ name: ' Ana Pérez ', email: 'ana@example.com' }),
+ 'Ana Pérez'
+ );
+ assert.equal(
+ resolveUserDisplayName({ name: ' ', email: ' ana@example.com ' }),
+ 'ana@example.com'
+ );
+ assert.equal(resolveUserDisplayName({ name: ' ', email: ' ' }), USER_FALLBACK_LABEL);
+ assert.equal(formatPrivatePath(privatePath, 'Ana Pérez'), 'Ana Pérez/expediente');
+ assert.equal(formatPrivatePath(privatePath, 'ana@example.com'), 'ana@example.com/expediente');
+ assert.equal(formatPrivatePath(privatePath, USER_FALLBACK_LABEL), 'Usuario/expediente');
+ assert.equal(formatPrivatePath(`privado-similar/${IDENTITY_ID}`, 'Ana Pérez'), undefined);
+});
+
+test('forma rutas normales, privadas y anidadas sin datos físicos', () => {
+ const normalRoot = formatNavigationRootLabel(
+ `${PRIMARY_BUCKET}/doctrina`,
+ bucketLabels,
+ 'Ana Pérez'
+ );
+ const normalRoute = [normalRoot, 'contratos', '2026'].join(' > ');
+ const privateRoot = formatNavigationRootLabel(
+ `${SECONDARY_BUCKET}/privado/${IDENTITY_ID}`,
+ bucketLabels,
+ 'Ana Pérez'
+ );
+ const privateRoute = [privateRoot, 'expediente'].join(' > ');
+
+ assert.equal(normalRoute, 'frauden/doctrina > contratos > 2026');
+ assert.equal(privateRoute, 'Ana Pérez > expediente');
+ assert.ok(!privateRoute.includes('privado'));
+ assert.ok(!privateRoute.includes(IDENTITY_ID));
+ assert.ok(!privateRoute.includes(SECONDARY_BUCKET));
+
+ assert.equal(
+ formatLocationEntryLabel(
+ SECONDARY_BUCKET,
+ `privado/${IDENTITY_ID}/expediente`,
+ bucketLabels,
+ 'Ana Pérez'
+ ),
+ 'Ana Pérez/expediente'
+ );
+ assert.equal(
+ formatActionDestinationRootLabel(`privado/${IDENTITY_ID}`, bucketLabels, 'Ana Pérez'),
+ 'Ana Pérez'
+ );
+ assert.equal(
+ formatLocationTitle(
+ {
+ bucket: SECONDARY_BUCKET,
+ key: `privado/${IDENTITY_ID}/expediente/`,
+ prefix: `privado/${IDENTITY_ID}/`,
+ },
+ bucketLabels,
+ 'Ana Pérez'
+ ),
+ 'Ana Pérez/expediente'
+ );
+});
+
+test('Arriba usa el breadcrumb anterior y desde la raíz usa Home', () => {
+ const calls: string[] = [];
+ const home = () => calls.push('home');
+ const root = () => calls.push('root');
+ const parent = () => calls.push('parent');
+ const current = () => calls.push('current');
+
+ const nestedCallbacks = getNavigationCallbacks([
+ { name: 'Home', onNavigate: home },
+ { name: 'raíz', onNavigate: root },
+ { name: 'carpeta', onNavigate: parent },
+ { name: 'actual', onNavigate: current },
+ ]);
+ nestedCallbacks.parent?.();
+ nestedCallbacks.home?.();
+
+ const rootCallbacks = getNavigationCallbacks([
+ { name: 'Home', onNavigate: home },
+ { name: 'raíz', onNavigate: root },
+ ]);
+ rootCallbacks.parent?.();
+
+ assert.deepEqual(calls, ['parent', 'home', 'home']);
+});
+
+test('acumula todas las páginas S3 en orden usando lotes de 1,000', async () => {
+ const requests: Array<{ nextToken?: string; pageSize?: number }> = [];
+ const listPage: ListLocationItemsHandler = async (input) => {
+ requests.push({
+ nextToken: input.options?.nextToken,
+ pageSize: input.options?.pageSize,
+ });
+
+ if (!input.options?.nextToken) {
+ return {
+ items: [{ id: '1', key: 'a/', type: 'FOLDER' }],
+ nextToken: 'page-2',
+ };
+ }
+
+ if (input.options.nextToken === 'page-2') {
+ return {
+ items: [{ id: '2', key: 'b/', type: 'FOLDER' }],
+ nextToken: 'page-3',
+ };
+ }
+
+ return {
+ items: [{ id: '3', key: 'c/', type: 'FOLDER' }],
+ nextToken: undefined,
+ };
+ };
+
+ const listAll = createListAllLocationItemsHandler(listPage);
+ const result = await listAll({
+ config: {} as never,
+ prefix: 'doctrina/',
+ options: { pageSize: Number.MAX_SAFE_INTEGER },
+ });
+
+ assert.deepEqual(
+ result.items.map(({ key }) => key),
+ ['a/', 'b/', 'c/']
+ );
+ assert.equal(result.nextToken, undefined);
+ assert.deepEqual(
+ requests.map(({ nextToken }) => nextToken),
+ [undefined, 'page-2', 'page-3']
+ );
+ assert.ok(requests.every(({ pageSize }) => pageSize === S3_LIST_PAGE_SIZE));
+});
+
+test('rechaza tokens repetidos y no presenta una lista parcial como completa', async () => {
+ const listPage: ListLocationItemsHandler = async () => ({
+ items: [{ id: '1', key: 'a/', type: 'FOLDER' }],
+ nextToken: 'repetido',
+ });
+ const listAll = createListAllLocationItemsHandler(listPage);
+
+ await assert.rejects(
+ listAll({ config: {} as never, prefix: 'doctrina/' }),
+ /token de continuación repetido/
+ );
+});
+
+test('propaga un fallo intermedio en vez de devolver los elementos parciales', async () => {
+ let requestCount = 0;
+ const listPage: ListLocationItemsHandler = async () => {
+ requestCount += 1;
+
+ if (requestCount === 1) {
+ return {
+ items: [{ id: '1', key: 'a/', type: 'FOLDER' }],
+ nextToken: 'page-2',
+ };
+ }
+
+ throw new Error('fallo intermedio');
+ };
+ const listAll = createListAllLocationItemsHandler(listPage);
+
+ await assert.rejects(listAll({ config: {} as never, prefix: 'doctrina/' }), /fallo intermedio/);
+ assert.equal(requestCount, 2);
+});
+
+test('conserva la paginación del selector de carpetas usado por Copiar', async () => {
+ const requests: Array<{ nextToken?: string; pageSize?: number }> = [];
+ const listPage: ListLocationItemsHandler = async (input) => {
+ requests.push({
+ nextToken: input.options?.nextToken,
+ pageSize: input.options?.pageSize,
+ });
+ return {
+ items: [{ id: '1', key: 'destino/', type: 'FOLDER' }],
+ nextToken: 'siguiente',
+ };
+ };
+ const listAll = createListAllLocationItemsHandler(listPage);
+
+ const result = await listAll({
+ config: {} as never,
+ prefix: 'doctrina/',
+ options: { exclude: 'FILE', nextToken: 'actual', pageSize: 25 },
+ });
+
+ assert.equal(requests.length, 1);
+ assert.deepEqual(requests[0], { nextToken: 'actual', pageSize: 25 });
+ assert.equal(result.nextToken, 'siguiente');
+});
From 74de9d8afb8de862ecf72bd67d8ef8e2826686dd Mon Sep 17 00:00:00 2001
From: rcarvajalp
Date: Tue, 8 Sep 2026 21:58:08 -0600
Subject: [PATCH 30/31] Replace unsupported Storage Browser DataTable with
compatible renderer
---
package.json | 2 +-
src/App.css | 7 +
src/storageBrowserComponents.tsx | 240 ++++++++++++++++++++++--
tests/storageBrowserComponents.test.tsx | 111 +++++++++++
4 files changed, 345 insertions(+), 15 deletions(-)
create mode 100644 tests/storageBrowserComponents.test.tsx
diff --git a/package.json b/package.json
index 15ce3f6..39b6a39 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
- "test": "tsx --test tests/storageBrowserPresentation.test.ts",
+ "test": "tsx --test tests/storageBrowserPresentation.test.ts tests/storageBrowserComponents.test.tsx",
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\""
},
"dependencies": {
diff --git a/src/App.css b/src/App.css
index 1b6842d..4bc6b78 100644
--- a/src/App.css
+++ b/src/App.css
@@ -340,6 +340,13 @@ body::before {
overflow-x: auto;
}
+.storage-table-icon {
+ width: 1.25rem;
+ height: 1.25rem;
+ flex: 0 0 auto;
+ fill: currentColor;
+}
+
.amplify-authenticator {
min-height: 100vh;
background:
diff --git a/src/storageBrowserComponents.tsx b/src/storageBrowserComponents.tsx
index 08db06a..0b94b75 100644
--- a/src/storageBrowserComponents.tsx
+++ b/src/storageBrowserComponents.tsx
@@ -1,4 +1,14 @@
-import { createContext, useContext, type ComponentProps, type ReactNode } from 'react';
+import React, { type ComponentProps, type ReactNode } from 'react';
+import {
+ Button,
+ Checkbox,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableRow,
+ View,
+} from '@aws-amplify/ui-react';
import { componentsDefault } from '@aws-amplify/ui-react-storage/browser';
import {
formatActionDestinationRootLabel,
@@ -16,7 +26,7 @@ type PresentationContextValue = {
const EMPTY_BUCKET_LABELS = new Map();
-const StoragePresentationContext = createContext({
+const StoragePresentationContext = React.createContext({
bucketLabels: EMPTY_BUCKET_LABELS,
userLabel: USER_FALLBACK_LABEL,
});
@@ -54,7 +64,7 @@ function HomeIcon() {
}
export function StorageNavigation({ items }: NavigationProps) {
- const { bucketLabels, userLabel } = useContext(StoragePresentationContext);
+ const { bucketLabels, userLabel } = React.useContext(StoragePresentationContext);
if (!items.length || !DefaultNavigation) {
return null;
@@ -105,7 +115,7 @@ const DefaultActionDestination = componentsDefault.ActionDestination;
type ActionDestinationProps = ComponentProps>;
export function StorageActionDestination({ items, ...props }: ActionDestinationProps) {
- const { bucketLabels, userLabel } = useContext(StoragePresentationContext);
+ const { bucketLabels, userLabel } = React.useContext(StoragePresentationContext);
if (!DefaultActionDestination) {
return null;
@@ -123,9 +133,213 @@ export function StorageActionDestination({ items, ...props }: ActionDestinationP
return ;
}
-const DefaultDataTable = componentsDefault.DataTable;
-type DataTableProps = ComponentProps>;
+type DataTableProps = ComponentProps>;
type DataTableCell = DataTableProps['rows'][number]['content'][number];
+type DataTableHeader = DataTableProps['headers'][number];
+
+// Storage Browser 3.9.1 uses these icons in table cells, but does not expose its
+// DataTable renderer. Keeping the small set of paths here avoids importing the
+// package's unsupported `internal` entry point.
+const STORAGE_TABLE_ICON_PATHS: Record = {
+ 'action-canceled':
+ 'M280-440h400v-80H280v80ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z',
+ 'action-error':
+ 'M480-280q17 0 28.5-11.5T520-320q0-17-11.5-28.5T480-360q-17 0-28.5 11.5T440-320q0 17 11.5 28.5T480-280Zm-40-160h80v-240h-80v240Zm40 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z',
+ 'action-info':
+ 'M440-280h80v-240h-80v240Zm40-320q17 0 28.5-11.5T520-640q0-17-11.5-28.5T480-680q-17 0-28.5 11.5T440-640q0 17 11.5 28.5T480-600Zm0 520q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z',
+ 'action-initial':
+ 'M480-360q50 0 85-35t35-85q0-50-35-85t-85-35q-50 0-85 35t-35 85q0 50 35 85t85 35Zm0 280q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z',
+ 'action-progress':
+ 'M480-80q-82 0-155-31.5t-127.5-86Q143-252 111.5-325T80-480q0-83 31.5-155.5t86-127Q252-817 325-848.5T480-880q17 0 28.5 11.5T520-840q0 17-11.5 28.5T480-800q-133 0-226.5 93.5T160-480q0 133 93.5 226.5T480-160q133 0 226.5-93.5T800-480q0-17 11.5-28.5T840-520q17 0 28.5 11.5T880-480q0 82-31.5 155t-86 127.5q-54.5 54.5-127 86T480-80Z',
+ 'action-queued':
+ 'M280-420q25 0 42.5-17.5T340-480q0-25-17.5-42.5T280-540q-25 0-42.5 17.5T220-480q0 25 17.5 42.5T280-420Zm200 0q25 0 42.5-17.5T540-480q0-25-17.5-42.5T480-540q-25 0-42.5 17.5T420-480q0 25 17.5 42.5T480-420Zm200 0q25 0 42.5-17.5T740-480q0-25-17.5-42.5T680-540q-25 0-42.5 17.5T620-480q0 25 17.5 42.5T680-420ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z',
+ 'action-success':
+ 'm424-296 282-282-56-56-226 226-114-114-56 56 170 170Zm56 216q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z',
+ cancel:
+ 'm336-280 144-144 144 144 56-56-144-144 144-144-56-56-144 144-144-144-56 56 144 144-144 144 56 56ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z',
+ download:
+ 'M480-320 280-520l56-58 104 104v-326h80v326l104-104 56 58-200 200ZM240-160q-33 0-56.5-23.5T160-240v-120h80v120h480v-120h80v120q0 33-23.5 56.5T720-160H240Z',
+ file: 'M240-80q-33 0-56.5-23.5T160-160v-640q0-33 23.5-56.5T240-880h320l240 240v480q0 33-23.5 56.5T720-80H240Zm280-520v-200H240v640h480v-440H520ZM240-800v200-200 640-640Z',
+ folder:
+ 'M160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h240l80 80h320q33 0 56.5 23.5T880-640v400q0 33-23.5 56.5T800-160H160Zm0-80h640v-400H447l-80-80H160v480Zm0 0v-480 480Z',
+ 'sort-ascending': 'm280-400 200-200 200 200H280Z',
+ 'sort-descending': 'M480-360 280-560h400L480-360Z',
+ 'sort-indeterminate': 'M240-440v-80h480v80H240Z',
+};
+
+function StorageTableIcon({ className, name }: { className?: string; name?: string }) {
+ if (!name || !(name in STORAGE_TABLE_ICON_PATHS)) {
+ return null;
+ }
+
+ const path = STORAGE_TABLE_ICON_PATHS[name];
+
+ return (
+
+
+
+ );
+}
+
+function StorageTableHeader({ header }: { header: DataTableHeader }) {
+ const { content, type } = header;
+
+ if (type === 'checkbox') {
+ return (
+
+ );
+ }
+
+ if (type === 'sort') {
+ const iconName = `sort-${content.sortDirection ?? 'indeterminate'}`;
+
+ return (
+
+ {content.label}
+
+
+ );
+ }
+
+ return {content.text} ;
+}
+
+function StorageTableCell({ cell }: { cell: DataTableCell }) {
+ const { content, type } = cell;
+
+ if (type === 'button') {
+ const isIconOnly = Boolean(content.icon && !content.label);
+ const isCancel = isIconOnly && content.icon === 'cancel';
+
+ return (
+
+
+ {content.label}
+
+ );
+ }
+
+ if (type === 'checkbox') {
+ return (
+
+ );
+ }
+
+ if (type === 'date') {
+ return (
+
+ {content.displayValue ?? content.value?.toLocaleString()}
+
+ );
+ }
+
+ if (type === 'number') {
+ return (
+
+ {content.displayValue ?? content.value}
+
+ );
+ }
+
+ return (
+
+
+
+ {content.text}
+
+
+ );
+}
+
+function CompatibleStorageDataTable({ headers, isLoading, rows }: DataTableProps) {
+ return (
+
+
+ {headers.length ? (
+
+ {headers.map((header) => (
+
+
+
+ ))}
+
+ ) : null}
+
+
+ {isLoading
+ ? null
+ : rows.map((row) => (
+
+ {row.content.map((cell) => (
+
+
+
+ ))}
+
+ ))}
+
+
+ );
+}
const getCellLabel = (cell: DataTableCell | undefined) => {
if (cell?.type === 'text') return cell.content.text ?? '';
@@ -145,8 +359,8 @@ const withCellLabel = (cell: DataTableCell, label: string): DataTableCell => {
return cell;
};
-export function StorageDataTable({ headers, rows, ...props }: DataTableProps) {
- const { bucketLabels, userLabel } = useContext(StoragePresentationContext);
+export function StorageDataTable({ headers, isLoading, rows }: DataTableProps) {
+ const { bucketLabels, userLabel } = React.useContext(StoragePresentationContext);
const bucketColumnIndex = headers.findIndex(({ key }) => key === 'bucket');
const folderColumnIndex = headers.findIndex(({ key }) => key === 'folder');
const isLocationsTable =
@@ -154,12 +368,8 @@ export function StorageDataTable({ headers, rows, ...props }: DataTableProps) {
folderColumnIndex !== -1 &&
headers.some(({ key }) => key === 'permission');
- if (!DefaultDataTable) {
- return null;
- }
-
if (!isLocationsTable) {
- return ;
+ return ;
}
const displayHeaders = headers.filter((_, index) => index !== bucketColumnIndex);
@@ -182,7 +392,9 @@ export function StorageDataTable({ headers, rows, ...props }: DataTableProps) {
};
});
- return ;
+ return (
+
+ );
}
const DefaultPagination = componentsDefault.Pagination;
diff --git a/tests/storageBrowserComponents.test.tsx b/tests/storageBrowserComponents.test.tsx
new file mode 100644
index 0000000..31ae98b
--- /dev/null
+++ b/tests/storageBrowserComponents.test.tsx
@@ -0,0 +1,111 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import React from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { StorageDataTable, StoragePresentationProvider } from '../src/storageBrowserComponents';
+import { createBucketLabelMap } from '../src/storageBrowserPresentation';
+
+const PHYSICAL_BUCKET = 'frauden-expedientes-physical-bucket-456';
+const IDENTITY_ID = 'us-east-1:11111111-2222-3333-4444-555555555555';
+
+test('renderiza la tabla inicial sin depender de componentsDefault.DataTable', () => {
+ const headers = [
+ {
+ key: 'folder',
+ type: 'sort' as const,
+ content: { label: 'Carpeta' },
+ },
+ {
+ key: 'bucket',
+ type: 'sort' as const,
+ content: { label: 'Bucket' },
+ },
+ {
+ key: 'permission',
+ type: 'sort' as const,
+ content: { label: 'Permisos' },
+ },
+ ];
+ const rows = [
+ {
+ key: 'location-1',
+ content: [
+ {
+ key: 'folder-location-1',
+ type: 'button' as const,
+ content: { label: `privado/${IDENTITY_ID}/` },
+ },
+ {
+ key: 'bucket-location-1',
+ type: 'text' as const,
+ content: { text: PHYSICAL_BUCKET },
+ },
+ {
+ key: 'permission-location-1',
+ type: 'text' as const,
+ content: { text: 'Lectura/Escritura' },
+ },
+ ],
+ },
+ ];
+ const html = renderToStaticMarkup(
+
+
+
+ );
+
+ assert.match(html, /Carpeta);
+ assert.match(html, />Permisos);
+ assert.match(html, />Ana Pérez);
+ assert.doesNotMatch(html, />Bucket);
+ assert.doesNotMatch(html, new RegExp(PHYSICAL_BUCKET));
+ assert.doesNotMatch(html, new RegExp(IDENTITY_ID));
+});
+
+test('renderiza también las tablas de detalle y acciones', () => {
+ const html = renderToStaticMarkup(
+
+
+
+ );
+
+ assert.match(html, /
Date: Tue, 8 Sep 2026 23:10:51 -0600
Subject: [PATCH 31/31] Enforce uploader access controls and session inactivity
expiry
---
README.md | 52 ++-
amplify/auth/resource.ts | 4 +-
amplify/backend.ts | 70 +++-
amplify/storage/resource.ts | 4 +
package.json | 2 +-
src/App.css | 37 ++
src/App.tsx | 368 ++++++++++++++++++--
src/activityReporter.ts | 24 ++
src/sessionActivity.ts | 409 +++++++++++++++++++++++
src/sessionActivityContext.ts | 26 ++
src/sessionActivityReact.tsx | 121 +++++++
src/storageBrowserActions.ts | 6 +
src/storageBrowserComponents.tsx | 50 ++-
src/storageBrowserPresentation.ts | 18 +
src/trackedDownloadHandler.ts | 44 +++
src/userAccess.ts | 72 ++++
tests/sessionActivity.test.ts | 226 +++++++++++++
tests/storageBrowserComponents.test.tsx | 87 ++++-
tests/storageBrowserPresentation.test.ts | 8 +
tests/userAccess.test.ts | 58 ++++
20 files changed, 1640 insertions(+), 46 deletions(-)
create mode 100644 src/activityReporter.ts
create mode 100644 src/sessionActivity.ts
create mode 100644 src/sessionActivityContext.ts
create mode 100644 src/sessionActivityReact.tsx
create mode 100644 src/storageBrowserActions.ts
create mode 100644 src/trackedDownloadHandler.ts
create mode 100644 src/userAccess.ts
create mode 100644 tests/sessionActivity.test.ts
create mode 100644 tests/userAccess.test.ts
diff --git a/README.md b/README.md
index 133b61a..af2de22 100644
--- a/README.md
+++ b/README.md
@@ -8,17 +8,17 @@ This template equips you with a foundational React application integrated with A
## Features
-- **Authentication**: Setup with Amazon Cognito for secure user authentication with email login.
- - More info on how to setup and configuration option: https://docs.amplify.aws/react/build-a-backend/auth/set-up-auth/
+- **Authentication**: Setup with Amazon Cognito for secure user authentication with email login.
+ - More info on how to setup and configuration option: https://docs.amplify.aws/react/build-a-backend/auth/set-up-auth/
- **Storage**: Configured with multiple S3 buckets and granular access controls. The sample is configured with
- Default `frauden-bucket` storage bucket with `doctrina`, `medios`, `jurisprudencia`, and `legislacion` access paths for authenticated readers and admin read/write/delete access.
- Secondary `frauden-expedientes` storage bucket with owner-scoped `privado/{entity_id}` access paths. Read/write access is limited to users in the `gexpedientes` group for their own identity folder, with delete permissions for the `eliminadores` group.
- More info on how to setup : https://docs.amplify.aws/react/build-a-backend/storage/set-up-storage/#building-your-storage-backend
- **UI Components**: Pre-integrated Amplify UI React components including:
- Authenticator for sign-in/sign-up flows
- - More info : https://ui.docs.amplify.aws/react/connected-components/authenticator
+ - More info : https://ui.docs.amplify.aws/react/connected-components/authenticator
- Storage Browser for S3 file management.
- - More info : https://ui.docs.amplify.aws/react/connected-components/storage/storage-browser
+ - More info : https://ui.docs.amplify.aws/react/connected-components/storage/storage-browser
## Project Structure
@@ -33,22 +33,65 @@ This template equips you with a foundational React application integrated with A
└── package.json # Project dependencies
```
+## Permisos de almacenamiento
+
+Las precedencias de los grupos de Cognito son únicas y se evalúan en este orden:
+`uploaders` (0), `admin` (1), `eliminadores` (2) y `gexpedientes` (3). Por ello,
+un usuario que pertenezca a `uploaders` y a cualquier otro de esos grupos conserva
+el perfil restrictivo de `uploaders`.
+
+| Perfil efectivo | Almacenamiento principal (`doctrina/`, `medios/`, `jurisprudencia/`, `legislacion/`) | `frauden-expedientes` | Acciones visibles |
+| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
+| `uploaders` | Listar y subir/reemplazar (`ListBucket`, `PutObject`). Denegación explícita de obtener versiones/objetos y eliminar versiones/objetos. | Sin acceso | Solo `Subir`; sin selección, descarga, copia, eliminación ni creación dedicada de carpetas |
+| `admin` | Lectura y escritura según las reglas de Amplify existentes | Sin cambios | Sin cambios |
+| `eliminadores` | Eliminación según las reglas de Amplify existentes | Eliminación según las reglas de Amplify existentes | Sin cambios |
+| `gexpedientes` | Sin cambios | Lectura/escritura de la carpeta privada de su identidad | Sin cambios |
+| Autenticado sin esos grupos | Lectura según las reglas de Amplify existentes | Sin acceso por la denegación del rol autenticado | Sin cambios |
+
+`Sincronizar conocimiento` permanece disponible para `uploaders`; su endpoint,
+token y payload no cambian. El grupo `uploaders` debe existir previamente en el
+User Pool. Amplify genera su rol y salidas de cliente, omite únicamente la creación
+del grupo y usa `UpdateGroup` para asociar el grupo existente con el rol generado.
+
+## Vencimiento por inactividad
+
+Toda sesión de esta aplicación web vence tras 60 minutos efectivos sin actividad.
+Se consideran actividad el teclado, puntero, toque y desplazamiento, además de las
+listas/búsquedas, cargas, copias, eliminaciones, creación de carpetas, preparación
+de descargas y solicitudes de sincronización. La renovación automática de tokens
+no cuenta como actividad.
+
+Mientras exista una operación activa el cierre se aplaza. Al finalizar la última
+operación comienza un nuevo período completo de 60 minutos. La última actividad y
+los marcadores de operación se comparten entre pestañas mediante `localStorage` y
+`BroadcastChannel`; los marcadores tienen una concesión renovable y caducan si una
+pestaña desaparece abruptamente. Al volver a una pestaña se comprueba el vencimiento
+antes de aceptar nueva actividad.
+
+El vencimiento ejecuta un cierre local de Cognito una sola vez por pestaña, vuelve
+al Authenticator y muestra el motivo. No hay aviso previo ni cierre global de otros
+dispositivos. Esta es una garantía de la aplicación web, no un control centralizado
+sobre tokens utilizados fuera de ella.
+
## Getting Started
### Installation
1. Clone this repository
+
```bash
git clone
cd sample-amplify-storage-browser
```
2. Install dependencies
+
```bash
npm install
```
3. Initialize and deploy the Amplify backend
+
```bash
npx ampx sandbox
```
@@ -74,5 +117,4 @@ See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more inform
This library is licensed under the MIT-0 License. See the LICENSE file.
-
_These sample applications are provided as a reference to help get started easily and are not supported by AWS Support._
diff --git a/amplify/auth/resource.ts b/amplify/auth/resource.ts
index e10a2e5..b7c42b8 100644
--- a/amplify/auth/resource.ts
+++ b/amplify/auth/resource.ts
@@ -8,5 +8,7 @@ export const auth = defineAuth({
loginWith: {
email: true,
},
- groups: ['admin', 'eliminadores'],
+ // Array order defines Cognito group precedence. `uploaders` must win for
+ // users that also belong to a more privileged group.
+ groups: ['uploaders', 'admin', 'eliminadores'],
});
diff --git a/amplify/backend.ts b/amplify/backend.ts
index cddb309..4bd6804 100644
--- a/amplify/backend.ts
+++ b/amplify/backend.ts
@@ -1,5 +1,5 @@
import { defineBackend } from '@aws-amplify/backend';
-import { Aspects, IAspect } from 'aws-cdk-lib';
+import { Aspects, CfnCondition, Fn, IAspect, Stack } from 'aws-cdk-lib';
import {
AwsCustomResource,
AwsCustomResourcePolicy,
@@ -39,7 +39,8 @@ class GeneratedNodeRuntime implements IAspect {
Aspects.of(backend.stack).add(new GeneratedNodeRuntime());
const expedientesBucket = backend.secondaryStorage.resources.bucket;
-const expedientesKmsKeyArn = 'arn:aws:kms:us-east-1:953290282809:key/e0b3229c-c603-4e8f-9157-8abbed28943f';
+const expedientesKmsKeyArn =
+ 'arn:aws:kms:us-east-1:953290282809:key/e0b3229c-c603-4e8f-9157-8abbed28943f';
const expedientesCfnBucket = expedientesBucket.node.defaultChild as CfnBucket;
expedientesCfnBucket.bucketEncryption = {
@@ -54,13 +55,76 @@ expedientesCfnBucket.bucketEncryption = {
};
const expedientesAccessStack = backend.createStack('expedientesAccess');
+const primaryBucket = backend.storage.resources.bucket;
const expedientesKmsKey = Key.fromKeyArn(
expedientesAccessStack,
'ExpedientesKmsKey',
expedientesKmsKeyArn
);
const gexpedientesGroupName = 'gexpedientes';
+const uploadersGroupName = 'uploaders';
const privateIdentityPrefix = 'privado/${cognito-identity.amazonaws.com:sub}';
+const uploadersGroup = backend.auth.resources.groups[uploadersGroupName];
+
+if (!uploadersGroup) {
+ throw new Error('Amplify did not generate the expected uploaders group resources.');
+}
+
+// `uploaders` already exists in the deployed User Pool. Keep Amplify's generated
+// IAM role and client outputs, but make only the CfnUserPoolGroup resource a
+// no-op so CloudFormation never attempts to recreate the existing group.
+const skipExistingUploadersGroup = new CfnCondition(
+ Stack.of(uploadersGroup.cfnUserGroup),
+ 'SkipExistingUploadersGroupCreation',
+ { expression: Fn.conditionEquals('existing', 'create') }
+);
+uploadersGroup.cfnUserGroup.cfnOptions.condition = skipExistingUploadersGroup;
+
+const linkUploadersGroupRoleCall = {
+ service: 'cognito-identity-provider',
+ action: 'UpdateGroup',
+ parameters: {
+ GroupName: uploadersGroupName,
+ UserPoolId: backend.auth.resources.userPool.userPoolId,
+ RoleArn: uploadersGroup.role.roleArn,
+ Precedence: 0,
+ },
+ physicalResourceId: PhysicalResourceId.of('uploaders-group-role-link'),
+};
+
+new AwsCustomResource(expedientesAccessStack, 'LinkUploadersGroupRole', {
+ onCreate: linkUploadersGroupRoleCall,
+ onUpdate: linkUploadersGroupRoleCall,
+ installLatestAwsSdk: false,
+ policy: AwsCustomResourcePolicy.fromStatements([
+ new PolicyStatement({
+ effect: Effect.ALLOW,
+ actions: ['cognito-idp:UpdateGroup'],
+ resources: [backend.auth.resources.userPool.userPoolArn],
+ }),
+ new PolicyStatement({
+ effect: Effect.ALLOW,
+ actions: ['iam:PassRole'],
+ resources: [uploadersGroup.role.roleArn],
+ }),
+ ]),
+});
+
+new Policy(expedientesAccessStack, 'UploadersObjectReadDeleteDeny', {
+ roles: [uploadersGroup.role],
+ statements: [
+ new PolicyStatement({
+ effect: Effect.DENY,
+ actions: ['s3:GetObject', 's3:GetObjectVersion', 's3:DeleteObject', 's3:DeleteObjectVersion'],
+ resources: [
+ `${primaryBucket.bucketArn}/doctrina/*`,
+ `${primaryBucket.bucketArn}/medios/*`,
+ `${primaryBucket.bucketArn}/jurisprudencia/*`,
+ `${primaryBucket.bucketArn}/legislacion/*`,
+ ],
+ }),
+ ],
+});
// Storage group rules replace {entity_id} with a wildcard. Keep the owner-scoped
// storage output and enforce the gexpedientes gate from a separate stack. The
@@ -90,7 +154,7 @@ const linkGExpedientesGroupRoleCall = {
GroupName: gexpedientesGroupName,
UserPoolId: backend.auth.resources.userPool.userPoolId,
RoleArn: gexpedientesRole.roleArn,
- Precedence: 2,
+ Precedence: 3,
},
physicalResourceId: PhysicalResourceId.of('gexpedientes-group-role-link'),
};
diff --git a/amplify/storage/resource.ts b/amplify/storage/resource.ts
index 9577154..485d89c 100644
--- a/amplify/storage/resource.ts
+++ b/amplify/storage/resource.ts
@@ -6,21 +6,25 @@ export const storage = defineStorage({
access: (allow) => ({
'doctrina/*': [
allow.authenticated.to(['read']),
+ allow.groups(['uploaders']).to(['list', 'write']),
allow.groups(['admin']).to(['read', 'write']),
allow.groups(['eliminadores']).to(['delete']),
],
'medios/*': [
allow.authenticated.to(['read']),
+ allow.groups(['uploaders']).to(['list', 'write']),
allow.groups(['admin']).to(['read', 'write']),
allow.groups(['eliminadores']).to(['delete']),
],
'jurisprudencia/*': [
allow.authenticated.to(['read']),
+ allow.groups(['uploaders']).to(['list', 'write']),
allow.groups(['admin']).to(['read', 'write']),
allow.groups(['eliminadores']).to(['delete']),
],
'legislacion/*': [
allow.authenticated.to(['read']),
+ allow.groups(['uploaders']).to(['list', 'write']),
allow.groups(['admin']).to(['read', 'write']),
allow.groups(['eliminadores']).to(['delete']),
],
diff --git a/package.json b/package.json
index 39b6a39..811c757 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
- "test": "tsx --test tests/storageBrowserPresentation.test.ts tests/storageBrowserComponents.test.tsx",
+ "test": "tsx --test tests/storageBrowserPresentation.test.ts tests/storageBrowserComponents.test.tsx tests/userAccess.test.ts tests/sessionActivity.test.ts",
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\""
},
"dependencies": {
diff --git a/src/App.css b/src/App.css
index 4bc6b78..4698a71 100644
--- a/src/App.css
+++ b/src/App.css
@@ -64,6 +64,43 @@ body::before {
min-height: 100vh;
}
+.idle-session-message {
+ position: fixed;
+ top: 1rem;
+ left: 50%;
+ z-index: 20;
+ width: min(42rem, calc(100% - 2rem));
+ padding: 0.9rem 1rem;
+ color: #7a281a;
+ font-weight: 700;
+ text-align: center;
+ background: #fff4f1;
+ border: 1px solid #efb8ad;
+ border-radius: 1rem;
+ box-shadow: 0 14px 36px rgba(16, 25, 54, 0.16);
+ transform: translateX(-50%);
+}
+
+.session-check {
+ display: grid;
+ min-height: 100vh;
+ padding: 2rem;
+ color: var(--frauden-navy);
+ font-weight: 700;
+ text-align: center;
+ place-content: center;
+}
+
+.session-check--error {
+ gap: 1rem;
+}
+
+.session-check--error p {
+ max-width: 36rem;
+ margin: 0;
+ color: #8a1f11;
+}
+
.app-shell {
width: min(1440px, 100%);
min-height: 100vh;
diff --git a/src/App.tsx b/src/App.tsx
index c93c4bf..09a6dcf 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -6,15 +6,19 @@ import {
import '@aws-amplify/ui-react/styles.css';
import '@aws-amplify/ui-react-storage/styles.css';
import './App.css';
-import { useEffect, useMemo, useState, type ComponentProps } from 'react';
+import { useCallback, useEffect, useMemo, useState, type ComponentProps } from 'react';
import config from '../amplify_outputs.json';
import { Amplify } from 'aws-amplify';
import { fetchAuthSession, fetchUserAttributes } from 'aws-amplify/auth';
import { I18n } from 'aws-amplify/utils';
-import { Authenticator, Button, translations } from '@aws-amplify/ui-react';
+import { Authenticator, Button, translations, View } from '@aws-amplify/ui-react';
import fraudenLogo from './assets/frauden-logo.svg';
+import { trackApplicationOperation } from './activityReporter';
import { listLocationItemsPage } from './listLocationItemsPage';
+import { useSessionActivity, useTrackApplicationProcessing } from './sessionActivityContext';
+import { SessionActivityProvider } from './sessionActivityReact';
import {
+ StorageActionsList,
StorageActionDestination,
StorageDataTable,
StorageNavigation,
@@ -25,10 +29,13 @@ import {
createBucketLabelMap,
createListAllLocationItemsHandler,
formatLocationTitle,
+ getPermissionDisplayName,
resolveUserDisplayName,
USER_FALLBACK_LABEL,
type AmplifyBucketConfig,
} from './storageBrowserPresentation';
+import { trackedDownloadHandler } from './trackedDownloadHandler';
+import { filterUploaderLocations, resolveUserAccess, type UserAccess } from './userAccess';
Amplify.configure(config);
I18n.putVocabularies(translations);
@@ -37,17 +44,54 @@ I18n.setLanguage('es');
const bucketLabels = createBucketLabelMap(
Amplify.getConfig().Storage?.S3?.buckets as AmplifyBucketConfig | undefined
);
+const primaryBucketName = Amplify.getConfig().Storage?.S3?.bucket;
+const amplifyAuthAdapter = createAmplifyAuthAdapter();
+const listAllLocationItems = createListAllLocationItemsHandler(listLocationItemsPage);
+let locationAdapterSessionId: string | undefined;
+let locationAdapter = amplifyAuthAdapter;
+
+const trackedAuthAdapter = {
+ ...amplifyAuthAdapter,
+ listLocations: (input: Parameters[0]) =>
+ trackApplicationOperation(async () => {
+ const session = await fetchAuthSession();
+ const payload = session.tokens?.accessToken?.payload;
+
+ if (!payload) {
+ throw new Error('No se pudo verificar el acceso a las ubicaciones de almacenamiento.');
+ }
+
+ const access = resolveUserAccess(payload as Record);
+ if (locationAdapterSessionId !== access.sessionId) {
+ locationAdapterSessionId = access.sessionId;
+ locationAdapter = createAmplifyAuthAdapter();
+ }
+ const result = await locationAdapter.listLocations(input);
+
+ if (!access.isUploader) return result;
+ if (!primaryBucketName) {
+ throw new Error('No se encontró la configuración del almacenamiento principal.');
+ }
+
+ return {
+ ...result,
+ items: filterUploaderLocations(result.items, primaryBucketName),
+ };
+ }),
+};
-const { StorageBrowser } = createStorageBrowser({
- config: createAmplifyAuthAdapter(),
+const { StorageBrowser, useView } = createStorageBrowser({
+ config: trackedAuthAdapter,
actions: {
default: {
- listLocationItems: createListAllLocationItemsHandler(listLocationItemsPage),
+ download: trackedDownloadHandler,
+ listLocationItems: (input) => trackApplicationOperation(() => listAllLocationItems(input)),
},
},
components: {
...componentsDefault,
ActionDestination: StorageActionDestination,
+ ActionsList: StorageActionsList,
DataTable: StorageDataTable,
Navigation: StorageNavigation,
Pagination: StoragePagination,
@@ -75,6 +119,140 @@ function AllItemsLocationDetailView(props: LocationDetailViewProps) {
);
}
+function TrackedUploadView() {
+ const state = useView('Upload');
+ useTrackApplicationProcessing(state.isProcessing);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function TrackedCopyView() {
+ const state = useView('Copy');
+ useTrackApplicationProcessing(state.isProcessing);
+
+ return (
+
+
+
+
+
+
+
+ {state.isProcessing || state.isProcessingComplete ? null : (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function TrackedDeleteView() {
+ const state = useView('Delete');
+ useTrackApplicationProcessing(state.isProcessing);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function TrackedCreateFolderView() {
+ const state = useView('CreateFolder');
+ useTrackApplicationProcessing(state.isProcessing);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
type StorageBrowserDisplayText = NonNullable[0]['displayText']>;
type KnowledgeSyncStatus = {
@@ -102,6 +280,7 @@ const authenticatorComponents = {
function KnowledgeSyncButton() {
const [isSyncing, setIsSyncing] = useState(false);
const [syncStatus, setSyncStatus] = useState(null);
+ const { beginOperation } = useSessionActivity();
const handleKnowledgeSync = async () => {
if (!syncKnowledgeEndpoint) {
@@ -115,6 +294,7 @@ function KnowledgeSyncButton() {
setIsSyncing(true);
setSyncStatus({ type: 'info', message: 'Solicitando sincronización de conocimiento...' });
+ const finishOperation = beginOperation();
try {
const session = await fetchAuthSession();
@@ -133,9 +313,11 @@ function KnowledgeSyncButton() {
body: JSON.stringify({ action: 'syncKnowledgeBase' }),
});
- void syncRequest.catch((error) => {
- console.warn('No se pudo leer la respuesta de sincronización.', error);
- });
+ void syncRequest
+ .catch((error) => {
+ console.warn('No se pudo leer la respuesta de sincronización.', error);
+ })
+ .finally(finishOperation);
setSyncStatus({
type: 'success',
@@ -144,6 +326,7 @@ function KnowledgeSyncButton() {
window.setTimeout(() => setIsSyncing(false), syncKnowledgeButtonCooldownMs);
} catch (error) {
+ finishOperation();
setSyncStatus({
type: 'error',
message:
@@ -194,16 +377,7 @@ const storageBrowserDisplayTextBase: StorageBrowserDisplayText = {
tableColumnFolderHeader: 'Carpeta',
tableColumnPermissionsHeader: 'Permisos',
tableColumnActionsHeader: 'Acciones',
- getPermissionName: (permissions) => {
- const canRead = permissions.includes('get') || permissions.includes('list');
- const canWrite = permissions.includes('write') || permissions.includes('delete');
-
- if (canRead && canWrite) return 'Lectura/Escritura';
- if (canRead) return 'Lectura';
- if (canWrite) return 'Escritura';
-
- return permissions.join('/');
- },
+ getPermissionName: (permissions) => getPermissionDisplayName(permissions),
getDownloadLabel: (fileName) => `Descargar ${fileName}`,
getListLocationsResultMessage: (data) => {
const { isLoading, items, hasExhaustedSearch, hasError = false, message } = data ?? {};
@@ -390,8 +564,15 @@ const storageBrowserDisplayTextBase: StorageBrowserDisplayText = {
},
};
-const createStorageBrowserDisplayText = (userLabel: string): StorageBrowserDisplayText => ({
+const createStorageBrowserDisplayText = (
+ userLabel: string,
+ isUploader: boolean
+): StorageBrowserDisplayText => ({
...storageBrowserDisplayTextBase,
+ LocationsView: {
+ ...storageBrowserDisplayTextBase.LocationsView,
+ getPermissionName: (permissions) => getPermissionDisplayName(permissions, isUploader),
+ },
LocationDetailView: {
...storageBrowserDisplayTextBase.LocationDetailView,
getTitle: ({ current, key }) =>
@@ -434,16 +615,78 @@ function useUserDisplayName() {
}
type AuthenticatedContentProps = {
+ onAuthenticated: () => void;
+ onSessionExpired: () => void;
signOut?: () => void;
username?: string;
};
-function AuthenticatedContent({ signOut, username }: AuthenticatedContentProps) {
+type UserAccessState =
+ | { status: 'loading' }
+ | { access: UserAccess; status: 'ready' }
+ | { message: string; status: 'error' };
+
+function useAuthenticatedUserAccess() {
+ const [state, setState] = useState({ status: 'loading' });
+
+ useEffect(() => {
+ let isCurrent = true;
+
+ void fetchAuthSession()
+ .then((session) => {
+ const payload = session.tokens?.accessToken?.payload;
+ if (!payload) {
+ throw new Error('No se pudo leer el token de acceso de la sesión.');
+ }
+
+ const access = resolveUserAccess(payload as Record);
+ if (isCurrent) setState({ access, status: 'ready' });
+ })
+ .catch((error) => {
+ if (isCurrent) {
+ setState({
+ message:
+ error instanceof Error
+ ? error.message
+ : 'No se pudieron verificar los permisos de la sesión.',
+ status: 'error',
+ });
+ }
+ });
+
+ return () => {
+ isCurrent = false;
+ };
+ }, []);
+
+ return state;
+}
+
+function ApplicationShell({
+ access,
+ signOut,
+ username,
+}: Pick & { access: UserAccess }) {
const userLabel = useUserDisplayName();
+ const { clearSharedState } = useSessionActivity();
const storageBrowserDisplayText = useMemo(
- () => createStorageBrowserDisplayText(userLabel),
- [userLabel]
+ () => createStorageBrowserDisplayText(userLabel, access.isUploader),
+ [access.isUploader, userLabel]
+ );
+ const storageBrowserViews = useMemo(
+ () => ({
+ CopyView: TrackedCopyView,
+ CreateFolderView: TrackedCreateFolderView,
+ DeleteView: TrackedDeleteView,
+ LocationDetailView: AllItemsLocationDetailView,
+ UploadView: TrackedUploadView,
+ }),
+ []
);
+ const handleSignOut = () => {
+ clearSharedState();
+ signOut?.();
+ };
return (
@@ -456,7 +699,7 @@ function AuthenticatedContent({ signOut, username }: AuthenticatedContentProps)
Sesión activa
{username ?? USER_FALLBACK_LABEL}
-
+
Cerrar sesión
@@ -474,22 +717,85 @@ function AuthenticatedContent({ signOut, username }: AuthenticatedContentProps)
);
}
+function AuthenticatedContent({
+ onAuthenticated,
+ onSessionExpired,
+ signOut,
+ username,
+}: AuthenticatedContentProps) {
+ const accessState = useAuthenticatedUserAccess();
+
+ useEffect(() => {
+ onAuthenticated();
+ }, [onAuthenticated]);
+
+ if (accessState.status === 'loading') {
+ return (
+
+ Verificando permisos de la sesión...
+
+ );
+ }
+
+ if (accessState.status === 'error') {
+ return (
+
+
{accessState.message}
+
+ Volver a iniciar sesión
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
+
function App() {
+ const [sessionExpired, setSessionExpired] = useState(false);
+ const clearSessionExpiredMessage = useCallback(() => setSessionExpired(false), []);
+
return (
-
- {({ signOut, user }) => }
-
+ <>
+ {sessionExpired ? (
+
+ La sesión terminó después de 60 minutos de inactividad. Inicia sesión nuevamente.
+
+ ) : null}
+
+ {({ signOut, user }) => (
+ {
+ setSessionExpired(true);
+ signOut?.();
+ }}
+ signOut={signOut}
+ username={user?.username}
+ />
+ )}
+
+ >
);
}
diff --git a/src/activityReporter.ts b/src/activityReporter.ts
new file mode 100644
index 0000000..8c30256
--- /dev/null
+++ b/src/activityReporter.ts
@@ -0,0 +1,24 @@
+export type ActivityReporter = {
+ beginOperation: () => () => void;
+ recordActivity: () => void;
+};
+
+let reporter: ActivityReporter | undefined;
+
+export function registerActivityReporter(nextReporter: ActivityReporter) {
+ reporter = nextReporter;
+
+ return () => {
+ if (reporter === nextReporter) reporter = undefined;
+ };
+}
+
+export async function trackApplicationOperation(operation: () => Promise): Promise {
+ const finish = reporter?.beginOperation();
+
+ try {
+ return await operation();
+ } finally {
+ finish?.();
+ }
+}
diff --git a/src/sessionActivity.ts b/src/sessionActivity.ts
new file mode 100644
index 0000000..a292a0f
--- /dev/null
+++ b/src/sessionActivity.ts
@@ -0,0 +1,409 @@
+export const SESSION_INACTIVITY_MS = 60 * 60 * 1000;
+export const BUSY_MARKER_LEASE_MS = 2 * 60 * 1000;
+export const BUSY_MARKER_HEARTBEAT_MS = 30 * 1000;
+
+export type ActivityClock = {
+ clearInterval: (handle: unknown) => void;
+ clearTimeout: (handle: unknown) => void;
+ now: () => number;
+ setInterval: (callback: () => void, delay: number) => unknown;
+ setTimeout: (callback: () => void, delay: number) => unknown;
+};
+
+export type ActivityStorage = Pick<
+ Storage,
+ 'getItem' | 'key' | 'length' | 'removeItem' | 'setItem'
+>;
+
+export type SessionActivityCoordinatorOptions = {
+ busyHeartbeatMs?: number;
+ busyLeaseMs?: number;
+ clock: ActivityClock;
+ inactivityMs?: number;
+ notifyPeers?: () => void;
+ onExpire: () => void;
+ sessionId: string;
+ storage: ActivityStorage;
+ tabId: string;
+ userId: string;
+};
+
+type SessionRecord = {
+ lastActivity: number;
+ sessionId: string;
+};
+
+type BusyRecord = {
+ expiresAt: number;
+ sessionId: string;
+ tabId: string;
+};
+
+type LogoutRecord = {
+ expiredAt: number;
+ sessionId: string;
+};
+
+const parseRecord = (value: string | null): T | undefined => {
+ if (!value) return undefined;
+
+ try {
+ return JSON.parse(value) as T;
+ } catch {
+ return undefined;
+ }
+};
+
+const isFiniteTimestamp = (value: unknown): value is number =>
+ typeof value === 'number' && Number.isFinite(value);
+
+export class SessionActivityCoordinator {
+ readonly keyPrefix: string;
+
+ get hasExpired() {
+ return this.expired;
+ }
+
+ private readonly busyHeartbeatMs: number;
+ private readonly busyKey: string;
+ private readonly busyLeaseMs: number;
+ private readonly clock: ActivityClock;
+ private readonly inactivityMs: number;
+ private readonly logoutKey: string;
+ private readonly notifyPeers?: () => void;
+ private readonly onExpire: () => void;
+ private readonly sessionId: string;
+ private readonly stateKey: string;
+ private readonly storage: ActivityStorage;
+ private readonly tabId: string;
+ private evaluationTimer?: unknown;
+ private expired = false;
+ private heartbeatTimer?: unknown;
+ private localOperations = 0;
+ private started = false;
+
+ constructor(options: SessionActivityCoordinatorOptions) {
+ this.clock = options.clock;
+ this.storage = options.storage;
+ this.onExpire = options.onExpire;
+ this.notifyPeers = options.notifyPeers;
+ this.sessionId = options.sessionId;
+ this.tabId = options.tabId;
+ this.inactivityMs = options.inactivityMs ?? SESSION_INACTIVITY_MS;
+ this.busyLeaseMs = options.busyLeaseMs ?? BUSY_MARKER_LEASE_MS;
+ this.busyHeartbeatMs = options.busyHeartbeatMs ?? BUSY_MARKER_HEARTBEAT_MS;
+ this.keyPrefix = `frauden:session-activity:v1:${encodeURIComponent(options.userId)}`;
+ this.stateKey = `${this.keyPrefix}:state`;
+ this.logoutKey = `${this.keyPrefix}:logout`;
+ this.busyKey = `${this.keyPrefix}:busy:${this.tabId}`;
+ }
+
+ start() {
+ if (this.started) return;
+ this.started = true;
+
+ const logout = this.readLogout();
+ if (logout?.sessionId === this.sessionId) {
+ this.expireLocally();
+ return;
+ }
+
+ const current = this.readSession();
+ if (!current || current.sessionId !== this.sessionId) {
+ this.removeKeys(`${this.keyPrefix}:busy:`);
+ this.storage.removeItem(this.logoutKey);
+ this.writeSession(this.clock.now());
+ this.notifyPeers?.();
+ }
+
+ this.synchronize();
+ }
+
+ recordActivity() {
+ if (!this.canContinue()) return;
+
+ this.writeSession(this.clock.now());
+ this.scheduleEvaluation();
+ this.notifyPeers?.();
+ }
+
+ beginOperation(): () => void {
+ if (!this.canContinue()) return () => undefined;
+
+ this.localOperations += 1;
+ this.writeBusyMarker();
+
+ if (this.localOperations === 1) {
+ this.heartbeatTimer = this.clock.setInterval(() => {
+ if (!this.expired && this.localOperations > 0) {
+ this.writeBusyMarker();
+ this.scheduleEvaluation();
+ this.notifyPeers?.();
+ }
+ }, this.busyHeartbeatMs);
+ }
+
+ this.scheduleEvaluation();
+ this.notifyPeers?.();
+
+ let released = false;
+ return () => {
+ if (released) return;
+ released = true;
+ this.finishOperation();
+ };
+ }
+
+ synchronize() {
+ if (!this.started || this.expired) return;
+
+ const logout = this.readLogout();
+ if (logout?.sessionId === this.sessionId) {
+ this.expireLocally();
+ return;
+ }
+
+ const current = this.readSession();
+ if (!current || current.sessionId !== this.sessionId) {
+ this.stopTimers();
+ return;
+ }
+
+ if (this.localOperations > 0) {
+ const ownMarker = this.readBusyRecord(this.busyKey);
+ if (!ownMarker || ownMarker.expiresAt <= this.clock.now()) {
+ this.writeBusyMarker();
+ }
+ }
+
+ this.removeStaleBusyMarkers();
+ this.evaluateExpiry(current);
+ }
+
+ clearSharedState() {
+ this.stopTimers();
+ this.removeKeys(this.keyPrefix);
+ this.localOperations = 0;
+ this.notifyPeers?.();
+ }
+
+ dispose() {
+ this.stopTimers();
+ this.storage.removeItem(this.busyKey);
+ this.localOperations = 0;
+ this.notifyPeers?.();
+ }
+
+ private canContinue() {
+ if (!this.started || this.expired) return false;
+
+ const logout = this.readLogout();
+ if (logout?.sessionId === this.sessionId) {
+ this.expireLocally();
+ return false;
+ }
+
+ const current = this.readSession();
+ if (!current || current.sessionId !== this.sessionId) return false;
+
+ if (this.localOperations > 0) {
+ this.writeBusyMarker();
+ }
+
+ this.removeStaleBusyMarkers();
+ if (
+ this.clock.now() >= current.lastActivity + this.inactivityMs &&
+ this.readActiveBusyMarkers().length === 0
+ ) {
+ this.expireSession();
+ return false;
+ }
+
+ return true;
+ }
+
+ private finishOperation() {
+ if (this.localOperations === 0) return;
+ this.localOperations -= 1;
+
+ if (this.localOperations > 0) {
+ this.writeBusyMarker();
+ return;
+ }
+
+ if (this.heartbeatTimer !== undefined) {
+ this.clock.clearInterval(this.heartbeatTimer);
+ this.heartbeatTimer = undefined;
+ }
+ this.storage.removeItem(this.busyKey);
+
+ if (!this.expired) {
+ const current = this.readSession();
+ if (current?.sessionId === this.sessionId) {
+ this.removeStaleBusyMarkers();
+ if (this.readActiveBusyMarkers().length === 0) {
+ this.writeSession(this.clock.now());
+ }
+ this.scheduleEvaluation();
+ }
+ }
+
+ this.notifyPeers?.();
+ }
+
+ private evaluateExpiry(current: SessionRecord) {
+ const now = this.clock.now();
+ const deadline = current.lastActivity + this.inactivityMs;
+ const busyMarkers = this.readActiveBusyMarkers();
+
+ if (now >= deadline && busyMarkers.length === 0) {
+ this.expireSession();
+ return;
+ }
+
+ this.scheduleEvaluation(current, busyMarkers);
+ }
+
+ private scheduleEvaluation(
+ current = this.readSession(),
+ busyMarkers = this.readActiveBusyMarkers()
+ ) {
+ if (this.evaluationTimer !== undefined) {
+ this.clock.clearTimeout(this.evaluationTimer);
+ this.evaluationTimer = undefined;
+ }
+
+ if (this.expired || !current || current.sessionId !== this.sessionId) return;
+
+ const now = this.clock.now();
+ const inactivityDeadline = current.lastActivity + this.inactivityMs;
+ const nextDeadline =
+ now < inactivityDeadline || busyMarkers.length === 0
+ ? inactivityDeadline
+ : Math.min(...busyMarkers.map(({ expiresAt }) => expiresAt));
+ const delay = Math.max(0, nextDeadline - now);
+
+ this.evaluationTimer = this.clock.setTimeout(() => {
+ this.evaluationTimer = undefined;
+ this.synchronize();
+ }, delay);
+ }
+
+ private expireSession() {
+ if (this.expired) return;
+
+ const logout: LogoutRecord = {
+ expiredAt: this.clock.now(),
+ sessionId: this.sessionId,
+ };
+ this.storage.setItem(this.logoutKey, JSON.stringify(logout));
+ this.storage.removeItem(this.stateKey);
+ this.removeKeys(`${this.keyPrefix}:busy:`);
+ this.notifyPeers?.();
+ this.expireLocally();
+ }
+
+ private expireLocally() {
+ if (this.expired) return;
+ this.expired = true;
+ this.stopTimers();
+ this.localOperations = 0;
+ this.onExpire();
+ }
+
+ private stopTimers() {
+ if (this.evaluationTimer !== undefined) {
+ this.clock.clearTimeout(this.evaluationTimer);
+ this.evaluationTimer = undefined;
+ }
+ if (this.heartbeatTimer !== undefined) {
+ this.clock.clearInterval(this.heartbeatTimer);
+ this.heartbeatTimer = undefined;
+ }
+ }
+
+ private writeSession(lastActivity: number) {
+ const current = this.readSession();
+ const record: SessionRecord = {
+ lastActivity:
+ current?.sessionId === this.sessionId
+ ? Math.max(current.lastActivity, lastActivity)
+ : lastActivity,
+ sessionId: this.sessionId,
+ };
+ this.storage.setItem(this.stateKey, JSON.stringify(record));
+ }
+
+ private readSession(): SessionRecord | undefined {
+ const record = parseRecord(this.storage.getItem(this.stateKey));
+ return record && isFiniteTimestamp(record.lastActivity) && typeof record.sessionId === 'string'
+ ? record
+ : undefined;
+ }
+
+ private readLogout(): LogoutRecord | undefined {
+ const record = parseRecord(this.storage.getItem(this.logoutKey));
+ return record && isFiniteTimestamp(record.expiredAt) && typeof record.sessionId === 'string'
+ ? record
+ : undefined;
+ }
+
+ private writeBusyMarker() {
+ const record: BusyRecord = {
+ expiresAt: this.clock.now() + this.busyLeaseMs,
+ sessionId: this.sessionId,
+ tabId: this.tabId,
+ };
+ this.storage.setItem(this.busyKey, JSON.stringify(record));
+ }
+
+ private readBusyRecord(key: string): BusyRecord | undefined {
+ const record = parseRecord(this.storage.getItem(key));
+ return record &&
+ isFiniteTimestamp(record.expiresAt) &&
+ typeof record.sessionId === 'string' &&
+ typeof record.tabId === 'string'
+ ? record
+ : undefined;
+ }
+
+ private readActiveBusyMarkers(): BusyRecord[] {
+ const records: BusyRecord[] = [];
+ const prefix = `${this.keyPrefix}:busy:`;
+ const now = this.clock.now();
+
+ for (const key of this.getKeys(prefix)) {
+ const record = this.readBusyRecord(key);
+ if (record?.sessionId === this.sessionId && record.expiresAt > now) {
+ records.push(record);
+ }
+ }
+
+ return records;
+ }
+
+ private removeStaleBusyMarkers() {
+ const now = this.clock.now();
+ for (const key of this.getKeys(`${this.keyPrefix}:busy:`)) {
+ const record = this.readBusyRecord(key);
+ if (!record || record.sessionId !== this.sessionId || record.expiresAt <= now) {
+ this.storage.removeItem(key);
+ }
+ }
+ }
+
+ private removeKeys(prefix: string) {
+ for (const key of this.getKeys(prefix)) {
+ this.storage.removeItem(key);
+ }
+ }
+
+ private getKeys(prefix: string) {
+ const keys: string[] = [];
+ for (let index = 0; index < this.storage.length; index += 1) {
+ const key = this.storage.key(index);
+ if (key?.startsWith(prefix)) keys.push(key);
+ }
+ return keys;
+ }
+}
diff --git a/src/sessionActivityContext.ts b/src/sessionActivityContext.ts
new file mode 100644
index 0000000..6014e63
--- /dev/null
+++ b/src/sessionActivityContext.ts
@@ -0,0 +1,26 @@
+import React from 'react';
+
+export type SessionActivityContextValue = {
+ beginOperation: () => () => void;
+ clearSharedState: () => void;
+ recordActivity: () => void;
+};
+
+export const SessionActivityContext = React.createContext({
+ beginOperation: () => () => undefined,
+ clearSharedState: () => undefined,
+ recordActivity: () => undefined,
+});
+
+export function useSessionActivity() {
+ return React.useContext(SessionActivityContext);
+}
+
+export function useTrackApplicationProcessing(isProcessing: boolean) {
+ const { beginOperation } = useSessionActivity();
+
+ React.useEffect(() => {
+ if (!isProcessing) return undefined;
+ return beginOperation();
+ }, [beginOperation, isProcessing]);
+}
diff --git a/src/sessionActivityReact.tsx b/src/sessionActivityReact.tsx
new file mode 100644
index 0000000..7a61c87
--- /dev/null
+++ b/src/sessionActivityReact.tsx
@@ -0,0 +1,121 @@
+import React, { type ReactNode } from 'react';
+import { registerActivityReporter } from './activityReporter';
+import { SessionActivityCoordinator, type ActivityClock } from './sessionActivity';
+import { SessionActivityContext, type SessionActivityContextValue } from './sessionActivityContext';
+
+const browserClock: ActivityClock = {
+ clearInterval: (handle) => window.clearInterval(handle as number),
+ clearTimeout: (handle) => window.clearTimeout(handle as number),
+ now: () => Date.now(),
+ setInterval: (callback, delay) => window.setInterval(callback, delay),
+ setTimeout: (callback, delay) => window.setTimeout(callback, delay),
+};
+
+const createTabId = () =>
+ typeof globalThis.crypto?.randomUUID === 'function'
+ ? globalThis.crypto.randomUUID()
+ : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
+
+export function SessionActivityProvider({
+ children,
+ onExpire,
+ sessionId,
+ userId,
+}: {
+ children: ReactNode;
+ onExpire: () => void;
+ sessionId: string;
+ userId: string;
+}) {
+ const coordinatorRef = React.useRef(undefined);
+ const onExpireRef = React.useRef(onExpire);
+ const [isReady, setIsReady] = React.useState(false);
+
+ onExpireRef.current = onExpire;
+
+ const contextValue = React.useMemo(
+ () => ({
+ beginOperation: () => coordinatorRef.current?.beginOperation() ?? (() => undefined),
+ clearSharedState: () => coordinatorRef.current?.clearSharedState(),
+ recordActivity: () => coordinatorRef.current?.recordActivity(),
+ }),
+ []
+ );
+
+ React.useEffect(() => {
+ setIsReady(false);
+ const channelName = `frauden-session-activity:${encodeURIComponent(userId)}`;
+ let channel: BroadcastChannel | undefined;
+ try {
+ channel =
+ typeof BroadcastChannel === 'undefined' ? undefined : new BroadcastChannel(channelName);
+ } catch {
+ // The `storage` event remains available as the cross-tab fallback.
+ channel = undefined;
+ }
+ const coordinator = new SessionActivityCoordinator({
+ clock: browserClock,
+ notifyPeers: () => channel?.postMessage({ type: 'changed' }),
+ onExpire: () => onExpireRef.current(),
+ sessionId,
+ storage: window.localStorage,
+ tabId: createTabId(),
+ userId,
+ });
+ coordinatorRef.current = coordinator;
+
+ const unregisterReporter = registerActivityReporter({
+ beginOperation: () => coordinator.beginOperation(),
+ recordActivity: () => coordinator.recordActivity(),
+ });
+ const recordActivity = () => coordinator.recordActivity();
+ const synchronize = () => coordinator.synchronize();
+ const synchronizeWhenVisible = () => {
+ if (document.visibilityState === 'visible') synchronize();
+ };
+ const onStorage = (event: StorageEvent) => {
+ if (event.key?.startsWith(coordinator.keyPrefix)) synchronize();
+ };
+
+ channel?.addEventListener('message', synchronize);
+ window.addEventListener('storage', onStorage);
+ window.addEventListener('focus', synchronize);
+ document.addEventListener('visibilitychange', synchronizeWhenVisible);
+ window.addEventListener('keydown', recordActivity, true);
+ window.addEventListener('pointerdown', recordActivity, true);
+ window.addEventListener('touchstart', recordActivity, { capture: true, passive: true });
+ window.addEventListener('wheel', recordActivity, { capture: true, passive: true });
+ window.addEventListener('scroll', recordActivity, { capture: true, passive: true });
+
+ coordinator.start();
+ if (!coordinator.hasExpired) setIsReady(true);
+
+ return () => {
+ unregisterReporter();
+ coordinator.dispose();
+ channel?.removeEventListener('message', synchronize);
+ channel?.close();
+ window.removeEventListener('storage', onStorage);
+ window.removeEventListener('focus', synchronize);
+ document.removeEventListener('visibilitychange', synchronizeWhenVisible);
+ window.removeEventListener('keydown', recordActivity, true);
+ window.removeEventListener('pointerdown', recordActivity, true);
+ window.removeEventListener('touchstart', recordActivity, true);
+ window.removeEventListener('wheel', recordActivity, true);
+ window.removeEventListener('scroll', recordActivity, true);
+ if (coordinatorRef.current === coordinator) coordinatorRef.current = undefined;
+ };
+ }, [sessionId, userId]);
+
+ return (
+
+ {isReady ? (
+ children
+ ) : (
+
+ Verificando vigencia de la sesión...
+
+ )}
+
+ );
+}
diff --git a/src/storageBrowserActions.ts b/src/storageBrowserActions.ts
new file mode 100644
index 0000000..75bde19
--- /dev/null
+++ b/src/storageBrowserActions.ts
@@ -0,0 +1,6 @@
+export function filterStorageActionsForUploader(
+ items: T[],
+ isUploader: boolean
+): T[] {
+ return isUploader ? items.filter(({ actionType }) => actionType === 'upload') : items;
+}
diff --git a/src/storageBrowserComponents.tsx b/src/storageBrowserComponents.tsx
index 0b94b75..ac13adc 100644
--- a/src/storageBrowserComponents.tsx
+++ b/src/storageBrowserComponents.tsx
@@ -18,9 +18,11 @@ import {
USER_FALLBACK_LABEL,
type BucketLabelMap,
} from './storageBrowserPresentation';
+import { filterStorageActionsForUploader } from './storageBrowserActions';
type PresentationContextValue = {
bucketLabels: BucketLabelMap;
+ isUploader: boolean;
userLabel: string;
};
@@ -28,21 +30,42 @@ const EMPTY_BUCKET_LABELS = new Map();
const StoragePresentationContext = React.createContext({
bucketLabels: EMPTY_BUCKET_LABELS,
+ isUploader: false,
userLabel: USER_FALLBACK_LABEL,
});
export function StoragePresentationProvider({
bucketLabels,
children,
+ isUploader = false,
userLabel,
-}: PresentationContextValue & { children: ReactNode }) {
+}: Omit & {
+ children: ReactNode;
+ isUploader?: boolean;
+}) {
return (
-
+
{children}
);
}
+const DefaultActionsList = componentsDefault.ActionsList;
+type ActionsListProps = ComponentProps>;
+
+export function StorageActionsList(props: ActionsListProps) {
+ const { isUploader } = React.useContext(StoragePresentationContext);
+
+ if (!DefaultActionsList) return null;
+
+ return (
+
+ );
+}
+
const DefaultNavigation = componentsDefault.Navigation;
type NavigationProps = ComponentProps>;
@@ -360,7 +383,7 @@ const withCellLabel = (cell: DataTableCell, label: string): DataTableCell => {
};
export function StorageDataTable({ headers, isLoading, rows }: DataTableProps) {
- const { bucketLabels, userLabel } = React.useContext(StoragePresentationContext);
+ const { bucketLabels, isUploader, userLabel } = React.useContext(StoragePresentationContext);
const bucketColumnIndex = headers.findIndex(({ key }) => key === 'bucket');
const folderColumnIndex = headers.findIndex(({ key }) => key === 'folder');
const isLocationsTable =
@@ -369,7 +392,26 @@ export function StorageDataTable({ headers, isLoading, rows }: DataTableProps) {
headers.some(({ key }) => key === 'permission');
if (!isLocationsTable) {
- return ;
+ const hiddenColumnIndexes = new Set(
+ isUploader
+ ? headers
+ .map(({ key }, index) => (key === 'checkbox' || key === 'download' ? index : -1))
+ .filter((index) => index >= 0)
+ : []
+ );
+ const displayHeaders = headers.filter((_, index) => !hiddenColumnIndexes.has(index));
+ const displayRows = rows.map((row) => ({
+ ...row,
+ content: row.content.filter((_, index) => !hiddenColumnIndexes.has(index)),
+ }));
+
+ return (
+
+ );
}
const displayHeaders = headers.filter((_, index) => index !== bucketColumnIndex);
diff --git a/src/storageBrowserPresentation.ts b/src/storageBrowserPresentation.ts
index 398ed67..4a1d1ea 100644
--- a/src/storageBrowserPresentation.ts
+++ b/src/storageBrowserPresentation.ts
@@ -94,6 +94,24 @@ export function resolveUserDisplayName(attributes?: { email?: string; name?: str
return attributes?.name?.trim() || attributes?.email?.trim() || USER_FALLBACK_LABEL;
}
+export function getPermissionDisplayName(
+ permissions: readonly string[],
+ isUploader = false
+): string {
+ if (isUploader && permissions.includes('list') && permissions.includes('write')) {
+ return 'Listar/Subir';
+ }
+
+ const canRead = permissions.includes('get') || permissions.includes('list');
+ const canWrite = permissions.includes('write') || permissions.includes('delete');
+
+ if (canRead && canWrite) return 'Lectura/Escritura';
+ if (canRead) return 'Lectura';
+ if (canWrite) return 'Escritura';
+
+ return permissions.join('/');
+}
+
export function formatPrivatePath(path: string, userLabel: string): string | undefined {
const parts = getPathParts(path);
diff --git a/src/trackedDownloadHandler.ts b/src/trackedDownloadHandler.ts
new file mode 100644
index 0000000..bc6ef3c
--- /dev/null
+++ b/src/trackedDownloadHandler.ts
@@ -0,0 +1,44 @@
+import { getUrl } from '@aws-amplify/storage/internals';
+import type { ActionHandler } from '@aws-amplify/ui-react-storage/browser';
+import { trackApplicationOperation } from './activityReporter';
+
+const downloadFromUrl = (fileName: string, url: string) => {
+ const anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download = fileName;
+ anchor.target = '_blank';
+ document.body.appendChild(anchor);
+ anchor.click();
+ document.body.removeChild(anchor);
+};
+
+export const trackedDownloadHandler: ActionHandler<{ fileKey: string }, { url: URL }> = ({
+ config,
+ data: { key },
+}) => {
+ const result = trackApplicationOperation(async () => {
+ try {
+ const { url } = await getUrl({
+ path: key,
+ options: {
+ bucket: { bucketName: config.bucket, region: config.region },
+ contentDisposition: 'attachment',
+ customEndpoint: config.customEndpoint,
+ expectedBucketOwner: config.accountId,
+ locationCredentialsProvider: config.credentials,
+ validateObjectExistence: true,
+ },
+ });
+
+ downloadFromUrl(key, url.toString());
+ return { status: 'COMPLETE' as const, value: { url } };
+ } catch (error) {
+ return {
+ message: error instanceof Error ? error.message : 'No se pudo preparar la descarga.',
+ status: 'FAILED' as const,
+ };
+ }
+ });
+
+ return { result };
+};
diff --git a/src/userAccess.ts b/src/userAccess.ts
new file mode 100644
index 0000000..06f42b2
--- /dev/null
+++ b/src/userAccess.ts
@@ -0,0 +1,72 @@
+export const GROUP_PRECEDENCE = ['uploaders', 'admin', 'eliminadores', 'gexpedientes'] as const;
+
+export const UPLOADER_PREFIXES = ['doctrina', 'medios', 'jurisprudencia', 'legislacion'] as const;
+
+export type RecognizedGroup = (typeof GROUP_PRECEDENCE)[number];
+
+export type UserAccess = {
+ effectiveGroup?: RecognizedGroup;
+ groups: string[];
+ isUploader: boolean;
+ sessionId: string;
+ userId: string;
+};
+
+const getStringClaim = (payload: Record, name: string) => {
+ const value = payload[name];
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
+};
+
+export function getCognitoGroups(payload: Record): string[] {
+ const value = payload['cognito:groups'];
+
+ if (!Array.isArray(value)) {
+ return [];
+ }
+
+ return Array.from(
+ new Set(value.filter((group): group is string => typeof group === 'string' && Boolean(group)))
+ );
+}
+
+export function resolveUserAccess(payload: Record): UserAccess {
+ const userId = getStringClaim(payload, 'sub');
+
+ if (!userId) {
+ throw new Error('La sesión no contiene un identificador de usuario válido.');
+ }
+
+ const groups = getCognitoGroups(payload);
+ const effectiveGroup = GROUP_PRECEDENCE.find((group) => groups.includes(group));
+ const originJti = getStringClaim(payload, 'origin_jti');
+ const authenticationTime = payload.auth_time;
+ const sessionDiscriminator =
+ originJti ??
+ (typeof authenticationTime === 'number' && Number.isFinite(authenticationTime)
+ ? String(authenticationTime)
+ : undefined);
+
+ if (!sessionDiscriminator) {
+ throw new Error('La sesión no contiene un identificador de inicio de sesión válido.');
+ }
+
+ return {
+ effectiveGroup,
+ groups,
+ isUploader: effectiveGroup === 'uploaders',
+ sessionId: `${userId}:${sessionDiscriminator}`,
+ userId,
+ };
+}
+
+export function filterUploaderLocations<
+ T extends {
+ bucket: string;
+ prefix: string;
+ },
+>(locations: T[], primaryBucket: string): T[] {
+ return locations.filter(({ bucket, prefix }) => {
+ const [root] = prefix.replace(/^\/+/, '').split('/');
+ return bucket === primaryBucket && UPLOADER_PREFIXES.some((allowed) => allowed === root);
+ });
+}
diff --git a/tests/sessionActivity.test.ts b/tests/sessionActivity.test.ts
new file mode 100644
index 0000000..cad5367
--- /dev/null
+++ b/tests/sessionActivity.test.ts
@@ -0,0 +1,226 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ SESSION_INACTIVITY_MS,
+ SessionActivityCoordinator,
+ type ActivityClock,
+ type ActivityStorage,
+} from '../src/sessionActivity';
+
+test('configura el límite productivo en 60 minutos', () => {
+ assert.equal(SESSION_INACTIVITY_MS, 60 * 60 * 1000);
+});
+
+class MemoryStorage implements ActivityStorage {
+ private readonly values = new Map();
+
+ get length() {
+ return this.values.size;
+ }
+
+ getItem(key: string) {
+ return this.values.get(key) ?? null;
+ }
+
+ key(index: number) {
+ return Array.from(this.values.keys())[index] ?? null;
+ }
+
+ removeItem(key: string) {
+ this.values.delete(key);
+ }
+
+ setItem(key: string, value: string) {
+ this.values.set(key, value);
+ }
+}
+
+type Timer = {
+ callback: () => void;
+ interval?: number;
+ runAt: number;
+};
+
+class FakeClock implements ActivityClock {
+ private nextId = 1;
+ private readonly timers = new Map();
+ private value = 0;
+
+ now = () => this.value;
+
+ clearInterval = (handle: unknown) => {
+ this.timers.delete(handle as number);
+ };
+
+ clearTimeout = (handle: unknown) => {
+ this.timers.delete(handle as number);
+ };
+
+ setInterval = (callback: () => void, delay: number) => {
+ const id = this.nextId++;
+ this.timers.set(id, { callback, interval: delay, runAt: this.value + delay });
+ return id;
+ };
+
+ setTimeout = (callback: () => void, delay: number) => {
+ const id = this.nextId++;
+ this.timers.set(id, { callback, runAt: this.value + delay });
+ return id;
+ };
+
+ advance(milliseconds: number) {
+ const target = this.value + milliseconds;
+
+ while (true) {
+ const next = Array.from(this.timers.entries())
+ .filter(([, timer]) => timer.runAt <= target)
+ .sort((left, right) => left[1].runAt - right[1].runAt)[0];
+ if (!next) break;
+
+ const [id, timer] = next;
+ this.value = timer.runAt;
+ if (timer.interval === undefined) {
+ this.timers.delete(id);
+ } else {
+ timer.runAt += timer.interval;
+ }
+ timer.callback();
+ }
+
+ this.value = target;
+ }
+}
+
+const createCoordinator = ({
+ clock,
+ expired,
+ storage,
+ tabId = 'tab-a',
+}: {
+ clock: FakeClock;
+ expired: string[];
+ storage: MemoryStorage;
+ tabId?: string;
+}) =>
+ new SessionActivityCoordinator({
+ busyHeartbeatMs: 10,
+ busyLeaseMs: 30,
+ clock,
+ inactivityMs: 100,
+ onExpire: () => expired.push(tabId),
+ sessionId: 'user-1:login-1',
+ storage,
+ tabId,
+ userId: 'user-1',
+ });
+
+test('no cierra antes del límite y cierra exactamente al cumplirlo', () => {
+ const clock = new FakeClock();
+ const storage = new MemoryStorage();
+ const expired: string[] = [];
+ const coordinator = createCoordinator({ clock, expired, storage });
+
+ coordinator.start();
+ clock.advance(99);
+ assert.deepEqual(expired, []);
+ clock.advance(1);
+ assert.deepEqual(expired, ['tab-a']);
+});
+
+test('una actividad a los 59:59 reinicia una hora completa', () => {
+ const clock = new FakeClock();
+ const storage = new MemoryStorage();
+ const expired: string[] = [];
+ const coordinator = createCoordinator({ clock, expired, storage });
+
+ coordinator.start();
+ clock.advance(99);
+ coordinator.recordActivity();
+ clock.advance(99);
+ assert.deepEqual(expired, []);
+ clock.advance(1);
+ assert.deepEqual(expired, ['tab-a']);
+});
+
+test('una operación activa aplaza el cierre y al terminar concede el período completo', () => {
+ const clock = new FakeClock();
+ const storage = new MemoryStorage();
+ const expired: string[] = [];
+ const coordinator = createCoordinator({ clock, expired, storage });
+
+ coordinator.start();
+ const finish = coordinator.beginOperation();
+ clock.advance(250);
+ assert.deepEqual(expired, []);
+
+ finish();
+ clock.advance(99);
+ assert.deepEqual(expired, []);
+ clock.advance(1);
+ assert.deepEqual(expired, ['tab-a']);
+});
+
+test('una recarga conserva la última actividad y no revive una sesión vencida', () => {
+ const clock = new FakeClock();
+ const storage = new MemoryStorage();
+ const expired: string[] = [];
+ const first = createCoordinator({ clock, expired, storage });
+
+ first.start();
+ first.dispose();
+ clock.advance(101);
+
+ const reloaded = createCoordinator({ clock, expired, storage, tabId: 'tab-reloaded' });
+ reloaded.start();
+ assert.deepEqual(expired, ['tab-reloaded']);
+
+ const lateInteraction = reloaded.beginOperation();
+ lateInteraction();
+ assert.deepEqual(expired, ['tab-reloaded']);
+});
+
+test('comparte actividad, operaciones y cierre entre pestañas', () => {
+ const clock = new FakeClock();
+ const storage = new MemoryStorage();
+ const expired: string[] = [];
+ const first = createCoordinator({ clock, expired, storage, tabId: 'tab-a' });
+ const second = createCoordinator({ clock, expired, storage, tabId: 'tab-b' });
+
+ first.start();
+ second.start();
+ clock.advance(90);
+ second.recordActivity();
+ first.synchronize();
+ clock.advance(90);
+ assert.deepEqual(expired, []);
+
+ const finish = second.beginOperation();
+ first.synchronize();
+ clock.advance(150);
+ assert.deepEqual(expired, []);
+ finish();
+ first.synchronize();
+ clock.advance(100);
+ second.synchronize();
+
+ assert.deepEqual(new Set(expired), new Set(['tab-a', 'tab-b']));
+});
+
+test('un marcador abandonado caduca y no bloquea la sesión indefinidamente', () => {
+ const clock = new FakeClock();
+ const storage = new MemoryStorage();
+ const expired: string[] = [];
+ const coordinator = createCoordinator({ clock, expired, storage });
+
+ coordinator.start();
+ storage.setItem(
+ `${coordinator.keyPrefix}:busy:crashed-tab`,
+ JSON.stringify({ expiresAt: 120, sessionId: 'user-1:login-1', tabId: 'crashed-tab' })
+ );
+ coordinator.synchronize();
+
+ clock.advance(119);
+ assert.deepEqual(expired, []);
+ clock.advance(1);
+ assert.deepEqual(expired, ['tab-a']);
+});
diff --git a/tests/storageBrowserComponents.test.tsx b/tests/storageBrowserComponents.test.tsx
index 31ae98b..ebb76e1 100644
--- a/tests/storageBrowserComponents.test.tsx
+++ b/tests/storageBrowserComponents.test.tsx
@@ -2,7 +2,12 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
-import { StorageDataTable, StoragePresentationProvider } from '../src/storageBrowserComponents';
+import {
+ StorageActionsList,
+ StorageDataTable,
+ StoragePresentationProvider,
+} from '../src/storageBrowserComponents';
+import { filterStorageActionsForUploader } from '../src/storageBrowserActions';
import { createBucketLabelMap } from '../src/storageBrowserPresentation';
const PHYSICAL_BUCKET = 'frauden-expedientes-physical-bucket-456';
@@ -109,3 +114,83 @@ test('renderiza también las tablas de detalle y acciones', () => {
assert.match(html, /contrato\.pdf/);
assert.match(html, /12 KB/);
});
+
+test('para uploaders conserva únicamente la acción Subir', () => {
+ const actions = [
+ { actionType: 'copy', label: 'Copiar' },
+ { actionType: 'delete', label: 'Eliminar' },
+ { actionType: 'createFolder', label: 'Crear carpeta' },
+ { actionType: 'upload', label: 'Subir' },
+ ];
+ const uploaderHtml = renderToStaticMarkup(
+
+
+
+ );
+ const regularHtml = renderToStaticMarkup(
+
+
+
+ );
+
+ assert.deepEqual(
+ filterStorageActionsForUploader(actions, true).map(({ label }) => label),
+ ['Subir']
+ );
+ assert.deepEqual(
+ filterStorageActionsForUploader(actions, false).map(({ label }) => label),
+ ['Copiar', 'Eliminar', 'Crear carpeta', 'Subir']
+ );
+ assert.match(uploaderHtml, /aria-label="Menu Toggle"/);
+ assert.match(regularHtml, /aria-label="Menu Toggle"/);
+});
+
+test('oculta selección y descarga en el detalle uploader sin cambiar otros perfiles', () => {
+ const headers = [
+ {
+ key: 'checkbox',
+ type: 'checkbox' as const,
+ content: { id: 'select-all', label: 'Seleccionar todos' },
+ },
+ { key: 'name', type: 'text' as const, content: { text: 'Nombre' } },
+ { key: 'download', type: 'text' as const, content: { text: 'Descargar' } },
+ ];
+ const rows = [
+ {
+ key: 'file-1',
+ content: [
+ {
+ key: 'checkbox-file-1',
+ type: 'checkbox' as const,
+ content: { id: 'select-file-1', label: 'Seleccionar archivo' },
+ },
+ {
+ key: 'name-file-1',
+ type: 'text' as const,
+ content: { text: 'contrato.pdf' },
+ },
+ {
+ key: 'download-file-1',
+ type: 'button' as const,
+ content: { ariaLabel: 'Descargar contrato.pdf', icon: 'download' as const },
+ },
+ ],
+ },
+ ];
+ const uploaderHtml = renderToStaticMarkup(
+
+
+
+ );
+ const regularHtml = renderToStaticMarkup(
+
+
+
+ );
+
+ assert.match(uploaderHtml, /contrato\.pdf/);
+ assert.doesNotMatch(uploaderHtml, /type="checkbox"/);
+ assert.doesNotMatch(uploaderHtml, /Descargar contrato\.pdf/);
+ assert.match(regularHtml, /type="checkbox"/);
+ assert.match(regularHtml, /Descargar contrato\.pdf/);
+});
diff --git a/tests/storageBrowserPresentation.test.ts b/tests/storageBrowserPresentation.test.ts
index bece7fd..b973d2a 100644
--- a/tests/storageBrowserPresentation.test.ts
+++ b/tests/storageBrowserPresentation.test.ts
@@ -10,6 +10,7 @@ import {
formatPrivatePath,
getBucketDisplayName,
getNavigationCallbacks,
+ getPermissionDisplayName,
NEUTRAL_STORAGE_LABEL,
resolveUserDisplayName,
S3_LIST_PAGE_SIZE,
@@ -73,6 +74,13 @@ test('convierte la carpeta privada a nombre, correo o Usuario', () => {
assert.equal(formatPrivatePath(`privado-similar/${IDENTITY_ID}`, 'Ana Pérez'), undefined);
});
+test('muestra Listar/Subir solo para el perfil uploader', () => {
+ assert.equal(getPermissionDisplayName(['list', 'write'], true), 'Listar/Subir');
+ assert.equal(getPermissionDisplayName(['list', 'write']), 'Lectura/Escritura');
+ assert.equal(getPermissionDisplayName(['get', 'list']), 'Lectura');
+ assert.equal(getPermissionDisplayName(['delete']), 'Escritura');
+});
+
test('forma rutas normales, privadas y anidadas sin datos físicos', () => {
const normalRoot = formatNavigationRootLabel(
`${PRIMARY_BUCKET}/doctrina`,
diff --git a/tests/userAccess.test.ts b/tests/userAccess.test.ts
new file mode 100644
index 0000000..1425757
--- /dev/null
+++ b/tests/userAccess.test.ts
@@ -0,0 +1,58 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { filterUploaderLocations, GROUP_PRECEDENCE, resolveUserAccess } from '../src/userAccess';
+
+const payload = (groups: string[]) => ({
+ 'cognito:groups': groups,
+ origin_jti: 'login-123',
+ sub: 'user-123',
+});
+
+test('declara precedencias únicas en el orden restrictivo acordado', () => {
+ assert.deepEqual(GROUP_PRECEDENCE, ['uploaders', 'admin', 'eliminadores', 'gexpedientes']);
+ assert.equal(new Set(GROUP_PRECEDENCE).size, GROUP_PRECEDENCE.length);
+});
+
+test('uploaders prevalece ante cualquier combinación de grupos privilegiados', () => {
+ for (const groups of [
+ ['admin', 'uploaders'],
+ ['eliminadores', 'uploaders'],
+ ['gexpedientes', 'uploaders'],
+ ]) {
+ const access = resolveUserAccess(payload(groups));
+ assert.equal(access.effectiveGroup, 'uploaders');
+ assert.equal(access.isUploader, true);
+ assert.equal(access.sessionId, 'user-123:login-123');
+ }
+});
+
+test('conserva la presentación efectiva de los demás grupos', () => {
+ assert.equal(resolveUserAccess(payload(['admin'])).effectiveGroup, 'admin');
+ assert.equal(resolveUserAccess(payload(['eliminadores'])).effectiveGroup, 'eliminadores');
+ assert.equal(resolveUserAccess(payload(['gexpedientes'])).effectiveGroup, 'gexpedientes');
+ assert.equal(resolveUserAccess(payload([])).effectiveGroup, undefined);
+});
+
+test('limita las ubicaciones uploader a las cuatro colecciones del bucket principal', () => {
+ const primaryBucket = 'frauden-primary';
+ const locations = [
+ { bucket: primaryBucket, id: '1', prefix: 'doctrina/', permissions: ['list', 'write'] },
+ { bucket: primaryBucket, id: '2', prefix: 'medios/', permissions: ['list', 'write'] },
+ { bucket: primaryBucket, id: '3', prefix: 'jurisprudencia/', permissions: ['list', 'write'] },
+ { bucket: primaryBucket, id: '4', prefix: 'legislacion/', permissions: ['list', 'write'] },
+ { bucket: 'frauden-expedientes', id: '5', prefix: 'privado/user-123/', permissions: ['write'] },
+ { bucket: primaryBucket, id: '6', prefix: 'doctrina-privada/', permissions: ['write'] },
+ ];
+
+ assert.deepEqual(
+ filterUploaderLocations(locations, primaryBucket).map(({ id }) => id),
+ ['1', '2', '3', '4']
+ );
+});
+
+test('rechaza una sesión sin identidad estable', () => {
+ assert.throws(
+ () => resolveUserAccess({ 'cognito:groups': ['uploaders'], sub: 'user-123' }),
+ /identificador de inicio de sesión/
+ );
+});