diff --git a/README.md b/README.md index 3bfe9f0..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 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 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 8bbd191..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'] + // 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 032938d..4bd6804 100644 --- a/amplify/backend.ts +++ b/amplify/backend.ts @@ -1,13 +1,227 @@ import { defineBackend } from '@aws-amplify/backend'; +import { Aspects, CfnCondition, Fn, IAspect, Stack } from 'aws-cdk-lib'; +import { + AwsCustomResource, + AwsCustomResourcePolicy, + 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 { 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 */ -defineBackend({ +const backend = defineBackend({ auth, - storage, - secondaryStorage + storage, + secondaryStorage, +}); + +// Do not override cfnBucket.bucketName here. S3 bucket names are globally unique, +// and Amplify's generated physical names avoid collisions during deployments. + +class GeneratedNodeRuntime implements IAspect { + visit(node: IConstruct) { + if ( + node instanceof CfnFunction && + (node.runtime === 'nodejs18.x' || node.node.path.includes('AmplifyBranchLinker')) + ) { + node.runtime = 'nodejs24.x'; + } + } +} + +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 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 +// 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' + ), +}); + +expedientesKmsKey.grantEncryptDecrypt(gexpedientesRole); + +const linkGExpedientesGroupRoleCall = { + service: 'cognito-identity-provider', + action: 'UpdateGroup', + parameters: { + GroupName: gexpedientesGroupName, + UserPoolId: backend.auth.resources.userPool.userPoolId, + RoleArn: gexpedientesRole.roleArn, + Precedence: 3, + }, + physicalResourceId: PhysicalResourceId.of('gexpedientes-group-role-link'), +}; + +new AwsCustomResource(expedientesAccessStack, 'LinkGExpedientesGroupRole', { + onCreate: linkGExpedientesGroupRoleCall, + onUpdate: linkGExpedientesGroupRoleCall, + 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: [gexpedientesRole.roleArn], + }), + ]), }); + +new Policy(expedientesAccessStack, 'GExpedientesPrivateFolderAccess', { + roles: [gexpedientesRole], + 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}/`], + }, + }, + }), + ], +}); + +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; + +cfnUserPool.adminCreateUserConfig = { + ...cfnUserPool.adminCreateUserConfig, + allowAdminCreateUserOnly: true, +}; diff --git a/amplify/storage/resource.ts b/amplify/storage/resource.ts index 62d6340..485d89c 100644 --- a/amplify/storage/resource.ts +++ b/amplify/storage/resource.ts @@ -1,39 +1,42 @@ import { defineStorage } from '@aws-amplify/backend'; export const storage = defineStorage({ - name: 'myStorageBucket', + name: 'frauden', 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(['uploaders']).to(['list', 'write']), + allow.groups(['admin']).to(['read', 'write']), + allow.groups(['eliminadores']).to(['delete']), ], - 'admin/*': [ - allow.groups(['admin']).to(['read', 'write', 'delete']), - allow.authenticated.to(['read']) + 'medios/*': [ + allow.authenticated.to(['read']), + allow.groups(['uploaders']).to(['list', 'write']), + allow.groups(['admin']).to(['read', 'write']), + allow.groups(['eliminadores']).to(['delete']), ], - 'private/{entity_id}/*': [ - allow.entity('identity').to(['read', 'write', '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']), + ], + }), }); export const secondaryStorage = defineStorage({ - name: 'mySecondaryStorageBucket', - access: (allow) => ({ - 'backup_public/*': [ - allow.guest.to(['read', 'write']), - allow.authenticated.to(['read', 'write', 'delete']), - ], - 'backup_admin/*': [ - allow.groups(['admin']).to(['read', 'write', 'delete']), - allow.authenticated.to(['read']) + name: 'frauden-expedientes', + access: (allow) => ({ + 'privado/{entity_id}/*': [ + allow.entity('identity').to(['read', 'write']), + allow.groups(['eliminadores']).to(['delete']), ], - 'backup_private/{entity_id}/*': [ - allow.entity('identity').to(['read', 'write', 'delete']) - ] - }) + }), }); - - - 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/package.json b/package.json index 962c5f9..811c757 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 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 5bbce99..4698a71 100644 --- a/src/App.css +++ b/src/App.css @@ -1,11 +1,447 @@ +: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 { - margin: 0 auto; + width: 100%; + 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; } -.header { +.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; + margin: 0 auto; + padding: 1.5rem; +} + +.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: 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)), + 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; + width: 100%; +} + +.hero-card .eyebrow { + color: var(--frauden-silver); +} + +.hero-card h1 { + width: 100%; + max-width: none; + 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; +} + +.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); + 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; +} + +.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; +} + +.storage-table-icon { + width: 1.25rem; + height: 1.25rem; + flex: 0 0 auto; + fill: currentColor; +} + +.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; + } + + .storage-navigation { + align-items: flex-start; + } + + .storage-navigation__breadcrumbs { + padding-top: 0.35rem; + } +} + +.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..09a6dcf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,32 +1,801 @@ 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 { useCallback, useEffect, useMemo, useState, type ComponentProps } from 'react'; import config from '../amplify_outputs.json'; import { Amplify } from 'aws-amplify'; -import { Authenticator, Button } from '@aws-amplify/ui-react'; +import { fetchAuthSession, fetchUserAttributes } from 'aws-amplify/auth'; +import { I18n } from 'aws-amplify/utils'; +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, + StoragePagination, + StoragePresentationProvider, +} from './storageBrowserComponents'; +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); +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 { StorageBrowser } = createStorageBrowser({ - config: createAmplifyAuthAdapter(), + 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, useView } = createStorageBrowser({ + config: trackedAuthAdapter, + actions: { + default: { + download: trackedDownloadHandler, + listLocationItems: (input) => trackApplicationOperation(() => listAllLocationItems(input)), + }, + }, + components: { + ...componentsDefault, + ActionDestination: StorageActionDestination, + ActionsList: StorageActionsList, + DataTable: StorageDataTable, + Navigation: StorageNavigation, + Pagination: StoragePagination, + }, }); -function App() { +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 ( + + ); +} + +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 = { + message: string; + type: 'error' | 'info' | 'success'; +} | 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 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() { + return ( +
+ Frauden +
+ ); + }, +}; + +function KnowledgeSyncButton() { + const [isSyncing, setIsSyncing] = useState(false); + const [syncStatus, setSyncStatus] = useState(null); + const { beginOperation } = useSessionActivity(); + + 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...' }); + const finishOperation = beginOperation(); + + 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 syncRequest = fetch(syncKnowledgeEndpoint, { + method: 'POST', + headers: { + Authorization: `Bearer ${idToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ action: 'syncKnowledgeBase' }), + }); + + void syncRequest + .catch((error) => { + console.warn('No se pudo leer la respuesta de sincronización.', error); + }) + .finally(finishOperation); + + setSyncStatus({ + type: 'success', + message: syncKnowledgeRequestSentMessage, + }); + + window.setTimeout(() => setIsSyncing(false), syncKnowledgeButtonCooldownMs); + } catch (error) { + finishOperation(); + setSyncStatus({ + type: 'error', + message: + error instanceof Error + ? error.message + : 'No se pudo iniciar la sincronización de conocimiento.', + }); + setIsSyncing(false); + } + }; + return ( - - {({ signOut, user }) => ( - <> -
-

{`Hello ${user?.username}`}

- +
+
+ Base de conocimiento +
+
+ + {syncStatus ? ( +
+ {syncStatus.message}
- - - )} - + ) : null} +
+
+ ); +} + +const storageBrowserDisplayTextBase: 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) => getPermissionDisplayName(permissions), + 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', + getTitle: () => '', + 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', + }), + }, +}; + +const createStorageBrowserDisplayText = ( + userLabel: string, + isUploader: boolean +): StorageBrowserDisplayText => ({ + ...storageBrowserDisplayTextBase, + LocationsView: { + ...storageBrowserDisplayTextBase.LocationsView, + getPermissionName: (permissions) => getPermissionDisplayName(permissions, isUploader), + }, + 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 = { + onAuthenticated: () => void; + onSessionExpired: () => void; + signOut?: () => void; + username?: string; +}; + +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, access.isUploader), + [access.isUploader, userLabel] + ); + const storageBrowserViews = useMemo( + () => ({ + CopyView: TrackedCopyView, + CreateFolderView: TrackedCreateFolderView, + DeleteView: TrackedDeleteView, + LocationDetailView: AllItemsLocationDetailView, + UploadView: TrackedUploadView, + }), + [] + ); + const handleSignOut = () => { + clearSharedState(); + signOut?.(); + }; + + return ( +
+
+ + Frauden + +
+
+ Sesión activa + {username ?? USER_FALLBACK_LABEL} +
+ +
+
+ +
+
+ Panel seguro +

Gestor documental de Frauden

+

+ carga archivos y organiza de forma segura y sencilla el archivo documental de Frauden. +

+
+
+ +
+ + + + +
+
+ ); +} + +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}

+ +
+ ); + } + + return ( + + + + ); +} + +function App() { + const [sessionExpired, setSessionExpired] = useState(false); + const clearSessionExpiredMessage = useCallback(() => setSessionExpired(false), []); + + return ( + <> + {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/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 + 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/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 new file mode 100644 index 0000000..ac13adc --- /dev/null +++ b/src/storageBrowserComponents.tsx @@ -0,0 +1,453 @@ +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, + formatLocationEntryLabel, + formatNavigationRootLabel, + getNavigationCallbacks, + USER_FALLBACK_LABEL, + type BucketLabelMap, +} from './storageBrowserPresentation'; +import { filterStorageActionsForUploader } from './storageBrowserActions'; + +type PresentationContextValue = { + bucketLabels: BucketLabelMap; + isUploader: boolean; + userLabel: string; +}; + +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, +}: 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>; + +function UpIcon() { + return ( + + ); +} + +function HomeIcon() { + return ( + + ); +} + +export function StorageNavigation({ items }: NavigationProps) { + const { bucketLabels, userLabel } = React.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 } = React.useContext(StoragePresentationContext); + + if (!DefaultActionDestination) { + return null; + } + + const displayItems = items.map((item, index) => + index === 0 + ? { + ...item, + name: formatActionDestinationRootLabel(item.name ?? '', bucketLabels, userLabel), + } + : item + ); + + return ; +} + +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 ( + + ); + } + + 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 ( + + ); + } + + 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 ?? ''; + 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, isLoading, rows }: DataTableProps) { + const { bucketLabels, isUploader, userLabel } = React.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 (!isLocationsTable) { + 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); + 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..4a1d1ea --- /dev/null +++ b/src/storageBrowserPresentation.ts @@ -0,0 +1,253 @@ +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 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); + + 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/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 new file mode 100644 index 0000000..ebb76e1 --- /dev/null +++ b/tests/storageBrowserComponents.test.tsx @@ -0,0 +1,196 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +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'; +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, /CarpetaPermisosAna PérezBucket { + const html = renderToStaticMarkup( + + + + ); + + assert.match(html, /
{ + 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 new file mode 100644 index 0000000..b973d2a --- /dev/null +++ b/tests/storageBrowserPresentation.test.ts @@ -0,0 +1,259 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBucketLabelMap, + createListAllLocationItemsHandler, + formatActionDestinationRootLabel, + formatLocationEntryLabel, + formatLocationTitle, + formatNavigationRootLabel, + formatPrivatePath, + getBucketDisplayName, + getNavigationCallbacks, + getPermissionDisplayName, + 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('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`, + 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'); +}); 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/ + ); +});