From 6e0aa9296e1c436b3fd3265ec74b4d8bed2a2aa1 Mon Sep 17 00:00:00 2001 From: "Carlos H.F.S" Date: Tue, 8 Sep 2026 16:05:45 -0300 Subject: [PATCH 1/2] Projeto Carlos Henrique Teste Humanni. --- .brakeman.ignore | 28 + .dockerignore | 53 + .env.example | 41 + .gitattributes | 9 + .gitignore | 57 + .prettierignore | 8 + .prettierrc.json | 6 + .rubocop.yml | 8 + .ruby-version | 1 + Dockerfile | 90 + Gemfile | 79 + Gemfile.lock | 570 ++ Procfile.dev | 4 + Rakefile | 6 + app/assets/images/.keep | 0 app/channels/application_cable/channel.rb | 6 + app/channels/application_cable/connection.rb | 16 + app/channels/dashboard_channel/channel.rb | 11 + .../import_progress_channel/channel.rb | 12 + app/controllers/admin/base_controller.rb | 7 + app/controllers/admin/dashboard_controller.rb | 14 + .../admin/user_imports_controller.rb | 73 + app/controllers/admin/users_controller.rb | 131 + app/controllers/application_controller.rb | 18 + app/controllers/concerns/.keep | 0 app/controllers/concerns/authentication.rb | 56 + app/controllers/concerns/authorization.rb | 20 + app/controllers/passwords_controller.rb | 46 + app/controllers/profiles_controller.rb | 63 + app/controllers/registrations_controller.rb | 44 + app/controllers/root_controller.rb | 7 + app/controllers/sessions_controller.rb | 28 + app/forms/admin/user_form.rb | 39 + app/forms/users/form.rb | 110 + app/forms/users/password_reset_form.rb | 40 + app/forms/users/profile_form.rb | 30 + app/forms/users/registration_form.rb | 33 + app/frontend/App.tsx | 36 + app/frontend/assets/inertia.svg | 1 + app/frontend/assets/rails.svg | 9 + app/frontend/assets/react.svg | 1 + app/frontend/assets/vite_ruby.svg | 1 + app/frontend/atoms/Alert/Alert.styles.ts | 22 + app/frontend/atoms/Alert/Alert.test.tsx | 26 + app/frontend/atoms/Alert/Alert.tsx | 25 + app/frontend/atoms/Alert/Alert.types.ts | 8 + app/frontend/atoms/Avatar/Avatar.styles.ts | 45 + app/frontend/atoms/Avatar/Avatar.test.tsx | 37 + app/frontend/atoms/Avatar/Avatar.tsx | 21 + app/frontend/atoms/Avatar/Avatar.types.ts | 9 + app/frontend/atoms/Badge/Badge.styles.ts | 25 + app/frontend/atoms/Badge/Badge.test.tsx | 33 + app/frontend/atoms/Badge/Badge.tsx | 7 + app/frontend/atoms/Badge/Badge.types.ts | 8 + app/frontend/atoms/Button/Button.styles.ts | 41 + app/frontend/atoms/Button/Button.test.tsx | 47 + app/frontend/atoms/Button/Button.tsx | 8 + app/frontend/atoms/Button/Button.types.ts | 12 + app/frontend/atoms/Button/ButtonLink.tsx | 18 + .../atoms/EmptyState/EmptyState.styles.ts | 16 + .../atoms/EmptyState/EmptyState.test.tsx | 20 + app/frontend/atoms/EmptyState/EmptyState.tsx | 35 + app/frontend/atoms/Heading/Heading.styles.ts | 18 + app/frontend/atoms/Heading/Heading.test.tsx | 16 + app/frontend/atoms/Heading/Heading.tsx | 8 + app/frontend/atoms/Heading/Heading.types.ts | 8 + app/frontend/atoms/Icon/Icon.styles.ts | 12 + app/frontend/atoms/Icon/Icon.test.tsx | 19 + app/frontend/atoms/Icon/Icon.tsx | 96 + app/frontend/atoms/Icon/Icon.types.ts | 21 + app/frontend/atoms/Label/Label.styles.ts | 3 + app/frontend/atoms/Label/Label.test.tsx | 13 + app/frontend/atoms/Label/Label.tsx | 6 + app/frontend/atoms/Label/Label.types.ts | 5 + .../atoms/ProgressBar/ProgressBar.styles.ts | 17 + .../atoms/ProgressBar/ProgressBar.test.tsx | 24 + .../atoms/ProgressBar/ProgressBar.tsx | 19 + .../atoms/ProgressBar/ProgressBar.types.ts | 14 + app/frontend/atoms/Select/Select.styles.ts | 19 + app/frontend/atoms/Select/Select.test.tsx | 42 + app/frontend/atoms/Select/Select.tsx | 12 + app/frontend/atoms/Select/Select.types.ts | 7 + app/frontend/atoms/Spinner/Spinner.styles.ts | 18 + app/frontend/atoms/Spinner/Spinner.test.tsx | 17 + app/frontend/atoms/Spinner/Spinner.tsx | 12 + app/frontend/atoms/Spinner/Spinner.types.ts | 9 + .../atoms/TextInput/TextInput.styles.ts | 24 + .../atoms/TextInput/TextInput.test.tsx | 45 + app/frontend/atoms/TextInput/TextInput.tsx | 13 + .../atoms/TextInput/TextInput.types.ts | 8 + .../atoms/TextLink/TextLink.styles.ts | 5 + app/frontend/atoms/TextLink/TextLink.tsx | 14 + app/frontend/entrypoints/application.css | 1 + app/frontend/entrypoints/application.ts | 28 + app/frontend/entrypoints/inertia.tsx | 48 + app/frontend/hooks/useStore.ts | 2 + app/frontend/lib/cable.ts | 35 + app/frontend/lib/styled/cn.ts | 8 + app/frontend/lib/styled/index.ts | 6 + app/frontend/lib/styled/styled.tsx | 126 + app/frontend/lib/styled/variants.ts | 33 + .../molecules/ActionsRow/ActionsRow.styles.ts | 3 + .../molecules/ActionsRow/ActionsRow.tsx | 12 + app/frontend/molecules/Card/Card.styles.ts | 5 + app/frontend/molecules/Card/Card.tsx | 12 + .../molecules/DetailRow/DetailRow.styles.ts | 9 + .../molecules/DetailRow/DetailRow.test.tsx | 12 + .../molecules/DetailRow/DetailRow.tsx | 18 + app/frontend/molecules/Field/Field.styles.ts | 7 + app/frontend/molecules/Field/Field.test.tsx | 36 + app/frontend/molecules/Field/Field.tsx | 19 + app/frontend/molecules/Field/Field.types.ts | 9 + .../molecules/FieldGroup/FieldGroup.styles.ts | 3 + .../molecules/FieldGroup/FieldGroup.tsx | 12 + app/frontend/molecules/Form/Form.styles.ts | 5 + app/frontend/molecules/Form/Form.test.tsx | 22 + app/frontend/molecules/Form/Form.tsx | 13 + app/frontend/molecules/Form/FormActions.tsx | 8 + .../ImportProgressBar.styles.ts | 5 + .../ImportProgressBar/ImportProgressBar.tsx | 92 + .../ImportStatusBadge.test.tsx | 16 + .../ImportStatusBadge/ImportStatusBadge.tsx | 30 + .../molecules/PageHeader/PageHeader.styles.ts | 5 + .../molecules/PageHeader/PageHeader.tsx | 26 + .../ProfileHeader/ProfileHeader.styles.ts | 5 + .../molecules/ProfileHeader/ProfileHeader.tsx | 21 + .../molecules/RoleBadge/RoleBadge.test.tsx | 16 + .../molecules/RoleBadge/RoleBadge.tsx | 12 + .../molecules/SearchBar/SearchBar.styles.ts | 3 + .../molecules/SearchBar/SearchBar.test.tsx | 36 + .../molecules/SearchBar/SearchBar.tsx | 45 + .../ConfirmDialog/ConfirmDialog.styles.ts | 11 + .../ConfirmDialog/ConfirmDialog.test.tsx | 40 + .../organisms/ConfirmDialog/ConfirmDialog.tsx | 117 + .../DashboardStats/DashboardStats.styles.ts | 3 + .../DashboardStats/DashboardStats.tsx | 37 + .../FlashMessages/FlashMessages.styles.ts | 3 + .../FlashMessages/FlashMessages.test.tsx | 41 + .../organisms/FlashMessages/FlashMessages.tsx | 20 + .../FormErrorAlert/FormErrorAlert.test.tsx | 22 + .../FormErrorAlert/FormErrorAlert.tsx | 17 + .../ImportsTable/ImportsTable.styles.ts | 21 + .../organisms/ImportsTable/ImportsTable.tsx | 66 + .../organisms/Navbar/Navbar.styles.ts | 49 + app/frontend/organisms/Navbar/Navbar.tsx | 112 + .../organisms/Pagination/Pagination.styles.ts | 7 + .../organisms/Pagination/Pagination.tsx | 36 + .../organisms/StatCard/StatCard.styles.ts | 7 + .../organisms/StatCard/StatCard.test.tsx | 12 + app/frontend/organisms/StatCard/StatCard.tsx | 16 + app/frontend/organisms/UserForm/UserForm.tsx | 152 + .../organisms/UserTable/UserTable.styles.ts | 27 + .../organisms/UserTable/UserTable.tsx | 109 + app/frontend/pages/Admin/Dashboard.tsx | 35 + .../pages/Admin/UserImports/Index.tsx | 76 + app/frontend/pages/Admin/UserImports/New.tsx | 68 + app/frontend/pages/Admin/UserImports/Show.tsx | 43 + app/frontend/pages/Admin/Users/Edit.tsx | 30 + app/frontend/pages/Admin/Users/Index.tsx | 62 + app/frontend/pages/Admin/Users/New.tsx | 28 + app/frontend/pages/Auth/ForgotPassword.tsx | 54 + app/frontend/pages/Auth/Login.tsx | 74 + app/frontend/pages/Auth/Register.tsx | 130 + app/frontend/pages/Auth/ResetPassword.tsx | 74 + app/frontend/pages/Profile/Edit.tsx | 142 + app/frontend/pages/Profile/Show.tsx | 63 + app/frontend/stores/dashboard-store.ts | 25 + app/frontend/stores/flash-store.ts | 28 + app/frontend/stores/imports-store.ts | 40 + app/frontend/stores/index.ts | 12 + app/frontend/stores/root.ts | 21 + app/frontend/stores/session-store.ts | 38 + app/frontend/stores/store-context.tsx | 16 + app/frontend/styles/base.css | 59 + .../AdminLayout/AdminLayout.styles.ts | 7 + .../templates/AdminLayout/AdminLayout.tsx | 32 + .../AuthenticatedLayout.styles.ts | 7 + .../AuthenticatedLayout.tsx | 41 + .../GuestLayout/GuestLayout.styles.ts | 15 + .../templates/GuestLayout/GuestLayout.tsx | 30 + app/frontend/test/setup.ts | 1 + app/frontend/types/actioncable.d.ts | 21 + app/frontend/types/globals.d.ts | 9 + app/frontend/types/index.ts | 75 + app/frontend/types/vite-env.d.ts | 1 + app/helpers/application_helper.rb | 2 + app/jobs/application_job.rb | 7 + app/jobs/user_imports/job.rb | 14 + app/mailers/application_mailer.rb | 6 + app/mailers/passwords_mailer.rb | 6 + app/models/application_record.rb | 3 + app/models/concerns/.keep | 0 app/models/current.rb | 4 + app/models/session.rb | 3 + app/models/user.rb | 20 + app/models/user_import.rb | 34 + app/parsers/user_imports/parser.rb | 28 + app/parsers/user_imports/parser/roo.rb | 116 + app/policies/users/policy.rb | 49 + app/presenters/user_import_presenter.rb | 52 + app/presenters/user_presenter.rb | 32 + app/queries/dashboard/totals_query.rb | 26 + app/queries/user_imports/listing_query.rb | 47 + app/queries/users/listing_query.rb | 54 + app/services/dashboard/publisher.rb | 19 + app/services/user_imports/processor.rb | 122 + .../user_imports/progress_broadcaster.rb | 25 + app/services/users/account_closer.rb | 16 + app/services/users/role_toggler.rb | 33 + app/value_objects/user_imports/listing.rb | 15 + app/value_objects/user_imports/result.rb | 16 + app/value_objects/user_imports/row.rb | 16 + app/value_objects/user_imports/row_error.rb | 17 + app/value_objects/users/listing.rb | 15 + app/value_objects/users/overview.rb | 22 + app/views/layouts/application.html.erb | 41 + app/views/layouts/mailer.html.erb | 13 + app/views/layouts/mailer.text.erb | 1 + app/views/passwords_mailer/reset.html.erb | 4 + app/views/passwords_mailer/reset.text.erb | 2 + app/views/pwa/manifest.json.erb | 22 + app/views/pwa/service-worker.js | 26 + bin/brakeman | 7 + bin/dev | 26 + bin/docker-entrypoint | 14 + bin/jobs | 6 + bin/kamal | 16 + bin/rails | 4 + bin/rake | 4 + bin/rubocop | 8 + bin/setup | 35 + bin/thrust | 5 + bin/vite | 16 + config.ru | 6 + config/application.rb | 27 + config/boot.rb | 4 + config/brakeman.yml | 1 + config/cable.yml | 17 + config/cache.yml | 16 + config/credentials.yml.enc | 1 + config/database.yml | 41 + config/deploy.yml | 118 + config/environment.rb | 5 + config/environments/development.rb | 72 + config/environments/production.rb | 102 + config/environments/test.rb | 53 + .../initializers/content_security_policy.rb | 34 + .../initializers/filter_parameter_logging.rb | 8 + config/initializers/inertia_rails.rb | 9 + config/initializers/inflections.rb | 16 + config/initializers/sqlite_wal.rb | 28 + config/locales/en.yml | 18 + config/puma.rb | 41 + config/queue.yml | 18 + config/recurring.yml | 15 + config/routes.rb | 32 + config/storage.yml | 34 + config/vite.json | 17 + coverage/.history.json | 235 + coverage/.last_run.json | 5 + coverage/.report_stamp | 0 coverage/.resultset.json | 1 + coverage/.resultset.json.lock | 0 coverage/coverage.json | 4514 ++++++++++++++ coverage/index.html | 93 + db/cable_schema.rb | 11 + db/cache_schema.rb | 12 + db/migrate/20260906180314_create_users.rb | 14 + db/migrate/20260906180324_create_sessions.rb | 11 + ...te_active_storage_tables.active_storage.rb | 57 + .../20260906180327_create_user_imports.rb | 16 + ...3000_make_user_imports_user_id_optional.rb | 5 + db/queue_schema.rb | 160 + db/schema.rb | 80 + db/seeds.rb | 107 + eslint.config.js | 65 + eslint/no-any.mjs | 20 + eslint/no-literal-class-name.mjs | 26 + lib/tasks/.keep | 0 log/.keep | 0 package-lock.json | 5220 +++++++++++++++++ package.json | 52 + public/400.html | 114 + public/404.html | 114 + public/406-unsupported-browser.html | 114 + public/422.html | 114 + public/500.html | 114 + public/icon.png | Bin 0 -> 4166 bytes public/icon.svg | 3 + public/robots.txt | 1 + script/.keep | 0 storage/.keep | 0 test/application_system_test_case.rb | 5 + .../application_cable/connection_test.rb | 25 + test/channels/dashboard_channel_test.rb | 21 + test/channels/import_progress_channel_test.rb | 35 + test/contracts/presenter_contract_test.rb | 64 + test/controllers/.keep | 0 test/fixtures/files/.keep | 0 test/fixtures/files/avatar.png | Bin 0 -> 70 bytes test/fixtures/files/users.csv | 3 + test/fixtures/user_imports.yml | 19 + test/fixtures/users.yml | 15 + test/forms/admin/user_form_test.rb | 108 + test/forms/users/password_reset_form_test.rb | 49 + test/forms/users/profile_form_test.rb | 86 + test/forms/users/registration_form_test.rb | 112 + test/helpers/.keep | 0 test/integration/.keep | 0 .../admin_user_imports_flow_test.rb | 68 + test/integration/admin_users_flow_test.rb | 207 + test/integration/authentication_flow_test.rb | 122 + test/integration/inertia_share_test.rb | 38 + test/integration/passwords_flow_test.rb | 76 + test/integration/profile_flow_test.rb | 96 + test/integration/sessions_flow_test.rb | 59 + test/mailers/.keep | 0 test/models/.keep | 0 test/models/user_import_test.rb | 46 + test/models/user_presenter_test.rb | 33 + test/models/user_test.rb | 36 + test/parsers/user_imports/parser_test.rb | 79 + test/policies/users/policy_test.rb | 39 + test/queries/dashboard/totals_query_test.rb | 29 + test/queries/users/listing_query_test.rb | 60 + test/services/dashboard/publisher_test.rb | 17 + test/services/user_imports/processor_test.rb | 128 + test/services/users/account_closer_test.rb | 34 + test/system/.keep | 0 test/test_helper.rb | 64 + tmp/.keep | 0 tmp/pids/.keep | 0 tmp/storage/.keep | 0 tsconfig.app.json | 33 + tsconfig.json | 11 + tsconfig.node.json | 13 + vendor/.keep | 0 vite.config.ts | 15 + vitest.config.ts | 18 + 339 files changed, 20848 insertions(+) create mode 100644 .brakeman.ignore create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 .rubocop.yml create mode 100644 .ruby-version create mode 100644 Dockerfile create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 Procfile.dev create mode 100644 Rakefile create mode 100644 app/assets/images/.keep create mode 100644 app/channels/application_cable/channel.rb create mode 100644 app/channels/application_cable/connection.rb create mode 100644 app/channels/dashboard_channel/channel.rb create mode 100644 app/channels/import_progress_channel/channel.rb create mode 100644 app/controllers/admin/base_controller.rb create mode 100644 app/controllers/admin/dashboard_controller.rb create mode 100644 app/controllers/admin/user_imports_controller.rb create mode 100644 app/controllers/admin/users_controller.rb create mode 100644 app/controllers/application_controller.rb create mode 100644 app/controllers/concerns/.keep create mode 100644 app/controllers/concerns/authentication.rb create mode 100644 app/controllers/concerns/authorization.rb create mode 100644 app/controllers/passwords_controller.rb create mode 100644 app/controllers/profiles_controller.rb create mode 100644 app/controllers/registrations_controller.rb create mode 100644 app/controllers/root_controller.rb create mode 100644 app/controllers/sessions_controller.rb create mode 100644 app/forms/admin/user_form.rb create mode 100644 app/forms/users/form.rb create mode 100644 app/forms/users/password_reset_form.rb create mode 100644 app/forms/users/profile_form.rb create mode 100644 app/forms/users/registration_form.rb create mode 100644 app/frontend/App.tsx create mode 100644 app/frontend/assets/inertia.svg create mode 100644 app/frontend/assets/rails.svg create mode 100644 app/frontend/assets/react.svg create mode 100644 app/frontend/assets/vite_ruby.svg create mode 100644 app/frontend/atoms/Alert/Alert.styles.ts create mode 100644 app/frontend/atoms/Alert/Alert.test.tsx create mode 100644 app/frontend/atoms/Alert/Alert.tsx create mode 100644 app/frontend/atoms/Alert/Alert.types.ts create mode 100644 app/frontend/atoms/Avatar/Avatar.styles.ts create mode 100644 app/frontend/atoms/Avatar/Avatar.test.tsx create mode 100644 app/frontend/atoms/Avatar/Avatar.tsx create mode 100644 app/frontend/atoms/Avatar/Avatar.types.ts create mode 100644 app/frontend/atoms/Badge/Badge.styles.ts create mode 100644 app/frontend/atoms/Badge/Badge.test.tsx create mode 100644 app/frontend/atoms/Badge/Badge.tsx create mode 100644 app/frontend/atoms/Badge/Badge.types.ts create mode 100644 app/frontend/atoms/Button/Button.styles.ts create mode 100644 app/frontend/atoms/Button/Button.test.tsx create mode 100644 app/frontend/atoms/Button/Button.tsx create mode 100644 app/frontend/atoms/Button/Button.types.ts create mode 100644 app/frontend/atoms/Button/ButtonLink.tsx create mode 100644 app/frontend/atoms/EmptyState/EmptyState.styles.ts create mode 100644 app/frontend/atoms/EmptyState/EmptyState.test.tsx create mode 100644 app/frontend/atoms/EmptyState/EmptyState.tsx create mode 100644 app/frontend/atoms/Heading/Heading.styles.ts create mode 100644 app/frontend/atoms/Heading/Heading.test.tsx create mode 100644 app/frontend/atoms/Heading/Heading.tsx create mode 100644 app/frontend/atoms/Heading/Heading.types.ts create mode 100644 app/frontend/atoms/Icon/Icon.styles.ts create mode 100644 app/frontend/atoms/Icon/Icon.test.tsx create mode 100644 app/frontend/atoms/Icon/Icon.tsx create mode 100644 app/frontend/atoms/Icon/Icon.types.ts create mode 100644 app/frontend/atoms/Label/Label.styles.ts create mode 100644 app/frontend/atoms/Label/Label.test.tsx create mode 100644 app/frontend/atoms/Label/Label.tsx create mode 100644 app/frontend/atoms/Label/Label.types.ts create mode 100644 app/frontend/atoms/ProgressBar/ProgressBar.styles.ts create mode 100644 app/frontend/atoms/ProgressBar/ProgressBar.test.tsx create mode 100644 app/frontend/atoms/ProgressBar/ProgressBar.tsx create mode 100644 app/frontend/atoms/ProgressBar/ProgressBar.types.ts create mode 100644 app/frontend/atoms/Select/Select.styles.ts create mode 100644 app/frontend/atoms/Select/Select.test.tsx create mode 100644 app/frontend/atoms/Select/Select.tsx create mode 100644 app/frontend/atoms/Select/Select.types.ts create mode 100644 app/frontend/atoms/Spinner/Spinner.styles.ts create mode 100644 app/frontend/atoms/Spinner/Spinner.test.tsx create mode 100644 app/frontend/atoms/Spinner/Spinner.tsx create mode 100644 app/frontend/atoms/Spinner/Spinner.types.ts create mode 100644 app/frontend/atoms/TextInput/TextInput.styles.ts create mode 100644 app/frontend/atoms/TextInput/TextInput.test.tsx create mode 100644 app/frontend/atoms/TextInput/TextInput.tsx create mode 100644 app/frontend/atoms/TextInput/TextInput.types.ts create mode 100644 app/frontend/atoms/TextLink/TextLink.styles.ts create mode 100644 app/frontend/atoms/TextLink/TextLink.tsx create mode 100644 app/frontend/entrypoints/application.css create mode 100644 app/frontend/entrypoints/application.ts create mode 100644 app/frontend/entrypoints/inertia.tsx create mode 100644 app/frontend/hooks/useStore.ts create mode 100644 app/frontend/lib/cable.ts create mode 100644 app/frontend/lib/styled/cn.ts create mode 100644 app/frontend/lib/styled/index.ts create mode 100644 app/frontend/lib/styled/styled.tsx create mode 100644 app/frontend/lib/styled/variants.ts create mode 100644 app/frontend/molecules/ActionsRow/ActionsRow.styles.ts create mode 100644 app/frontend/molecules/ActionsRow/ActionsRow.tsx create mode 100644 app/frontend/molecules/Card/Card.styles.ts create mode 100644 app/frontend/molecules/Card/Card.tsx create mode 100644 app/frontend/molecules/DetailRow/DetailRow.styles.ts create mode 100644 app/frontend/molecules/DetailRow/DetailRow.test.tsx create mode 100644 app/frontend/molecules/DetailRow/DetailRow.tsx create mode 100644 app/frontend/molecules/Field/Field.styles.ts create mode 100644 app/frontend/molecules/Field/Field.test.tsx create mode 100644 app/frontend/molecules/Field/Field.tsx create mode 100644 app/frontend/molecules/Field/Field.types.ts create mode 100644 app/frontend/molecules/FieldGroup/FieldGroup.styles.ts create mode 100644 app/frontend/molecules/FieldGroup/FieldGroup.tsx create mode 100644 app/frontend/molecules/Form/Form.styles.ts create mode 100644 app/frontend/molecules/Form/Form.test.tsx create mode 100644 app/frontend/molecules/Form/Form.tsx create mode 100644 app/frontend/molecules/Form/FormActions.tsx create mode 100644 app/frontend/molecules/ImportProgressBar/ImportProgressBar.styles.ts create mode 100644 app/frontend/molecules/ImportProgressBar/ImportProgressBar.tsx create mode 100644 app/frontend/molecules/ImportStatusBadge/ImportStatusBadge.test.tsx create mode 100644 app/frontend/molecules/ImportStatusBadge/ImportStatusBadge.tsx create mode 100644 app/frontend/molecules/PageHeader/PageHeader.styles.ts create mode 100644 app/frontend/molecules/PageHeader/PageHeader.tsx create mode 100644 app/frontend/molecules/ProfileHeader/ProfileHeader.styles.ts create mode 100644 app/frontend/molecules/ProfileHeader/ProfileHeader.tsx create mode 100644 app/frontend/molecules/RoleBadge/RoleBadge.test.tsx create mode 100644 app/frontend/molecules/RoleBadge/RoleBadge.tsx create mode 100644 app/frontend/molecules/SearchBar/SearchBar.styles.ts create mode 100644 app/frontend/molecules/SearchBar/SearchBar.test.tsx create mode 100644 app/frontend/molecules/SearchBar/SearchBar.tsx create mode 100644 app/frontend/organisms/ConfirmDialog/ConfirmDialog.styles.ts create mode 100644 app/frontend/organisms/ConfirmDialog/ConfirmDialog.test.tsx create mode 100644 app/frontend/organisms/ConfirmDialog/ConfirmDialog.tsx create mode 100644 app/frontend/organisms/DashboardStats/DashboardStats.styles.ts create mode 100644 app/frontend/organisms/DashboardStats/DashboardStats.tsx create mode 100644 app/frontend/organisms/FlashMessages/FlashMessages.styles.ts create mode 100644 app/frontend/organisms/FlashMessages/FlashMessages.test.tsx create mode 100644 app/frontend/organisms/FlashMessages/FlashMessages.tsx create mode 100644 app/frontend/organisms/FormErrorAlert/FormErrorAlert.test.tsx create mode 100644 app/frontend/organisms/FormErrorAlert/FormErrorAlert.tsx create mode 100644 app/frontend/organisms/ImportsTable/ImportsTable.styles.ts create mode 100644 app/frontend/organisms/ImportsTable/ImportsTable.tsx create mode 100644 app/frontend/organisms/Navbar/Navbar.styles.ts create mode 100644 app/frontend/organisms/Navbar/Navbar.tsx create mode 100644 app/frontend/organisms/Pagination/Pagination.styles.ts create mode 100644 app/frontend/organisms/Pagination/Pagination.tsx create mode 100644 app/frontend/organisms/StatCard/StatCard.styles.ts create mode 100644 app/frontend/organisms/StatCard/StatCard.test.tsx create mode 100644 app/frontend/organisms/StatCard/StatCard.tsx create mode 100644 app/frontend/organisms/UserForm/UserForm.tsx create mode 100644 app/frontend/organisms/UserTable/UserTable.styles.ts create mode 100644 app/frontend/organisms/UserTable/UserTable.tsx create mode 100644 app/frontend/pages/Admin/Dashboard.tsx create mode 100644 app/frontend/pages/Admin/UserImports/Index.tsx create mode 100644 app/frontend/pages/Admin/UserImports/New.tsx create mode 100644 app/frontend/pages/Admin/UserImports/Show.tsx create mode 100644 app/frontend/pages/Admin/Users/Edit.tsx create mode 100644 app/frontend/pages/Admin/Users/Index.tsx create mode 100644 app/frontend/pages/Admin/Users/New.tsx create mode 100644 app/frontend/pages/Auth/ForgotPassword.tsx create mode 100644 app/frontend/pages/Auth/Login.tsx create mode 100644 app/frontend/pages/Auth/Register.tsx create mode 100644 app/frontend/pages/Auth/ResetPassword.tsx create mode 100644 app/frontend/pages/Profile/Edit.tsx create mode 100644 app/frontend/pages/Profile/Show.tsx create mode 100644 app/frontend/stores/dashboard-store.ts create mode 100644 app/frontend/stores/flash-store.ts create mode 100644 app/frontend/stores/imports-store.ts create mode 100644 app/frontend/stores/index.ts create mode 100644 app/frontend/stores/root.ts create mode 100644 app/frontend/stores/session-store.ts create mode 100644 app/frontend/stores/store-context.tsx create mode 100644 app/frontend/styles/base.css create mode 100644 app/frontend/templates/AdminLayout/AdminLayout.styles.ts create mode 100644 app/frontend/templates/AdminLayout/AdminLayout.tsx create mode 100644 app/frontend/templates/AuthenticatedLayout/AuthenticatedLayout.styles.ts create mode 100644 app/frontend/templates/AuthenticatedLayout/AuthenticatedLayout.tsx create mode 100644 app/frontend/templates/GuestLayout/GuestLayout.styles.ts create mode 100644 app/frontend/templates/GuestLayout/GuestLayout.tsx create mode 100644 app/frontend/test/setup.ts create mode 100644 app/frontend/types/actioncable.d.ts create mode 100644 app/frontend/types/globals.d.ts create mode 100644 app/frontend/types/index.ts create mode 100644 app/frontend/types/vite-env.d.ts create mode 100644 app/helpers/application_helper.rb create mode 100644 app/jobs/application_job.rb create mode 100644 app/jobs/user_imports/job.rb create mode 100644 app/mailers/application_mailer.rb create mode 100644 app/mailers/passwords_mailer.rb create mode 100644 app/models/application_record.rb create mode 100644 app/models/concerns/.keep create mode 100644 app/models/current.rb create mode 100644 app/models/session.rb create mode 100644 app/models/user.rb create mode 100644 app/models/user_import.rb create mode 100644 app/parsers/user_imports/parser.rb create mode 100644 app/parsers/user_imports/parser/roo.rb create mode 100644 app/policies/users/policy.rb create mode 100644 app/presenters/user_import_presenter.rb create mode 100644 app/presenters/user_presenter.rb create mode 100644 app/queries/dashboard/totals_query.rb create mode 100644 app/queries/user_imports/listing_query.rb create mode 100644 app/queries/users/listing_query.rb create mode 100644 app/services/dashboard/publisher.rb create mode 100644 app/services/user_imports/processor.rb create mode 100644 app/services/user_imports/progress_broadcaster.rb create mode 100644 app/services/users/account_closer.rb create mode 100644 app/services/users/role_toggler.rb create mode 100644 app/value_objects/user_imports/listing.rb create mode 100644 app/value_objects/user_imports/result.rb create mode 100644 app/value_objects/user_imports/row.rb create mode 100644 app/value_objects/user_imports/row_error.rb create mode 100644 app/value_objects/users/listing.rb create mode 100644 app/value_objects/users/overview.rb create mode 100644 app/views/layouts/application.html.erb create mode 100644 app/views/layouts/mailer.html.erb create mode 100644 app/views/layouts/mailer.text.erb create mode 100644 app/views/passwords_mailer/reset.html.erb create mode 100644 app/views/passwords_mailer/reset.text.erb create mode 100644 app/views/pwa/manifest.json.erb create mode 100644 app/views/pwa/service-worker.js create mode 100755 bin/brakeman create mode 100755 bin/dev create mode 100755 bin/docker-entrypoint create mode 100755 bin/jobs create mode 100755 bin/kamal create mode 100755 bin/rails create mode 100755 bin/rake create mode 100755 bin/rubocop create mode 100755 bin/setup create mode 100755 bin/thrust create mode 100755 bin/vite create mode 100644 config.ru create mode 100644 config/application.rb create mode 100644 config/boot.rb create mode 100644 config/brakeman.yml create mode 100644 config/cable.yml create mode 100644 config/cache.yml create mode 100644 config/credentials.yml.enc create mode 100644 config/database.yml create mode 100644 config/deploy.yml create mode 100644 config/environment.rb create mode 100644 config/environments/development.rb create mode 100644 config/environments/production.rb create mode 100644 config/environments/test.rb create mode 100644 config/initializers/content_security_policy.rb create mode 100644 config/initializers/filter_parameter_logging.rb create mode 100644 config/initializers/inertia_rails.rb create mode 100644 config/initializers/inflections.rb create mode 100644 config/initializers/sqlite_wal.rb create mode 100644 config/locales/en.yml create mode 100644 config/puma.rb create mode 100644 config/queue.yml create mode 100644 config/recurring.yml create mode 100644 config/routes.rb create mode 100644 config/storage.yml create mode 100644 config/vite.json create mode 100644 coverage/.history.json create mode 100644 coverage/.last_run.json create mode 100644 coverage/.report_stamp create mode 100644 coverage/.resultset.json create mode 100644 coverage/.resultset.json.lock create mode 100644 coverage/coverage.json create mode 100644 coverage/index.html create mode 100644 db/cable_schema.rb create mode 100644 db/cache_schema.rb create mode 100644 db/migrate/20260906180314_create_users.rb create mode 100644 db/migrate/20260906180324_create_sessions.rb create mode 100644 db/migrate/20260906180325_create_active_storage_tables.active_storage.rb create mode 100644 db/migrate/20260906180327_create_user_imports.rb create mode 100644 db/migrate/20260906203000_make_user_imports_user_id_optional.rb create mode 100644 db/queue_schema.rb create mode 100644 db/schema.rb create mode 100644 db/seeds.rb create mode 100644 eslint.config.js create mode 100644 eslint/no-any.mjs create mode 100644 eslint/no-literal-class-name.mjs create mode 100644 lib/tasks/.keep create mode 100644 log/.keep create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/400.html create mode 100644 public/404.html create mode 100644 public/406-unsupported-browser.html create mode 100644 public/422.html create mode 100644 public/500.html create mode 100644 public/icon.png create mode 100644 public/icon.svg create mode 100644 public/robots.txt create mode 100644 script/.keep create mode 100644 storage/.keep create mode 100644 test/application_system_test_case.rb create mode 100644 test/channels/application_cable/connection_test.rb create mode 100644 test/channels/dashboard_channel_test.rb create mode 100644 test/channels/import_progress_channel_test.rb create mode 100644 test/contracts/presenter_contract_test.rb create mode 100644 test/controllers/.keep create mode 100644 test/fixtures/files/.keep create mode 100644 test/fixtures/files/avatar.png create mode 100644 test/fixtures/files/users.csv create mode 100644 test/fixtures/user_imports.yml create mode 100644 test/fixtures/users.yml create mode 100644 test/forms/admin/user_form_test.rb create mode 100644 test/forms/users/password_reset_form_test.rb create mode 100644 test/forms/users/profile_form_test.rb create mode 100644 test/forms/users/registration_form_test.rb create mode 100644 test/helpers/.keep create mode 100644 test/integration/.keep create mode 100644 test/integration/admin_user_imports_flow_test.rb create mode 100644 test/integration/admin_users_flow_test.rb create mode 100644 test/integration/authentication_flow_test.rb create mode 100644 test/integration/inertia_share_test.rb create mode 100644 test/integration/passwords_flow_test.rb create mode 100644 test/integration/profile_flow_test.rb create mode 100644 test/integration/sessions_flow_test.rb create mode 100644 test/mailers/.keep create mode 100644 test/models/.keep create mode 100644 test/models/user_import_test.rb create mode 100644 test/models/user_presenter_test.rb create mode 100644 test/models/user_test.rb create mode 100644 test/parsers/user_imports/parser_test.rb create mode 100644 test/policies/users/policy_test.rb create mode 100644 test/queries/dashboard/totals_query_test.rb create mode 100644 test/queries/users/listing_query_test.rb create mode 100644 test/services/dashboard/publisher_test.rb create mode 100644 test/services/user_imports/processor_test.rb create mode 100644 test/services/users/account_closer_test.rb create mode 100644 test/system/.keep create mode 100644 test/test_helper.rb create mode 100644 tmp/.keep create mode 100644 tmp/pids/.keep create mode 100644 tmp/storage/.keep create mode 100644 tsconfig.app.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vendor/.keep create mode 100644 vite.config.ts create mode 100644 vitest.config.ts diff --git a/.brakeman.ignore b/.brakeman.ignore new file mode 100644 index 000000000..01d0a6372 --- /dev/null +++ b/.brakeman.ignore @@ -0,0 +1,28 @@ +{ + "ignored_warnings": [ + { + "warning_type": "Mass Assignment", + "warning_code": 105, + "fingerprint": "413a4f9962b57fb22b657af4609707dbd5a70c5a2a4e3aa212f3c678c84f45e8", + "check_name": "PermitAttributes", + "message": "Potentially dangerous key allowed for mass assignment", + "file": "app/controllers/admin/users_controller.rb", + "line": 103, + "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", + "code": "params.permit(:full_name, :email_address, :avatar, :avatar_url, :role, :password, :password_confirmation)", + "render_path": null, + "location": { + "type": "method", + "class": "Admin::UsersController", + "method": "user_params" + }, + "user_input": ":role", + "confidence": "Medium", + "cwe_id": [ + 915 + ], + "note": "role is enum-whitelisted in Admin::UserForm and only reachable behind Admin::BaseController#require_admin (Users::Policy); password is handled by has_secure_password. No privilege escalation is possible." + } + ], + "obsolete": [] +} \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..00392512f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,53 @@ +# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files. + +# Ignore git directory. +/.git/ +/.gitignore + +# Ignore bundler config. +/.bundle + +# Ignore all environment files. +/.env* + +# Ignore all default key files. +/config/master.key +/config/credentials/*.key + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/.keep + +# Ignore assets. +/node_modules/ +/app/assets/builds/* +!/app/assets/builds/.keep +/public/assets +# Vite build output is produced inside the Docker build (node stage), never copied from the host. +/public/vite* + +# Ignore CI service files. +/.github + +# Ignore Kamal files. +/config/deploy*.yml +/.kamal + +# Ignore development files +/.devcontainer + +# Ignore Docker-related files +/.dockerignore +/Dockerfile* diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..9eab09829 --- /dev/null +++ b/.env.example @@ -0,0 +1,41 @@ +# ------------------------------------------------------------------------------ +# Environment template. Copy to .env and fill in the actual values. +# cp .env.example .env +# Never commit the .env file itself — it is gitignored. +# +# In development/test the rails app loads this file via dotenv-rails. +# In production the same variables are provided by the deploy orchestrator +# (Kamal env/secrets) — there is no .env file inside the container. +# +# Deployment variables (DOCKER_IMAGE, APP_SERVER_HOST, REGISTRY_USERNAME) are +# read by config/deploy.yml (ERB) when running `bin/kamal`. Source this file +# before invoking kamal: source .env && bin/kamal deploy +# ------------------------------------------------------------------------------ + +# Host used to build absolute URLs (mailers, reset links, host authorization). +APP_HOST=localhost +APP_PROTOCOL=http +# APP_PORT=3000 + +# Mailer sender used by ApplicationMailer. +MAIL_FROM=no-reply@localhost + +# SMTP transport (optional). Password reset emails are only delivered when +# SMTP_HOST is set; otherwise delivery is disabled. +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_AUTH=plain +SMTP_STARTTLS_AUTO=true + +# Seeds (development/test only — seeds are skipped in production unless +# SEED_ALLOW_IN_PRODUCTION=true and real credentials are provided). +SEED_ADMIN_EMAIL=admin@iam.local +SEED_ADMIN_PASSWORD=Password123! +SEED_ALLOW_IN_PRODUCTION=false + +# Kamal deployment (used when running bin/kamal from the deploy host). +DOCKER_IMAGE=your-user/mini_rails8_iam_with_inertia_js +APP_SERVER_HOST=192.168.0.1 +REGISTRY_USERNAME=your-user \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..8dc432343 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# See https://git-scm.com/docs/gitattributes for more about git attribute files. + +# Mark the database schema as having been generated. +db/schema.rb linguist-generated + +# Mark any vendored files as having been vendored. +vendor/* linguist-vendored +config/credentials/*.yml.enc diff=rails_credentials +config/credentials.yml.enc diff=rails_credentials diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..c5f0c89d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,57 @@ +# See https://help.github.com/articles/ignoring-files for more about ignoring files. +# +# Temporary files generated by your text editor or operating system +# belong in git's global ignore instead: +# `$XDG_CONFIG_HOME/git/ignore` or `~/.config/git/ignore` + +# Ignore bundler config. +/.bundle + +# Ignore all environment files. +/.env* +!.env.example + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/ +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/ +!/tmp/storage/.keep + +/public/assets + +# Ignore master key for decrypting credentials and more. +/config/master.key + +# Vite Ruby +/public/vite* +node_modules +# Vite uses dotenv and suggests to ignore local-only env files. See +# https://vitejs.dev/guide/env-and-mode.html#env-files +*.local + +# Ignore Kamal files. +/.kamal + +# SimpleCov coverage reports. +/.resultset.json + +# Local planning docs — kept out of version control. +/plan/ +/plan2/ +/IAMLikeRubyApp.md +/models-plan-001.md +/tech_levantamento.md +/cmd-sequence.txt + diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..247a7c12b --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +*.md +*.yml +*.yaml +*.erb +Gemfile +Gemfile.lock +Rakefile +public/** \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 000000000..0e68044eb --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "printWidth": 100, + "semi": false, + "singleQuote": true, + "trailingComma": "all" +} diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 000000000..f9d86d4a5 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,8 @@ +# Omakase Ruby styling for Rails +inherit_gem: { rubocop-rails-omakase: rubocop.yml } + +# Overwrite or add rules to create your own house style +# +# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` +# Layout/SpaceInsideArrayLiteralBrackets: +# Enabled: false diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 000000000..1cf76f52b --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +ruby-4.0.6 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..f1fb08e64 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,90 @@ +# syntax=docker/dockerfile:1 +# check=error=true + +# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand: +# docker build -t mini_rails8_iam_with_inertia_js . +# docker run -d -p 80:80 -e RAILS_MASTER_KEY= --name mini_rails8_iam_with_inertia_js mini_rails8_iam_with_inertia_js + +# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html + +# Make sure RUBY_VERSION matches the Ruby version in .ruby-version +ARG RUBY_VERSION=4.0.6 +FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base + +# Rails app lives here +WORKDIR /rails + +# Install base packages +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Set production environment +ENV RAILS_ENV="production" \ + BUNDLE_DEPLOYMENT="1" \ + BUNDLE_PATH="/usr/local/bundle" \ + BUNDLE_WITHOUT="development" + +# Throw-away stage that compiles the Vite/React frontend with just Node. +# Segregated from the Ruby build so gem, bundler and Node caches never +# invalidate each other, and the final image keeps no Node tooling. +FROM node:22-slim AS build-assets + +# Rails app lives here (mirrors the base stage layout) +WORKDIR /rails + +# Locked npm packages first: the largest, most stable layer to cache +COPY package.json package-lock.json ./ +RUN npm ci + +# Application source (vite.config.ts, config/vite.json, app/frontend) +COPY . . + +# Compile entrypoints to public/vite (see config/vite.json and package.json) +RUN npm run build + +# Throw-away build stage to reduce size of final image +FROM base AS build + +# Install packages needed to build gems +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Install application gems +COPY vendor/* ./vendor/ +COPY Gemfile Gemfile.lock ./ +RUN bundle install && \ + rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ + bundle exec bootsnap precompile --gemfile + +# Copy application code (the built frontend stays in build-assets) +COPY . . + +# Bring in the frontend compiled by the Node stage +COPY --from=build-assets /rails/public/vite /rails/public/vite + +RUN SECRET_KEY_BASE_DUMMY=1 VITE_RUBY_SKIP_ASSETS_PRECOMPILE_EXTENSION=true APP_HOST=example.com MAIL_FROM=deploy@example.com ./bin/rails assets:precompile + +# Precompile bootsnap code for faster boot times +RUN bundle exec bootsnap precompile app/ lib/ + +# Final stage for app image +FROM base + +# Copy built artifacts: gems, application +COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" +COPY --from=build /rails /rails + +# Run and own only the runtime files as a non-root user for security +RUN groupadd --system --gid 1000 rails && \ + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ + chown -R rails:rails db log storage tmp +USER 1000:1000 + +# Entrypoint prepares the database. +ENTRYPOINT ["/rails/bin/docker-entrypoint"] + +# Start server via Thruster by default, this can be overwritten at runtime +EXPOSE 80 +CMD ["./bin/thrust", "./bin/rails", "server"] diff --git a/Gemfile b/Gemfile new file mode 100644 index 000000000..9f71a383c --- /dev/null +++ b/Gemfile @@ -0,0 +1,79 @@ +source "https://rubygems.org" + +# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" +gem "rails", "~> 8.0.5", ">= 8.0.5.1" +gem "propshaft" +# Use sqlite3 as the database for Active Record +gem "sqlite3", ">= 2.1" +# Use the Puma web server [https://github.com/puma/puma] +gem "puma", ">= 5.0" +# Build JSON APIs with ease [https://github.com/rails/jbuilder] +gem "jbuilder" + +# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] +gem "bcrypt", "~> 3.1.7" + +# Windows does not include zoneinfo files, so bundle the tzinfo-data gem +gem "tzinfo-data", platforms: %i[ windows jruby ] + +# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable +gem "solid_cache" +gem "solid_queue" +gem "solid_cable" + +# Reduces boot times through caching; required in config/boot.rb +gem "bootsnap", require: false + +# Pin RDoc to Ruby's default gem. irb depends on rdoc, and if Bundler installs a +# newer copy alongside the default one, both get loaded and Ruby warns about +# redefined constants ("already initialized constant RDoc::..."). Pinned to the +# exact default version so Bundler uses it directly and only one copy exists. +gem "rdoc", "7.0.4", require: false + +# Deploy this application anywhere as a Docker container [https://kamal-deploy.org] +gem "kamal", require: false + +# Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/] +gem "thruster", require: false + +# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] +# gem "image_processing", "~> 1.2" + +group :development, :test do + # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem + gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" + + # Load environment variables from .env / .env.local (see .env.example). + gem "dotenv-rails" + + # Static analysis for security vulnerabilities [https://brakemanscanner.org/] + gem "brakeman", require: false + + # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] + gem "rubocop-rails-omakase", require: false +end + +group :development do + # Use console on exceptions pages [https://github.com/rails/web-console] + gem "web-console" +end + +group :test do + # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] + gem "capybara" + gem "selenium-webdriver" +end + +gem "inertia_rails", "~> 3.22" + +gem "vite_rails", "~> 3.11" + +# Spreadsheet parsing (Phase 5: user imports) +gem "roo", "~> 3.0" + +# Pagination (Phase 3: query objects) +gem "pagy", "~> 9.0" + +gem "roo-xls", "~> 2.0" + +gem "simplecov", "~> 1.2", group: :test diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 000000000..01fad1cfb --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,570 @@ +GEM + remote: https://rubygems.org/ + specs: + actioncable (8.0.5.1) + actionpack (= 8.0.5.1) + activesupport (= 8.0.5.1) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (8.0.5.1) + actionpack (= 8.0.5.1) + activejob (= 8.0.5.1) + activerecord (= 8.0.5.1) + activestorage (= 8.0.5.1) + activesupport (= 8.0.5.1) + mail (>= 2.8.0) + actionmailer (8.0.5.1) + actionpack (= 8.0.5.1) + actionview (= 8.0.5.1) + activejob (= 8.0.5.1) + activesupport (= 8.0.5.1) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.0.5.1) + actionview (= 8.0.5.1) + activesupport (= 8.0.5.1) + nokogiri (>= 1.8.5) + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (8.0.5.1) + actionpack (= 8.0.5.1) + activerecord (= 8.0.5.1) + activestorage (= 8.0.5.1) + activesupport (= 8.0.5.1) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (8.0.5.1) + activesupport (= 8.0.5.1) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (8.0.5.1) + activesupport (= 8.0.5.1) + globalid (>= 0.3.6) + activemodel (8.0.5.1) + activesupport (= 8.0.5.1) + activerecord (8.0.5.1) + activemodel (= 8.0.5.1) + activesupport (= 8.0.5.1) + timeout (>= 0.4.0) + activestorage (8.0.5.1) + actionpack (= 8.0.5.1) + activejob (= 8.0.5.1) + activerecord (= 8.0.5.1) + activesupport (= 8.0.5.1) + marcel (~> 1.0) + activesupport (8.0.5.1) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + ast (2.4.3) + base64 (0.3.0) + bcrypt (3.1.22) + bcrypt_pbkdf (1.1.2) + benchmark (0.5.0) + bigdecimal (4.1.2) + bindex (0.8.1) + bootsnap (1.26.0) + msgpack (~> 1.5) + brakeman (8.0.6) + racc + builder (3.3.0) + capybara (3.40.0) + addressable + matrix + mini_mime (>= 0.1.3) + nokogiri (~> 1.11) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + regexp_parser (>= 1.5, < 3.0) + xpath (~> 3.2) + concurrent-ruby (1.3.8) + connection_pool (3.0.2) + crass (1.0.7) + csv (3.3.6) + date (3.5.1) + debug (1.11.1) + irb (~> 1.10) + reline (>= 0.3.8) + dotenv (3.2.0) + dotenv-rails (3.2.0) + dotenv (= 3.2.0) + railties (>= 6.1) + drb (2.2.3) + dry-cli (1.4.1) + ed25519 (1.4.0) + erb (6.0.7) + erubi (1.13.1) + et-orbi (1.4.2) + tzinfo + fugit (1.13.0) + et-orbi (~> 1.4) + raabro (~> 1.4) + globalid (1.4.0) + activesupport (>= 6.1) + i18n (1.15.2) + concurrent-ruby (~> 1.0) + inertia_rails (3.22.0) + railties (>= 6) + io-console (0.9.2) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + jbuilder (2.15.1) + actionview (>= 7.0.0) + activesupport (>= 7.0.0) + json (2.21.2) + kamal (2.12.0) + activesupport (>= 7.0) + base64 (~> 0.2) + bcrypt_pbkdf (~> 1.0) + concurrent-ruby (~> 1.2) + dotenv (~> 3.1) + ed25519 (~> 1.4) + net-ssh (~> 7.3) + sshkit (>= 1.23.0, < 2.0) + thor (~> 1.3) + zeitwerk (>= 2.6.18, < 3.0) + language_server-protocol (3.17.0.6) + lint_roller (1.1.0) + logger (1.7.0) + loofah (2.25.2) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.9.1) + logger + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.2.1) + matrix (0.4.3) + mini_mime (1.1.5) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) + msgpack (1.8.4) + mutex_m (0.3.0) + net-imap (0.6.6) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.3.0) + timeout + net-scp (4.1.0) + net-ssh (>= 2.6.5, < 8.0.0) + net-sftp (4.0.0) + net-ssh (>= 5.0.0, < 8.0.0) + net-smtp (0.5.1) + net-protocol + net-ssh (7.3.3) + nio4r (2.7.5) + nokogiri (1.19.4-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.19.4-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-arm-linux-musl) + racc (~> 1.4) + nokogiri (1.19.4-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-x86_64-linux-musl) + racc (~> 1.4) + ostruct (0.6.3) + pagy (9.4.0) + parallel (2.2.0) + parser (3.3.12.0) + ast (~> 2.4.1) + racc + pp (0.6.4) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + propshaft (1.3.2) + actionpack (>= 7.0.0) + activesupport (>= 7.0.0) + rack + psych (5.5.0) + date + stringio + public_suffix (7.0.5) + puma (8.0.2) + nio4r (~> 2.0) + raabro (1.5.0) + racc (1.8.1) + rack (3.2.7) + rack-proxy (2.0.0) + rack (>= 2.0, < 4) + rack-session (2.1.2) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.3.1) + rack (>= 3) + rails (8.0.5.1) + actioncable (= 8.0.5.1) + actionmailbox (= 8.0.5.1) + actionmailer (= 8.0.5.1) + actionpack (= 8.0.5.1) + actiontext (= 8.0.5.1) + actionview (= 8.0.5.1) + activejob (= 8.0.5.1) + activemodel (= 8.0.5.1) + activerecord (= 8.0.5.1) + activestorage (= 8.0.5.1) + activesupport (= 8.0.5.1) + bundler (>= 1.15.0) + railties (= 8.0.5.1) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.7.1) + loofah (~> 2.25, >= 2.25.2) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + railties (8.0.5.1) + actionpack (= 8.0.5.1) + activesupport (= 8.0.5.1) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) + zeitwerk (~> 2.6) + rainbow (3.1.1) + rake (13.4.2) + rdoc (7.0.4) + erb + psych (>= 4.0.0) + tsort + regexp_parser (2.12.0) + reline (0.7.0) + io-console (~> 0.5) + rexml (3.4.4) + roo (3.0.0) + base64 (~> 0.2) + csv (~> 3) + logger (~> 1) + nokogiri (~> 1) + rubyzip (>= 3.0.0, < 4.0.0) + roo-xls (2.0.0) + csv + nokogiri + roo (>= 2.0.0, < 4) + spreadsheet (>= 1.3.4, < 2) + rubocop (1.90.0) + json (>= 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (>= 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.50.0) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-performance (1.27.0) + lint_roller (~> 1.1) + rubocop (>= 1.89.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.37.0) + activesupport (>= 4.2.0) + lint_roller (~> 1.1) + rack (>= 1.1) + rubocop (>= 1.89.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rails-omakase (1.1.0) + rubocop (>= 1.72) + rubocop-performance (>= 1.24) + rubocop-rails (>= 2.30) + ruby-ole (1.2.13.1) + ruby-progressbar (1.13.0) + rubyzip (3.6.0) + securerandom (0.4.1) + selenium-webdriver (4.48.0) + base64 (~> 0.2) + logger (~> 1.4) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 4.0) + websocket (~> 1.0) + simplecov (1.2.0) + solid_cable (4.0.2) + actioncable (>= 7.2) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_cache (1.0.10) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_queue (1.7.0) + activejob (>= 7.1) + activerecord (>= 7.1) + concurrent-ruby (>= 1.3.1) + fugit (~> 1.11) + railties (>= 7.1) + thor (>= 1.3.1) + spreadsheet (1.3.5) + bigdecimal + logger + ruby-ole + sqlite3 (2.9.6-aarch64-linux-gnu) + sqlite3 (2.9.6-aarch64-linux-musl) + sqlite3 (2.9.6-arm-linux-gnu) + sqlite3 (2.9.6-arm-linux-musl) + sqlite3 (2.9.6-x86_64-linux-gnu) + sqlite3 (2.9.6-x86_64-linux-musl) + sshkit (1.25.1) + base64 + logger + net-scp (>= 1.1.2) + net-sftp (>= 2.1.2) + net-ssh (>= 2.8.0) + ostruct + stringio (3.2.0) + thor (1.5.0) + thruster (0.1.26) + thruster (0.1.26-aarch64-linux) + thruster (0.1.26-x86_64-linux) + timeout (0.6.1) + tsort (0.2.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) + useragent (0.16.11) + vite_rails (3.11.1) + railties (>= 5.1, < 9) + vite_ruby (~> 3.0, >= 3.2.2) + vite_ruby (3.10.5) + dry-cli (>= 0.7, < 2) + logger (~> 1.6) + mutex_m + rack-proxy (>= 0.6.1) + zeitwerk (~> 2.2) + web-console (4.3.0) + actionview (>= 8.0.0) + bindex (>= 0.4.0) + railties (>= 8.0.0) + websocket (1.2.11) + websocket-driver (0.8.2) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + xpath (3.2.0) + nokogiri (~> 1.8) + zeitwerk (2.8.3) + +PLATFORMS + aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + bcrypt (~> 3.1.7) + bootsnap + brakeman + capybara + debug + dotenv-rails + inertia_rails (~> 3.22) + jbuilder + kamal + pagy (~> 9.0) + propshaft + puma (>= 5.0) + rails (~> 8.0.5, >= 8.0.5.1) + rdoc (= 7.0.4) + roo (~> 3.0) + roo-xls (~> 2.0) + rubocop-rails-omakase + selenium-webdriver + simplecov (~> 1.2) + solid_cable + solid_cache + solid_queue + sqlite3 (>= 2.1) + thruster + tzinfo-data + vite_rails (~> 3.11) + web-console + +CHECKSUMS + actioncable (8.0.5.1) sha256=5adb700c605a7ef7628f87dc7a6da20cd5f0ceac782a59055c864ea51a77d7c7 + actionmailbox (8.0.5.1) sha256=f8b72eadf53b3e285df8f2d1f6533012abf5a0a001180abe436aea3139eeaed6 + actionmailer (8.0.5.1) sha256=c3d2b3f96e1989ea25f51699786a97fcb2536eb7abfc2a667cb8f2376ec08403 + actionpack (8.0.5.1) sha256=a5595c9d824d68884ddc4d3965ab78c897760d3752e190df7efe897371caa1eb + actiontext (8.0.5.1) sha256=370e90d35feb4313fc18ccef658776427d5bdd13126f266933b828a77e2125b2 + actionview (8.0.5.1) sha256=472a108b9cc2295c4ac3ff09b028045e619875801f48c556f0085210b9cb1440 + activejob (8.0.5.1) sha256=142407a21b6c3cbc6ddd92ca111ac18ea5c40298eb94d81845cd897a072a6880 + activemodel (8.0.5.1) sha256=559be32aa9c40db7a3ee0aef926d4508a9ebd22f96f7276c11326d21a7dff4a4 + activerecord (8.0.5.1) sha256=9252968fce404d75eb17092498a440d472167f2f8deee32b4658d6552b1eeea7 + activestorage (8.0.5.1) sha256=239742932b2fdcf0ead175e0889dbd385a36da2168fd7bde023aaad88ef745f2 + activesupport (8.0.5.1) sha256=329a4280c4fbcfcf338ae2cb9df28b0b14527929dba105e10b3604516d998710 + addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bcrypt (3.1.22) sha256=1f0072e88c2d705d94aff7f2c5cb02eb3f1ec4b8368671e19112527489f29032 + bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 + benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c + bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd + bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e + bootsnap (1.26.0) sha256=ca96237015e6cd74a02963d5821cf00ac5ea134653b323e8cd6d702a7718bf1b + brakeman (8.0.6) sha256=759cc69341115e6c2dcd47b6fd8649a0b9bd540e3585ac8a0a94e31c66fee386 + builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f + capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef + concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1 + connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a + crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 + csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456 + date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 + debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 + dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d + dotenv-rails (3.2.0) sha256=657e25554ba622ffc95d8c4f1670286510f47f2edda9f68293c3f661b303beab + drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + dry-cli (1.4.1) sha256=b8015bb76c708aa8705a36faf694973e75eeeffca39b89c8e172dc6f66a7d874 + ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506 + erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 + erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 + et-orbi (1.4.2) sha256=bb555dae668419cb24caa2a293a170e58be6d4df1e017c51f5030bdc133cd20c + fugit (1.13.0) sha256=a4f093fce740da52f216740a5041e2a594ea763cdb89e8b2754ca4399634ab18 + globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427 + i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5 + inertia_rails (3.22.0) sha256=39c20120de472015d2831fa461f8a09672c68e91c41d3d660e0b1d16b787b7b1 + io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08 + irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 + jbuilder (2.15.1) sha256=2430bec28fb0cebacb5875b1009cf9d8bc3c303ccb810c4c8b062a4b51457637 + json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a + kamal (2.12.0) sha256=c51d1ab085e515470f98d0c0f043637122b5ebf76e8b610cb1fbbed0b7f9b8fa + language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 + lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + loofah (2.25.2) sha256=2007f746959ac65552456e04b433e83deb22759ab38c838b4445c70e43425918 + mail (2.9.1) sha256=06574eca475253d6c18145dd70af80d0eb970182d55053497c5f4d797ea160e8 + marcel (1.2.1) sha256=1678e9360e32f9eafa917c80029e2f6d10b2715c66a4b87b6d0da9b9cd1f859f + matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 + msgpack (1.8.4) sha256=4411c22d350dd1c20250f7eada3cca2695438c2f769cf0782f0cd065d90a3e7b + mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751 + net-imap (0.6.6) sha256=96aa4ee50df3060203e649efc341f53480b791d49e150f2fdebf68beb141a8df + net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 + net-protocol (0.3.0) sha256=ba310c3d4f1cad46bb1ab20336b06669b1ff8f7c568d9cb9342b32a718547472 + net-scp (4.1.0) sha256=a99b0b92a1e5d360b0de4ffbf2dc0c91531502d3d4f56c28b0139a7c093d1a5d + net-sftp (4.0.0) sha256=65bb91c859c2f93b09826757af11b69af931a3a9155050f50d1b06d384526364 + net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 + net-ssh (7.3.3) sha256=831def58b2c51dcef66ec00d29397d4f210de89c19fe78f95873ca30f386e86a + nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 + nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f + nokogiri (1.19.4-aarch64-linux-musl) sha256=35c65b9ce72b3bb03207bdbe7067915019dc18c1b9b59139684bd6690fdd01af + nokogiri (1.19.4-arm-linux-gnu) sha256=a301313e38bb065d68239e79734bcd6f56fb6efaacebde29e9abf2a4735340ca + nokogiri (1.19.4-arm-linux-musl) sha256=588923c101bcfa78869734d247d25b598674323e7f22474fc468f6e5647311eb + nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a + nokogiri (1.19.4-x86_64-linux-musl) sha256=17dfb7c1fa194ae02fbf7c51a7afc8d278045ab3fdacfd86f91d02d7b274470b + ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 + pagy (9.4.0) sha256=db3f2e043f684155f18f78be62a81e8d033e39b9f97b1e1a8d12ad38d7bce738 + parallel (2.2.0) sha256=e1059c5fd7b649558a0aec38a769f06a42942bdb40503d005a59c352fe011cd8 + parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828 + pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + propshaft (1.3.2) sha256=1d56a3e56a92c21bfc29caf07406b5386b00d4c47ddf357cf989a5a234b1389e + psych (5.5.0) sha256=4b245bbbd0f3238df8ccee766ebc1a6b062b10e4ee1d3284eb588739ba156a0a + public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 + puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb + raabro (1.5.0) sha256=3f998a7bc84f9c84df3ab580634d2e0a5bda4f0841168d56035f529c9877440a + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rack (3.2.7) sha256=93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7 + rack-proxy (2.0.0) sha256=4f1d435d82afe93bc916d1226df8be307c1b808551f0ecdb56e0b668fd5756e6 + rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 + rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 + rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 + rails (8.0.5.1) sha256=c91cefbf38881876ddbe67b5e0246eb985d27d60c6bb1cd7034b4261de0ba495 + rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d + rails-html-sanitizer (1.7.1) sha256=e797a7c9b01e567307e317c576b49ab4168017e63eea4dba9ce3cb587e2f22c2 + railties (8.0.5.1) sha256=da1958e1d9dab04691a2f8721b3ff7fab323715d37f103c19972dedfd644d5c7 + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + rdoc (7.0.4) sha256=2b1c90011d6c6eab4c2fe5aa769d32285219047b2a97f489dc76242e8dff4f56 + regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb + reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + roo (3.0.0) sha256=6fdd7a9158d657c69768b4168754ff2110cc21fdc01a1bec1010820cb05c91b1 + roo-xls (2.0.0) sha256=72a6ac126e3fd140f3fb105097e5b212deb9419eb7e100abd72e0f7a94048f01 + rubocop (1.90.0) sha256=9eb4c065b5c5154e4ef554c547972f3905a9eb6b53e657e580b6796b54bf8242 + rubocop-ast (1.50.0) sha256=b9ca88300da0803ee222ad20cdb30494c0a784eed06fdc35d254b06d662788db + rubocop-performance (1.27.0) sha256=eeeb1374d062a368ee1c787b70eb0b0cc4b184cb1f8565f424760946146d61ce + rubocop-rails (2.37.0) sha256=6e1645add5060e0328f8ddda0d820f55697c591394398bf14bb9dccb62f14b7e + rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d + ruby-ole (1.2.13.1) sha256=578d10dd2a797a2b35a1286c6fb2c9525f67c24791346fc8015d39f0ffa3cb72 + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + rubyzip (3.6.0) sha256=268994d44d62282d1cfd99bf10eae48d7267199158ad7ea3e1fee2da9458b695 + securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + selenium-webdriver (4.48.0) sha256=0c8376ebc8a0a4879343fe6fe6eccdcea76748611cd25de370b33eded2077a94 + simplecov (1.2.0) sha256=ea6acd05eece5a41990e2a5171c57d15700d329326c7666c85ee8c6a0dd0977e + solid_cable (4.0.2) sha256=084636a67679ad00d23088b33c84047e614bcf41ee559db24b414d83cdc42d03 + solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 + solid_queue (1.7.0) sha256=6566b70b801d1c317c81bba7bcdd5677c019afac584a30374b4164002ca356d3 + spreadsheet (1.3.5) sha256=cd83ea66803d9cae4ac258dfe16cd8c2b85da33eec18a6d7b48fd4a45840ab7d + sqlite3 (2.9.6-aarch64-linux-gnu) sha256=d8b1f7d23efd7abac285775a9566562fc7debfef79d594e3a20354406fb7907c + sqlite3 (2.9.6-aarch64-linux-musl) sha256=3579e1c98cdc7ff5c3722847bb63ed4e1efb7ff675cb5e1e48ef2d4da5fb3bc9 + sqlite3 (2.9.6-arm-linux-gnu) sha256=33541500e3615da02afe54a9cc38b17a6985d3cf9d8b76d6d0a83002f114e7ec + sqlite3 (2.9.6-arm-linux-musl) sha256=c5490af48bb228fefa54314e9541375c3907e70f8109f3881b5ff97e1c93ae33 + sqlite3 (2.9.6-x86_64-linux-gnu) sha256=613188ce02f614126ddbc38c5e217ccffd6306d0dcd9adca9764547aa890a634 + sqlite3 (2.9.6-x86_64-linux-musl) sha256=d493b11818a3573387a1d56e1ee8fa00da23a683a7a1cc063e7a0feeed843abf + sshkit (1.25.1) sha256=be3f10b9d6eb0b44d5eaba3f7cbe41bc6bb894bce4339688ac20124391455b78 + stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 + thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 + thruster (0.1.26) sha256=6e45e807086b29d51404841bd1ad493b67cd95892fd65dc5afcdd32e82e94ce8 + thruster (0.1.26-aarch64-linux) sha256=2171cb34928c0250830008f535c4ab2ee57846cc3f5d3e96c3475f7b3de7a541 + thruster (0.1.26-x86_64-linux) sha256=3117a6ee430663f845a0457699fe9a05232dcc6c396e2cc83504de5a223c60e8 + timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 + useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 + vite_rails (3.11.1) sha256=61fa4a7c9248fc28f22a05e0760810bf79f645b776c721d888a815c5d21dd338 + vite_ruby (3.10.5) sha256=e9ee92be1cb31c0b6360b02182cfe09d3ac2b7fc278db7870e3f32ae64dde49e + web-console (4.3.0) sha256=e13b71301cdfc2093f155b5aa3a622db80b4672d1f2f713119cc7ec7ac6a6da4 + websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737 + websocket-driver (0.8.2) sha256=97c556b019bf3410b4961002ac501621e9322d3f8a7bc02161a09301cc4c4146 + websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 + xpath (3.2.0) sha256=6dfda79d91bb3b949b947ecc5919f042ef2f399b904013eb3ef6d20dd3a4082e + zeitwerk (2.8.3) sha256=2c85125a8467ce069e20123d1e709a08955c9d29c118c25b46b7b7fafdbb92e5 + +BUNDLED WITH + 4.0.16 diff --git a/Procfile.dev b/Procfile.dev new file mode 100644 index 000000000..886d263b9 --- /dev/null +++ b/Procfile.dev @@ -0,0 +1,4 @@ + +vite: bin/vite dev +web: bin/rails s +jobs: bin/jobs diff --git a/Rakefile b/Rakefile new file mode 100644 index 000000000..9a5ea7383 --- /dev/null +++ b/Rakefile @@ -0,0 +1,6 @@ +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require_relative "config/application" + +Rails.application.load_tasks diff --git a/app/assets/images/.keep b/app/assets/images/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb new file mode 100644 index 000000000..9aec23053 --- /dev/null +++ b/app/channels/application_cable/channel.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module ApplicationCable + class Channel < ActionCable::Channel::Base + end +end diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb new file mode 100644 index 000000000..4264c745c --- /dev/null +++ b/app/channels/application_cable/connection.rb @@ -0,0 +1,16 @@ +module ApplicationCable + class Connection < ActionCable::Connection::Base + identified_by :current_user + + def connect + set_current_user || reject_unauthorized_connection + end + + private + def set_current_user + if session = Session.find_by(id: cookies.signed[:session_id]) + self.current_user = session.user + end + end + end +end diff --git a/app/channels/dashboard_channel/channel.rb b/app/channels/dashboard_channel/channel.rb new file mode 100644 index 000000000..cc4269a7c --- /dev/null +++ b/app/channels/dashboard_channel/channel.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module DashboardChannel + class Channel < ApplicationCable::Channel + def subscribed + return reject unless Users::Policy.admin_reader?(current_user) + + stream_from "dashboard_stats" + end + end +end diff --git a/app/channels/import_progress_channel/channel.rb b/app/channels/import_progress_channel/channel.rb new file mode 100644 index 000000000..a4b37b48c --- /dev/null +++ b/app/channels/import_progress_channel/channel.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module ImportProgressChannel + class Channel < ApplicationCable::Channel + def subscribed + import = UserImport.find_by(id: params[:import_id]) + return reject unless import && Users::Policy.admin?(current_user) + + stream_for import + end + end +end diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb new file mode 100644 index 000000000..ad57dde7d --- /dev/null +++ b/app/controllers/admin/base_controller.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Admin + class BaseController < ApplicationController + include Authorization + end +end diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb new file mode 100644 index 000000000..5fa97cb14 --- /dev/null +++ b/app/controllers/admin/dashboard_controller.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Admin + class DashboardController < BaseController + def index + overview = Dashboard::TotalsQuery.call(User.all) + + render inertia: "Admin/Dashboard", props: { + overview: overview.to_inertia, + urls: { users_url: admin_users_path, imports_url: admin_user_imports_path } + } + end + end +end diff --git a/app/controllers/admin/user_imports_controller.rb b/app/controllers/admin/user_imports_controller.rb new file mode 100644 index 000000000..fd984b775 --- /dev/null +++ b/app/controllers/admin/user_imports_controller.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module Admin + # Thin controller (fase 5): create only builds the UserImport + enqueues + # the job; parser/validation/aggregation all live in Parser/Model/Processor. + # Reads go through ListingQuery + UserImportPresenter (DIP). + class UserImportsController < BaseController + def index + listing = UserImports::ListingQuery.call(page: params[:page]) + + render inertia: "Admin/UserImports/Index", props: { + imports: list_items(listing.imports), + pagination: listing.pagination, + urls: admin_urls.merge(new_import_url: new_admin_user_import_path) + } + end + + def new + render inertia: "Admin/UserImports/New", props: { + imports: list_items(recent_imports), + urls: admin_urls.merge(submit_url: admin_user_imports_path) + } + end + + def create + user_import = UserImport.new(user: Current.user, status: :pending) + user_import.file = params[:file] + + if user_import.save + UserImports::Job.perform_later(user_import) + redirect_to admin_user_import_path(user_import), + notice: "#{user_import.file.filename} queued for processing." + else + render inertia: "Admin/UserImports/New", props: { + imports: list_items(recent_imports), + errors: user_import.errors.to_hash, + urls: admin_urls.merge(submit_url: admin_user_imports_path) + } + end + end + + def show + user_import = UserImport.find(params[:id]) + + render inertia: "Admin/UserImports/Show", props: { + import: UserImportPresenter.new(user_import).to_inertia, + urls: admin_urls.merge(imports_url: admin_user_imports_path) + } + end + + private + + def admin_urls + { + dashboard_url: admin_root_path, + users_url: admin_users_path, + imports_url: admin_user_imports_path + } + end + + def recent_imports + UserImport.order(id: :desc).limit(5) + end + + def list_items(imports) + imports.map do |record| + UserImportPresenter.new(record).to_inertia.merge( + show_url: admin_user_import_path(record) + ) + end + end + end +end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb new file mode 100644 index 000000000..2f0382da6 --- /dev/null +++ b/app/controllers/admin/users_controller.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +module Admin + # Thin controller: writes go through Admin::UserForm, reads through + # Users::ListingQuery / UserPresenter, role rules live in Users::Policy. + class UsersController < BaseController + def index + listing = Users::ListingQuery.call( + scope: User.all, + search: params[:search], + page: params[:page] + ) + + render inertia: "Admin/Users/Index", props: { + users: listing.users.map { |user| user_item(user) }, + pagination: listing.pagination, + filters: { search: params[:search].to_s }, + urls: admin_urls.merge(new_url: new_admin_user_path) + } + end + + def new + render inertia: "Admin/Users/New", props: { + urls: admin_urls.merge(submit_url: admin_users_path, cancel_url: admin_users_path) + } + end + + def create + form = Admin::UserForm.new(user_params) + + if form.save + Dashboard::Publisher.call + redirect_to admin_users_path, notice: "User created successfully." + else + render inertia: "Admin/Users/New", props: { + urls: admin_urls.merge(submit_url: admin_users_path, cancel_url: admin_users_path), + form: echoed_attributes, + errors: form.errors.to_hash + } + end + end + + def edit + @user = User.find(params[:id]) + render inertia: "Admin/Users/Edit", props: { + urls: admin_urls.merge(submit_url: admin_user_path(@user), cancel_url: admin_users_path), + user: UserPresenter.new(@user).to_inertia, + form: edit_attributes + } + end + + def update + @user = User.find(params[:id]) + form = Admin::UserForm.new(user_params, user: @user) + + if form.update + Dashboard::Publisher.call + redirect_to admin_users_path, notice: "User updated successfully." + else + render inertia: "Admin/Users/Edit", props: { + urls: admin_urls.merge(submit_url: admin_user_path(@user), cancel_url: admin_users_path), + user: UserPresenter.new(@user).to_inertia, + form: echoed_attributes, + errors: form.errors.to_hash + } + end + end + + def destroy + target = User.find(params[:id]) + + if policy.destructible?(Current.user, target) + Users::AccountCloser.call(target) + Dashboard::Publisher.call + redirect_to admin_users_path, notice: "User deleted successfully." + else + redirect_to admin_users_path, alert: "You cannot delete this user." + end + end + + def toggle_role + target = User.find(params[:id]) + + if policy.demotable?(Current.user, target) && Users::RoleToggler.call(target) + Dashboard::Publisher.call + redirect_to admin_users_path, notice: "Role switched to #{target.role}." + else + redirect_to admin_users_path, alert: "You cannot change this user's role." + end + end + + private + + def policy + Users::Policy.new(Current.user) + end + + def admin_urls + { + dashboard_url: admin_root_path, + users_url: admin_users_path, + imports_url: admin_user_imports_path + } + end + + def user_params + params.permit(:full_name, :email_address, :avatar, :avatar_url, :role, :password, :password_confirmation) + end + + def user_item(user) + UserPresenter.new(user).to_inertia.merge( + edit_url: edit_admin_user_path(user), + toggle_url: toggle_role_admin_user_path(user), + delete_url: admin_user_path(user) + ) + end + + def echoed_attributes + params.permit(:full_name, :email_address, :avatar_url, :role).to_h + end + + def edit_attributes + { + full_name: @user.full_name, + email_address: @user.email_address, + avatar_url: @user.avatar_url, + role: @user.role + } + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb new file mode 100644 index 000000000..c963f3356 --- /dev/null +++ b/app/controllers/application_controller.rb @@ -0,0 +1,18 @@ +class ApplicationController < ActionController::Base + include Authentication + before_action :set_inertia_shared_auth + # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. + allow_browser versions: :modern + + private + + def set_inertia_shared_auth + inertia_share auth: { user: -> { auth_user_json } } + end + + def auth_user_json + return nil unless Current.user + + UserPresenter.new(Current.user).to_inertia + end +end diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb new file mode 100644 index 000000000..2c8528341 --- /dev/null +++ b/app/controllers/concerns/authentication.rb @@ -0,0 +1,56 @@ +module Authentication + extend ActiveSupport::Concern + + included do + before_action :require_authentication + helper_method :authenticated? + end + + class_methods do + def allow_unauthenticated_access(**options) + skip_before_action :require_authentication, **options + end + end + + private + def authenticated? + resume_session + end + + def require_authentication + resume_session || request_authentication + end + + def resume_session + Current.session ||= find_session_by_cookie + end + + def find_session_by_cookie + Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id] + end + + def request_authentication + session[:return_to_after_authenticating] = request.url + redirect_to new_session_path + end + + def after_authentication_url + session.delete(:return_to_after_authenticating) || redirect_target_for(Current.user) + end + + def redirect_target_for(user) + Users::Policy.admin?(user) ? admin_root_path : profile_path + end + + def start_new_session_for(user) + user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| + Current.session = session + cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax } + end + end + + def terminate_session + Current.session.destroy + cookies.delete(:session_id) + end +end diff --git a/app/controllers/concerns/authorization.rb b/app/controllers/concerns/authorization.rb new file mode 100644 index 000000000..112fd5336 --- /dev/null +++ b/app/controllers/concerns/authorization.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Authorization + extend ActiveSupport::Concern + + included do + before_action :require_admin + helper_method :current_administrator? + end + + private + + def current_administrator? + Users::Policy.admin?(Current.user) + end + + def require_admin + redirect_to root_path unless current_administrator? + end +end diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb new file mode 100644 index 000000000..2b42a4b84 --- /dev/null +++ b/app/controllers/passwords_controller.rb @@ -0,0 +1,46 @@ +class PasswordsController < ApplicationController + allow_unauthenticated_access + before_action :set_user_by_token, only: %i[ edit update ] + + def new + render inertia: "Auth/ForgotPassword", props: { + urls: { login_url: new_session_path } + } + end + + def create + if user = User.find_by(email_address: params[:email_address]) + PasswordsMailer.reset(user).deliver_later + end + + redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)." + end + + def edit + render inertia: "Auth/ResetPassword", props: { + token: params[:token], + urls: { login_url: new_session_path } + } + end + + def update + form = Users::PasswordResetForm.new(params.permit(:password, :password_confirmation), user: @user) + + if form.update + redirect_to new_session_path, notice: "Password has been reset." + else + render inertia: "Auth/ResetPassword", props: { + token: params[:token], + errors: form.errors.to_hash, + urls: { login_url: new_session_path } + } + end + end + + private + def set_user_by_token + @user = User.find_by_token_for!(:password_reset, params[:token]) + rescue ActiveSupport::MessageVerifier::InvalidSignature + redirect_to new_password_path, alert: "Password reset link is invalid or has expired." + end +end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb new file mode 100644 index 000000000..f6d150302 --- /dev/null +++ b/app/controllers/profiles_controller.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +class ProfilesController < ApplicationController + # Authenticated by the Authentication concern. Identity always comes from + # Current.user — the singular route never carries an id (anti-IDOR by design). + + def show + render inertia: "Profile/Show", props: { + user: UserPresenter.new(Current.user).to_inertia, + urls: { + profile_url: profile_path, + admin_url: admin_root_path, + edit_url: edit_profile_path, + delete_url: profile_path + } + } + end + + def edit + render inertia: "Profile/Edit", props: { + urls: { profile_url: profile_path, admin_url: admin_root_path, submit_url: profile_path, cancel_url: profile_path }, + form: profile_attributes + } + end + + def update + form = Users::ProfileForm.new(profile_params, user: Current.user) + + if form.update + redirect_to profile_path, notice: "Profile updated successfully." + else + render inertia: "Profile/Edit", props: { + urls: { profile_url: profile_path, admin_url: admin_root_path, submit_url: profile_path, cancel_url: profile_path }, + form: echoed_attributes, + errors: form.errors.to_hash + } + end + end + + def destroy + Users::AccountCloser.call(Current.user) + terminate_session + redirect_to new_session_path, notice: "Your account has been closed. Goodbye!" + end + + private + + def profile_params + params.permit(:full_name, :email_address, :avatar, :avatar_url, :password, :password_confirmation) + end + + def profile_attributes + { + full_name: Current.user.full_name, + email_address: Current.user.email_address, + avatar_url: Current.user.avatar_url + } + end + + def echoed_attributes + params.permit(:full_name, :email_address, :avatar_url).to_h + end +end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb new file mode 100644 index 000000000..28f570ee8 --- /dev/null +++ b/app/controllers/registrations_controller.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +class RegistrationsController < ApplicationController + allow_unauthenticated_access only: %i[new create] + rate_limit to: 10, within: 3.minutes, only: :create, + with: -> { redirect_to new_registration_path, alert: "Try again later." } + + def new + render inertia: "Auth/Register", props: { + urls: { + register_url: registration_path, + login_url: new_session_path + } + } + end + + def create + form = Users::RegistrationForm.new(registration_params) + + if form.save + start_new_session_for(form.user) + redirect_to after_authentication_url, notice: "Signed up successfully." + else + render inertia: "Auth/Register", props: { + urls: { + register_url: registration_path, + login_url: new_session_path + }, + form: echoed_attributes, + errors: form.errors.to_hash + } + end + end + + private + + def registration_params + params.permit(:full_name, :email_address, :avatar, :avatar_url, :password, :password_confirmation) + end + + def echoed_attributes + params.permit(:full_name, :email_address, :avatar_url).to_h + end +end diff --git a/app/controllers/root_controller.rb b/app/controllers/root_controller.rb new file mode 100644 index 000000000..d2ca2ea12 --- /dev/null +++ b/app/controllers/root_controller.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class RootController < ApplicationController + def index + redirect_to after_authentication_url + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 000000000..68843a7e8 --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,28 @@ +class SessionsController < ApplicationController + allow_unauthenticated_access only: %i[ new create ] + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_url, alert: "Try again later." } + + def new + render inertia: "Auth/Login", props: { + urls: { + login_url: session_path, + register_url: new_registration_path, + forgot_password_url: new_password_path + } + } + end + + def create + if user = User.authenticate_by(params.permit(:email_address, :password)) + start_new_session_for user + redirect_to after_authentication_url, notice: "Signed in successfully." + else + redirect_to new_session_path, alert: "Try another email address or password." + end + end + + def destroy + terminate_session + redirect_to new_session_path + end +end diff --git a/app/forms/admin/user_form.rb b/app/forms/admin/user_form.rb new file mode 100644 index 000000000..d7785859c --- /dev/null +++ b/app/forms/admin/user_form.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Admin + # Admin write use case: manages any user, including their role. Inherits the + # common user validations from Users::Form (DRY). A password is required only + # when creating a user; on update it is optional (use-case rule). + class UserForm < Users::Form + attribute :password, :string + attribute :password_confirmation, :string + attribute :role, :string + + validates :role, inclusion: { in: User.roles.keys } + validates :password, presence: true, length: { in: 8..72 }, if: :new_record? + validates :password, length: { in: 8..72 }, if: :password_chosen? + validates :password_confirmation, presence: true, if: :password_chosen? + validate :password_confirmation_match, if: :password_chosen? + + private + + def user_attributes + attributes = super.merge(role: role) + return attributes unless password.present? + + attributes.merge(password: password, password_confirmation: password_confirmation) + end + + def new_record? + user.new_record? + end + + def password_chosen? + password.present? + end + + def password_confirmation_match + errors.add(:password_confirmation, :confirmation) unless password == password_confirmation + end + end +end diff --git a/app/forms/users/form.rb b/app/forms/users/form.rb new file mode 100644 index 000000000..3673394c8 --- /dev/null +++ b/app/forms/users/form.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +module Users + # Base form object: single source of user write validations (DRY). + # Subclasses add use-case rules: RegistrationForm (role :user + password), + # Admin::UserForm (role + optional password) and ProfileForm (own profile). + class Form + include ActiveModel::Model + include ActiveModel::Attributes + + attr_reader :user + attr_accessor :avatar + + attribute :full_name, :string + attribute :email_address, :string + attribute :avatar_url, :string + # Import idempotency (Fase 5): when enabled, an already-imported email is + # a silent no-op instead of a duplicate error. The policy lives here, in + # the Form — never as a loose `if` in the Processor. + attribute :ignore_duplicate_emails, :boolean, default: false + # Import use case (Fase 5): spreadsheets may lack avatar columns, so the + # avatar rule is relaxed there (placeholder avatar in the presenter). + # Every interactive write keeps the avatar requirement. + attribute :require_avatar, :boolean, default: true + + validates :full_name, presence: true, length: { maximum: 100 } + validate :avatar_source + validate :avatar_url_format, if: -> { avatar_url.present? } + + def initialize(attributes = {}, user: nil, role: nil) + @user = user || build_user(role) + super(attributes) + end + + def save + persist + end + + def update + persist + end + + private + + def persist + return true if skip_existing_email? + return false unless valid? + + apply_attributes + return true if user.save + + errors.merge!(user.errors) + user.avatar.purge if avatar.present? + false + end + + # Re-processing the same spreadsheet must not fail: an existing email is + # counted as processed (a no-op) instead of raising "already taken". + def skip_existing_email? + return false unless ignore_duplicate_emails + + existing = User.find_by(email_address: email_address) + return false unless existing + + @user = existing + true + end + + def apply_attributes + user.assign_attributes(user_attributes) + apply_avatar + end + + def user_attributes + { + full_name: full_name, + email_address: email_address + } + end + + def build_user(role) + User.new.tap { |record| record.role = role if role } + end + + def apply_avatar + if avatar.present? + user.avatar.attach(avatar) + user.avatar_url = nil + elsif avatar_url.present? + user.avatar.purge if user.avatar.attached? + user.avatar_url = avatar_url + end + end + + def avatar_source + return unless require_avatar + return if avatar.present? + return if avatar_url.present? + return if user.persisted? && user.avatar_source.present? + + errors.add(:base, :avatar_required) + end + + def avatar_url_format + return if avatar_url.match?(%r{\Ahttps?://}) && avatar_url.length <= 2048 + + errors.add(:avatar_url, :invalid_http_url) + end + end +end diff --git a/app/forms/users/password_reset_form.rb b/app/forms/users/password_reset_form.rb new file mode 100644 index 000000000..0e530b128 --- /dev/null +++ b/app/forms/users/password_reset_form.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Users + # Forgot-password write use case: changes ONLY the password of an existing + # user whose reset token was already verified by the controller. Extends the + # common user validation contract so no User write bypasses the Form + # hierarchy (DIP/DRY); identity fields are prefilled from the target record + # and never rewritten. The avatar rule is out of scope for this intent. + class PasswordResetForm < Form + attribute :password, :string + attribute :password_confirmation, :string + attribute :require_avatar, :boolean, default: false + + validates :password, presence: true, length: { in: 8..72 } + validates :password_confirmation, presence: true + validate :password_confirmation_match + + def initialize(attributes = {}, user:) + identity = { + full_name: user.full_name, + email_address: user.email_address + }.merge(attributes) + + super(identity, user: user) + end + + private + + def user_attributes + { + password: password, + password_confirmation: password_confirmation + } + end + + def password_confirmation_match + errors.add(:password_confirmation, :confirmation) unless password == password_confirmation + end + end +end diff --git a/app/forms/users/profile_form.rb b/app/forms/users/profile_form.rb new file mode 100644 index 000000000..34baef704 --- /dev/null +++ b/app/forms/users/profile_form.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Users + # Own-profile write use case: full_name, email_address, avatar and an + # OPTIONAL password. Never manages a role or other users (ISP). + class ProfileForm < Form + attribute :password, :string + attribute :password_confirmation, :string + + validates :password, length: { in: 8..72 }, if: :password_chosen? + validates :password_confirmation, presence: true, if: :password_chosen? + validate :password_confirmation_match, if: :password_chosen? + + private + + def user_attributes + return super unless password.present? + + super.merge(password: password, password_confirmation: password_confirmation) + end + + def password_chosen? + password.present? + end + + def password_confirmation_match + errors.add(:password_confirmation, :confirmation) unless password == password_confirmation + end + end +end diff --git a/app/forms/users/registration_form.rb b/app/forms/users/registration_form.rb new file mode 100644 index 000000000..428981476 --- /dev/null +++ b/app/forms/users/registration_form.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Users + # Registration use case: a visitor always becomes a regular :user. + # The role rule is imposed here, never in the controller. + class RegistrationForm < Form + attribute :password, :string + attribute :password_confirmation, :string + + validates :password, presence: true, length: { in: 8..72 } + validates :password_confirmation, presence: true + validate :password_confirmation_match + + def initialize(attributes = {}, user: nil) + super(attributes, user: user, role: :user) + end + + private + + def user_attributes + super.merge( + password: password, + password_confirmation: password_confirmation + ) + end + + def password_confirmation_match + return if password.blank? || password_confirmation.blank? + + errors.add(:password_confirmation, :confirmation) unless password == password_confirmation + end + end +end diff --git a/app/frontend/App.tsx b/app/frontend/App.tsx new file mode 100644 index 000000000..3fa497736 --- /dev/null +++ b/app/frontend/App.tsx @@ -0,0 +1,36 @@ +import { router } from '@inertiajs/react' +import { useEffect, type ReactNode } from 'react' + +import { useStore } from '@/stores' +import type { SessionDP } from '@/types' + +type AppProps = { + children: ReactNode + initialPage: { props: unknown } +} + +// Bridge between the Inertia navigation stream and MobX. +// +// This wrapper renders ABOVE , so the Inertia PageContext (which +// only exists deeper in the tree) is not available here — hence no usePage(). +// Instead we read the initial page props passed from the entrypoint and keep +// the stores in sync through the global `navigate` event, which fires on every +// completed page swap (visits, partial reloads, history navigation). +export function App({ children, initialPage }: AppProps) { + const { session, flash } = useStore() + + useEffect(() => { + const hydrate = (pageProps: unknown) => { + const props = pageProps as SessionDP + session.hydrate(props.auth?.user ?? null) + flash.hydrate(props.flash ?? {}) + } + + hydrate(initialPage.props) + return router.on('navigate', (event) => { + hydrate((event.detail.page as { props: unknown }).props) + }) + }, [initialPage, session, flash]) + + return <>{children} +} diff --git a/app/frontend/assets/inertia.svg b/app/frontend/assets/inertia.svg new file mode 100644 index 000000000..61ec585c3 --- /dev/null +++ b/app/frontend/assets/inertia.svg @@ -0,0 +1 @@ + diff --git a/app/frontend/assets/rails.svg b/app/frontend/assets/rails.svg new file mode 100644 index 000000000..92f66e7c8 --- /dev/null +++ b/app/frontend/assets/rails.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/frontend/assets/react.svg b/app/frontend/assets/react.svg new file mode 100644 index 000000000..ae3e3f227 --- /dev/null +++ b/app/frontend/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/assets/vite_ruby.svg b/app/frontend/assets/vite_ruby.svg new file mode 100644 index 000000000..c4d427016 --- /dev/null +++ b/app/frontend/assets/vite_ruby.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/atoms/Alert/Alert.styles.ts b/app/frontend/atoms/Alert/Alert.styles.ts new file mode 100644 index 000000000..ead9c2910 --- /dev/null +++ b/app/frontend/atoms/Alert/Alert.styles.ts @@ -0,0 +1,22 @@ +import { css, styled, type VariantConfig } from '@/lib/styled' + +export const StyledAlert = styled.div( + 'flex items-start gap-2 rounded-md border px-4 py-3 text-sm', + { + variants: css({ + variant: { + info: 'border-brand-faint bg-brand-soft text-ink', + success: 'border-ok-faint bg-ok-soft text-ink', + warning: 'border-warn-faint bg-warn-soft text-ink', + danger: 'border-danger-faint bg-danger-soft text-danger-strong', + }, + }), + defaults: { variant: 'info' }, + } satisfies VariantConfig, +) + +export const AlertContent = styled.div('min-w-0 flex-1') + +export const AlertTitle = styled.p('font-medium') + +export const AlertBody = styled.p('mt-0.5') diff --git a/app/frontend/atoms/Alert/Alert.test.tsx b/app/frontend/atoms/Alert/Alert.test.tsx new file mode 100644 index 000000000..cf339afab --- /dev/null +++ b/app/frontend/atoms/Alert/Alert.test.tsx @@ -0,0 +1,26 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { Alert } from './Alert' + +describe('Alert', () => { + it('defaults to info status', () => { + render(Heads up) + const alert = screen.getByRole('status') + expect(alert).toHaveTextContent('Heads up') + expect(alert).toHaveClass('bg-brand-soft') + }) + + it('uses role=alert for danger and maps the icon', () => { + render( + + Something broke + , + ) + const alert = screen.getByRole('alert') + expect(alert).toHaveTextContent('Failed') + expect(alert).toHaveTextContent('Something broke') + expect(alert).toHaveClass('bg-danger-soft') + expect(alert.querySelector('svg')).not.toBeNull() + }) +}) diff --git a/app/frontend/atoms/Alert/Alert.tsx b/app/frontend/atoms/Alert/Alert.tsx new file mode 100644 index 000000000..1797d0cae --- /dev/null +++ b/app/frontend/atoms/Alert/Alert.tsx @@ -0,0 +1,25 @@ +import { Icon } from '@/atoms/Icon/Icon' +import type { IconName } from '@/atoms/Icon/Icon.types' + +import { AlertContent, AlertBody, AlertTitle, StyledAlert } from './Alert.styles' +import type { AlertProps } from './Alert.types' + +const ICON: Record, IconName> = { + info: 'info', + success: 'check', + warning: 'alert', + danger: 'alert', +} + +// User-facing feedback box. role="alert" for errors, "status" otherwise. +export function Alert({ variant = 'info', title, children, ...rest }: AlertProps) { + return ( + + + + {title ? {title} : null} + {children ? {children} : null} + + + ) +} diff --git a/app/frontend/atoms/Alert/Alert.types.ts b/app/frontend/atoms/Alert/Alert.types.ts new file mode 100644 index 000000000..f2437a9da --- /dev/null +++ b/app/frontend/atoms/Alert/Alert.types.ts @@ -0,0 +1,8 @@ +import type { ComponentProps } from 'react' + +export type AlertVariant = 'info' | 'success' | 'warning' | 'danger' + +export type AlertProps = Omit, 'variant'> & { + variant?: AlertVariant + title?: string +} diff --git a/app/frontend/atoms/Avatar/Avatar.styles.ts b/app/frontend/atoms/Avatar/Avatar.styles.ts new file mode 100644 index 000000000..b800c0d14 --- /dev/null +++ b/app/frontend/atoms/Avatar/Avatar.styles.ts @@ -0,0 +1,45 @@ +import { cn, css, styled, type VariantConfig } from '@/lib/styled' + +const avatarSizes = css({ + size: { + sm: 'h-8 w-8', + md: 'h-10 w-10', + lg: 'h-20 w-20', + }, +}) + +const avatarConfig: VariantConfig = { + variants: avatarSizes, + defaults: { size: 'md' }, +} + +export const StyledAvatar = styled.img('rounded-full object-cover ring-1 ring-line', avatarConfig) + +export const StyledAvatarFallback = styled.span( + 'inline-flex select-none items-center justify-center rounded-full font-bold uppercase ring-1 ring-line', + avatarConfig, +) + +// Soft-tone backgrounds for letter fallbacks. Each avatar without an image +// gets a random one (see avatarFallbackTone); tones use the theme color pairs. +export const avatarFallbackPalette = [ + { bg: 'bg-brand-soft', text: 'text-brand-strong' }, + { bg: 'bg-ok-soft', text: 'text-ok' }, + { bg: 'bg-warn-soft', text: 'text-warn-strong' }, + { bg: 'bg-danger-soft', text: 'text-danger-strong' }, + { bg: 'bg-line-soft', text: 'text-ink' }, +] as const + +let lastToneIndex = -1 + +// Picks a random tone, avoiding an immediate repeat so side-by-side avatars +// (tables, lists) differ from one another. +export function avatarFallbackTone(): string { + let index = Math.floor(Math.random() * avatarFallbackPalette.length) + if (index === lastToneIndex) { + index = (index + 1) % avatarFallbackPalette.length + } + lastToneIndex = index + const { bg, text } = avatarFallbackPalette[index] + return cn(bg, text) +} diff --git a/app/frontend/atoms/Avatar/Avatar.test.tsx b/app/frontend/atoms/Avatar/Avatar.test.tsx new file mode 100644 index 000000000..315ab6ad4 --- /dev/null +++ b/app/frontend/atoms/Avatar/Avatar.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { Avatar } from './Avatar' +import { avatarFallbackPalette } from './Avatar.styles' + +describe('Avatar', () => { + it('renders an image when src is provided', () => { + render() + const img = screen.getByRole('img', { name: 'Ada' }) + expect(img).toHaveAttribute('src', 'https://example.test/a.png') + expect(img).toHaveClass('h-10') + }) + + it('renders the bold first-letter fallback without src', () => { + render() + const fallback = screen.getByRole('img', { name: 'Grace Hopper' }) + expect(fallback).toHaveTextContent('G') + expect(fallback).not.toHaveTextContent('Grace') + expect(fallback).toHaveClass('font-bold', 'uppercase') + }) + + it('applies a palette background tone to the fallback', () => { + render() + const fallback = screen.getByRole('img', { name: 'Grace Hopper' }) + const classes = fallback.className + const matched = avatarFallbackPalette.some( + (tone) => classes.includes(tone.bg) && classes.includes(tone.text), + ) + expect(matched).toBe(true) + }) + + it('supports lg size', () => { + render() + expect(screen.getByRole('img')).toHaveClass('h-20') + }) +}) diff --git a/app/frontend/atoms/Avatar/Avatar.tsx b/app/frontend/atoms/Avatar/Avatar.tsx new file mode 100644 index 000000000..5a2e06e1c --- /dev/null +++ b/app/frontend/atoms/Avatar/Avatar.tsx @@ -0,0 +1,21 @@ +import { useState } from 'react' + +import { avatarFallbackTone, StyledAvatar, StyledAvatarFallback } from './Avatar.styles' +import type { AvatarProps } from './Avatar.types' + +// Renders the image when available, otherwise a bold single-letter fallback +// (no broken img bubbles in tables). The letter is the first character of the +// name, uppercased; the background tone is randomized per avatar instance. +export function Avatar({ src, alt = '', size = 'md', ...rest }: AvatarProps) { + const [fallbackTone] = useState(avatarFallbackTone) + + if (src) { + return + } + + return ( + + {alt.trim().charAt(0).toUpperCase() || '?'} + + ) +} diff --git a/app/frontend/atoms/Avatar/Avatar.types.ts b/app/frontend/atoms/Avatar/Avatar.types.ts new file mode 100644 index 000000000..5829d073d --- /dev/null +++ b/app/frontend/atoms/Avatar/Avatar.types.ts @@ -0,0 +1,9 @@ +import type { ComponentProps } from 'react' + +export type AvatarSize = 'sm' | 'md' | 'lg' + +export type AvatarProps = { + src: string | null | undefined + alt?: string + size?: AvatarSize +} & Omit & ComponentProps<'span'>, 'src' | 'alt' | 'size'> diff --git a/app/frontend/atoms/Badge/Badge.styles.ts b/app/frontend/atoms/Badge/Badge.styles.ts new file mode 100644 index 000000000..5353b4fd8 --- /dev/null +++ b/app/frontend/atoms/Badge/Badge.styles.ts @@ -0,0 +1,25 @@ +import { css, styled, type VariantConfig } from '@/lib/styled' + +export const badgeBase = css( + 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium', +) + +export const badgeVariants = css({ + variant: { + neutral: 'bg-line-soft text-ink-mute', + admin: 'bg-brand-soft text-brand-strong', + user: 'bg-line-soft text-ink-mute', + pending: 'bg-warn-soft text-warn', + processing: 'bg-brand-soft text-brand-strong', + completed: 'bg-ok-soft text-ok', + failed: 'bg-danger-soft text-danger-strong', + danger: 'bg-danger-soft text-danger-strong', + }, +}) + +export const badgeConfig: VariantConfig = { + variants: badgeVariants, + defaults: { variant: 'neutral' }, +} + +export const StyledBadge = styled.span(badgeBase, badgeConfig) diff --git a/app/frontend/atoms/Badge/Badge.test.tsx b/app/frontend/atoms/Badge/Badge.test.tsx new file mode 100644 index 000000000..b631d73ab --- /dev/null +++ b/app/frontend/atoms/Badge/Badge.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { Badge } from './Badge' + +describe('Badge', () => { + it('defaults to neutral', () => { + render(Draft) + expect(screen.getByText('Draft')).toHaveClass('bg-line-soft') + }) + + it('maps role and import statuses to variants', () => { + render(admin) + expect(screen.getByText('admin')).toHaveClass('bg-brand-soft') + + render(failed) + expect(screen.getByText('failed')).toHaveClass('bg-danger-soft') + }) + + it('keeps data attributes for tests/selectors', () => { + render( + + Processing + , + ) + expect(screen.getByText('Processing')).toHaveAttribute('data-status', 'processing') + }) + + it('merges consumer className', () => { + render(X) + expect(screen.getByText('X')).toHaveClass('uppercase') + }) +}) diff --git a/app/frontend/atoms/Badge/Badge.tsx b/app/frontend/atoms/Badge/Badge.tsx new file mode 100644 index 000000000..15ecfcc37 --- /dev/null +++ b/app/frontend/atoms/Badge/Badge.tsx @@ -0,0 +1,7 @@ +import { StyledBadge } from './Badge.styles' +import type { BadgeProps } from './Badge.types' + +// Tiny labelled status chip. Variant resolved by the styled wrapper. +export function Badge({ variant, ...rest }: BadgeProps) { + return +} diff --git a/app/frontend/atoms/Badge/Badge.types.ts b/app/frontend/atoms/Badge/Badge.types.ts new file mode 100644 index 000000000..1e277a38a --- /dev/null +++ b/app/frontend/atoms/Badge/Badge.types.ts @@ -0,0 +1,8 @@ +import type { ComponentProps } from 'react' + +export type BadgeVariant = + 'neutral' | 'admin' | 'user' | 'pending' | 'processing' | 'completed' | 'failed' | 'danger' + +export type BadgeProps = Omit, 'variant'> & { + variant?: BadgeVariant +} diff --git a/app/frontend/atoms/Button/Button.styles.ts b/app/frontend/atoms/Button/Button.styles.ts new file mode 100644 index 000000000..66842671f --- /dev/null +++ b/app/frontend/atoms/Button/Button.styles.ts @@ -0,0 +1,41 @@ +import { cn, css, styled, type VariantConfig } from '@/lib/styled' + +import type { ButtonSize, ButtonVariant } from './Button.types' + +export const buttonBase = css( + 'inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-50', +) + +export const buttonVariants = css({ + variant: { + primary: 'bg-brand text-white hover:bg-brand-strong focus-visible:ring-brand-ring-strong', + secondary: + 'bg-paper text-ink border border-line hover:bg-line-soft focus-visible:ring-brand-ring', + danger: 'bg-danger text-white hover:bg-danger-strong focus-visible:ring-danger-ring-strong', + ghost: 'bg-transparent text-brand hover:bg-brand-soft focus-visible:ring-brand-ring', + }, + size: { + sm: 'px-3 py-1.5 text-sm', + md: 'px-4 py-2 text-sm', + }, +}) + +export const buttonConfig: VariantConfig = { + variants: buttonVariants, + defaults: { variant: 'primary', size: 'md' }, +} + +export const StyledButton = styled.button(buttonBase, buttonConfig) + +// Shared with ButtonLink (Inertia ) — ButtonLink can't use the styled +// wrapper without giving up SPA navigation, so it composes cn() here (same +// resolution order: base -> variants -> consumer className). +export function buttonClasses( + options: { variant?: ButtonVariant; size?: ButtonSize } = {}, +): string { + return cn( + buttonBase, + buttonVariants.variant[options.variant ?? 'primary'], + buttonVariants.size[options.size ?? 'md'], + ) +} diff --git a/app/frontend/atoms/Button/Button.test.tsx b/app/frontend/atoms/Button/Button.test.tsx new file mode 100644 index 000000000..3b7a2eec1 --- /dev/null +++ b/app/frontend/atoms/Button/Button.test.tsx @@ -0,0 +1,47 @@ +import userEvent from '@testing-library/user-event' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import { Button } from './Button' + +describe('Button', () => { + it('renders children with primary + md styles by default', () => { + render() + const button = screen.getByRole('button', { name: 'Save' }) + expect(button).toBeInTheDocument() + expect(button).toHaveClass('bg-brand', 'py-2') + }) + + it('applies the danger variant and sm size', () => { + render( + , + ) + const button = screen.getByRole('button', { name: 'Delete' }) + expect(button).toHaveClass('bg-danger', 'px-3', 'py-1.5') + }) + + it('merges consumer className last (Tailwind-aware)', () => { + render() + expect(screen.getByRole('button', { name: 'Custom' })).toHaveClass('bg-red-500') + expect(screen.getByRole('button', { name: 'Custom' })).not.toHaveClass('bg-brand') + }) + + it('renders polymorphic `as` tag', () => { + render( + , + ) + expect(screen.getByTestId('poly').tagName).toBe('SPAN') + }) + + it('respects onClick', async () => { + const user = userEvent.setup() + const onClick = vi.fn() + render() + await user.click(screen.getByRole('button', { name: 'Go' })) + expect(onClick).toHaveBeenCalledTimes(1) + }) +}) diff --git a/app/frontend/atoms/Button/Button.tsx b/app/frontend/atoms/Button/Button.tsx new file mode 100644 index 000000000..40ae8315a --- /dev/null +++ b/app/frontend/atoms/Button/Button.tsx @@ -0,0 +1,8 @@ +import type { ButtonProps } from './Button.types' +import { StyledButton } from './Button.styles' + +// Primary interaction atom. Variant + size resolved by the styled wrapper; +// consumer className is merged last (Tailwind-aware, see lib/styled). +export function Button({ variant, size, ...rest }: ButtonProps) { + return +} diff --git a/app/frontend/atoms/Button/Button.types.ts b/app/frontend/atoms/Button/Button.types.ts new file mode 100644 index 000000000..21584d09d --- /dev/null +++ b/app/frontend/atoms/Button/Button.types.ts @@ -0,0 +1,12 @@ +import type { ComponentProps, ElementType } from 'react' + +export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost' +export type ButtonSize = 'sm' | 'md' + +// Native button props (including className/as for polymorphic reuse) without +// the variant machinery, which is re-exported with strict literal unions. +export type ButtonProps = Omit, 'variant' | 'size'> & { + as?: ElementType + variant?: ButtonVariant + size?: ButtonSize +} diff --git a/app/frontend/atoms/Button/ButtonLink.tsx b/app/frontend/atoms/Button/ButtonLink.tsx new file mode 100644 index 000000000..b120928e0 --- /dev/null +++ b/app/frontend/atoms/Button/ButtonLink.tsx @@ -0,0 +1,18 @@ +import { Link } from '@inertiajs/react' +import type { ComponentProps } from 'react' +import { cn } from '@/lib/styled' + +import { buttonClasses } from './Button.styles' +import type { ButtonSize, ButtonVariant } from './Button.types' + +type ButtonLinkProps = Omit, 'size' | 'variant' | 'className'> & { + variant?: ButtonVariant + size?: ButtonSize + className?: string +} + +// Button visuals for SPA navigation: renders an Inertia (never a plain +// anchor) so clicks go through the shared navigation lifecycle. +export function ButtonLink({ variant, size, className, ...rest }: ButtonLinkProps) { + return +} diff --git a/app/frontend/atoms/EmptyState/EmptyState.styles.ts b/app/frontend/atoms/EmptyState/EmptyState.styles.ts new file mode 100644 index 000000000..5bf38c3ad --- /dev/null +++ b/app/frontend/atoms/EmptyState/EmptyState.styles.ts @@ -0,0 +1,16 @@ +import { css, styled } from '@/lib/styled' + +export const StyledEmptyWrap = styled.div( + 'flex flex-col items-center justify-center gap-2 rounded-md border border-dashed border-line bg-paper px-6 py-10 text-center', +) + +export const StyledEmptyIcon = styled.span('text-ink-mute') + +export const StyledEmptyTitle = styled.p('text-sm font-semibold text-ink-soft') + +export const StyledEmptyDescription = styled.p('max-w-sm text-sm text-ink-mute') + +export const StyledEmptyAction = styled.div('mt-2') + +// Placeholder text for sr-only when only the icon is shown. +export const emptySrClass = css('sr-only') diff --git a/app/frontend/atoms/EmptyState/EmptyState.test.tsx b/app/frontend/atoms/EmptyState/EmptyState.test.tsx new file mode 100644 index 000000000..f3de346d0 --- /dev/null +++ b/app/frontend/atoms/EmptyState/EmptyState.test.tsx @@ -0,0 +1,20 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { EmptyState } from './EmptyState' + +describe('EmptyState', () => { + it('renders title, description and action', () => { + render( + Create} + />, + ) + expect(screen.getByText('No users')).toBeInTheDocument() + expect(screen.getByText('Nothing to see yet.')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Create' })).toBeInTheDocument() + }) +}) diff --git a/app/frontend/atoms/EmptyState/EmptyState.tsx b/app/frontend/atoms/EmptyState/EmptyState.tsx new file mode 100644 index 000000000..334be5318 --- /dev/null +++ b/app/frontend/atoms/EmptyState/EmptyState.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from 'react' + +import type { IconName } from '@/atoms/Icon/Icon.types' +import { Icon } from '@/atoms/Icon/Icon' + +import { + StyledEmptyAction, + StyledEmptyDescription, + StyledEmptyIcon, + StyledEmptyTitle, + StyledEmptyWrap, +} from './EmptyState.styles' + +export type EmptyStateProps = { + icon?: IconName + title: string + description?: string + action?: ReactNode +} + +// Friendly "nothing here yet" panel that hosts an optional action (atom/molecule). +export function EmptyState({ icon, title, description, action }: EmptyStateProps) { + return ( + + {icon ? ( + + + ) : null} + {title} + {description ? {description} : null} + {action ? {action} : null} + + ) +} diff --git a/app/frontend/atoms/Heading/Heading.styles.ts b/app/frontend/atoms/Heading/Heading.styles.ts new file mode 100644 index 000000000..8fea90475 --- /dev/null +++ b/app/frontend/atoms/Heading/Heading.styles.ts @@ -0,0 +1,18 @@ +import { css, styled, type VariantConfig } from '@/lib/styled' + +export const headingTags = css({ + sm: 'h3', + md: 'h2', + lg: 'h1', +} as const) + +export const StyledHeading = styled.h1('m-0 font-bold tracking-tight text-ink', { + variants: css({ + size: { + sm: 'text-base font-semibold', + md: 'text-xl', + lg: 'text-2xl', + }, + }), + defaults: { size: 'lg' }, +} satisfies VariantConfig) diff --git a/app/frontend/atoms/Heading/Heading.test.tsx b/app/frontend/atoms/Heading/Heading.test.tsx new file mode 100644 index 000000000..e6a7435ab --- /dev/null +++ b/app/frontend/atoms/Heading/Heading.test.tsx @@ -0,0 +1,16 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { Heading } from './Heading' + +describe('Heading', () => { + it('renders lg as h1', () => { + render(Page title) + expect(screen.getByRole('heading', { level: 1, name: 'Page title' })).toBeInTheDocument() + }) + + it('maps sizes to semantic tags', () => { + render(Section) + expect(screen.getByRole('heading', { level: 2, name: 'Section' })).toHaveClass('text-xl') + }) +}) diff --git a/app/frontend/atoms/Heading/Heading.tsx b/app/frontend/atoms/Heading/Heading.tsx new file mode 100644 index 000000000..b9ab97551 --- /dev/null +++ b/app/frontend/atoms/Heading/Heading.tsx @@ -0,0 +1,8 @@ +import { headingTags, StyledHeading } from './Heading.styles' +import type { HeadingProps } from './Heading.types' + +// Semantic/sized heading. Element tag follows size (lg -> h1, md -> h2, +// sm -> h3) unless overridden with `as`. +export function Heading({ size = 'lg', ...rest }: HeadingProps) { + return +} diff --git a/app/frontend/atoms/Heading/Heading.types.ts b/app/frontend/atoms/Heading/Heading.types.ts new file mode 100644 index 000000000..97a0246ab --- /dev/null +++ b/app/frontend/atoms/Heading/Heading.types.ts @@ -0,0 +1,8 @@ +import type { ComponentProps, ElementType } from 'react' + +export type HeadingSize = 'sm' | 'md' | 'lg' + +export type HeadingProps = Omit, 'size'> & { + as?: ElementType + size?: HeadingSize +} diff --git a/app/frontend/atoms/Icon/Icon.styles.ts b/app/frontend/atoms/Icon/Icon.styles.ts new file mode 100644 index 000000000..ec15d0cb8 --- /dev/null +++ b/app/frontend/atoms/Icon/Icon.styles.ts @@ -0,0 +1,12 @@ +import { css, styled, type VariantConfig } from '@/lib/styled' + +export const StyledIcon = styled.svg('inline-block shrink-0', { + variants: css({ + size: { + sm: 'size-4', + md: 'size-5', + lg: 'size-6', + }, + }), + defaults: { size: 'md' }, +} satisfies VariantConfig) diff --git a/app/frontend/atoms/Icon/Icon.test.tsx b/app/frontend/atoms/Icon/Icon.test.tsx new file mode 100644 index 000000000..36193f2b1 --- /dev/null +++ b/app/frontend/atoms/Icon/Icon.test.tsx @@ -0,0 +1,19 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { Icon } from './Icon' + +describe('Icon', () => { + it('renders a decorative inline svg', () => { + render() + const svg = screen.getByTestId('icon') + expect(svg.tagName).toBe('svg') + expect(svg).toHaveAttribute('aria-hidden', 'true') + expect(svg).toHaveClass('size-5') + }) + + it('applies size variant', () => { + render() + expect(screen.getByTestId('icon')).toHaveClass('size-4') + }) +}) diff --git a/app/frontend/atoms/Icon/Icon.tsx b/app/frontend/atoms/Icon/Icon.tsx new file mode 100644 index 000000000..36ac98dbd --- /dev/null +++ b/app/frontend/atoms/Icon/Icon.tsx @@ -0,0 +1,96 @@ +import type { ReactNode } from 'react' + +import { StyledIcon } from './Icon.styles' +import type { IconProps } from './Icon.types' + +// Inline SVG icon set (24px grid, currentColor). Decorative by default — +// pair with visible text or supply your own label. +const PATHS: Record = { + alert: ( + <> + + + + + ), + check: , + download: ( + <> + + + + + ), + info: ( + <> + + + + + ), + logout: ( + <> + + + + + ), + menu: ( + <> + + + + + ), + plus: ( + <> + + + + ), + search: ( + <> + + + + ), + upload: ( + <> + + + + + ), + users: ( + <> + + + + + + ), + x: ( + <> + + + + ), +} + +export function Icon({ name, size, ...rest }: IconProps) { + return ( + + ) +} diff --git a/app/frontend/atoms/Icon/Icon.types.ts b/app/frontend/atoms/Icon/Icon.types.ts new file mode 100644 index 000000000..bb4ffd1e3 --- /dev/null +++ b/app/frontend/atoms/Icon/Icon.types.ts @@ -0,0 +1,21 @@ +import type { ComponentProps } from 'react' + +export type IconName = + | 'alert' + | 'check' + | 'download' + | 'info' + | 'logout' + | 'menu' + | 'plus' + | 'search' + | 'upload' + | 'users' + | 'x' + +export type IconSize = 'sm' | 'md' | 'lg' + +export type IconProps = Omit, 'name' | 'size'> & { + name: IconName + size?: IconSize +} diff --git a/app/frontend/atoms/Label/Label.styles.ts b/app/frontend/atoms/Label/Label.styles.ts new file mode 100644 index 000000000..a52f2547c --- /dev/null +++ b/app/frontend/atoms/Label/Label.styles.ts @@ -0,0 +1,3 @@ +import { css } from '@/lib/styled' + +export const labelClass = css('block text-sm font-medium text-ink-soft') diff --git a/app/frontend/atoms/Label/Label.test.tsx b/app/frontend/atoms/Label/Label.test.tsx new file mode 100644 index 000000000..0e6c7a395 --- /dev/null +++ b/app/frontend/atoms/Label/Label.test.tsx @@ -0,0 +1,13 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { Label } from './Label' + +describe('Label', () => { + it('renders with htmlFor', () => { + render() + const label = screen.getByText('Email address') + expect(label).toHaveAttribute('for', 'email') + expect(label).toHaveClass('text-ink-soft') + }) +}) diff --git a/app/frontend/atoms/Label/Label.tsx b/app/frontend/atoms/Label/Label.tsx new file mode 100644 index 000000000..65bdec977 --- /dev/null +++ b/app/frontend/atoms/Label/Label.tsx @@ -0,0 +1,6 @@ +import { styled } from '@/lib/styled' + +import { labelClass } from './Label.styles' + +// Form label. Explicit htmlFor wiring belongs to the Field molecule. +export const Label = styled.label(labelClass) diff --git a/app/frontend/atoms/Label/Label.types.ts b/app/frontend/atoms/Label/Label.types.ts new file mode 100644 index 000000000..8f697d320 --- /dev/null +++ b/app/frontend/atoms/Label/Label.types.ts @@ -0,0 +1,5 @@ +import type { ComponentProps } from 'react' + +export type LabelProps = ComponentProps<'label'> + +export type { labelClass } from './Label.styles' diff --git a/app/frontend/atoms/ProgressBar/ProgressBar.styles.ts b/app/frontend/atoms/ProgressBar/ProgressBar.styles.ts new file mode 100644 index 000000000..0fcf0c4ed --- /dev/null +++ b/app/frontend/atoms/ProgressBar/ProgressBar.styles.ts @@ -0,0 +1,17 @@ +import { css, styled, type VariantConfig } from '@/lib/styled' + +export const StyledBar = styled.span('block h-2 w-full overflow-hidden rounded-full bg-line-soft') + +export const StyledFill = styled.span( + 'block h-full w-[var(--progress)] rounded-full transition-[width] duration-300 ease-out', + { + variants: css({ + variant: { + default: 'bg-brand', + completed: 'bg-ok', + failed: 'bg-danger', + }, + }), + defaults: { variant: 'default' }, + } satisfies VariantConfig, +) diff --git a/app/frontend/atoms/ProgressBar/ProgressBar.test.tsx b/app/frontend/atoms/ProgressBar/ProgressBar.test.tsx new file mode 100644 index 000000000..5c388e1c0 --- /dev/null +++ b/app/frontend/atoms/ProgressBar/ProgressBar.test.tsx @@ -0,0 +1,24 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { ProgressBar } from './ProgressBar' + +describe('ProgressBar', () => { + it('exposes progress to assistive tech', () => { + render() + const bar = screen.getByRole('progressbar') + expect(bar).toHaveAttribute('aria-valuenow', '42') + expect(bar.getAttribute('class')).toContain('bg-line-soft') + }) + + it('clamps percent to 0..100', () => { + const { container } = render() + const fill = container.querySelector('[style*="--progress"]') + expect(fill?.getAttribute('style')).toContain('100%') + }) + + it('applies terminal variants', () => { + const { container } = render() + expect(container.querySelector('span span')).toHaveClass('bg-danger') + }) +}) diff --git a/app/frontend/atoms/ProgressBar/ProgressBar.tsx b/app/frontend/atoms/ProgressBar/ProgressBar.tsx new file mode 100644 index 000000000..39a632425 --- /dev/null +++ b/app/frontend/atoms/ProgressBar/ProgressBar.tsx @@ -0,0 +1,19 @@ +import { StyledBar, StyledFill } from './ProgressBar.styles' +import { progressVar, type ProgressBarProps } from './ProgressBar.types' + +// Accessible progress meter. The fill reads its width from the --progress CSS +// variable (see progressVar) so percentage changes transition smoothly. +export function ProgressBar({ percent, variant, ...rest }: ProgressBarProps) { + return ( + + + + ) +} diff --git a/app/frontend/atoms/ProgressBar/ProgressBar.types.ts b/app/frontend/atoms/ProgressBar/ProgressBar.types.ts new file mode 100644 index 000000000..1d6023836 --- /dev/null +++ b/app/frontend/atoms/ProgressBar/ProgressBar.types.ts @@ -0,0 +1,14 @@ +import type { CSSProperties, ComponentProps } from 'react' + +export type ProgressVariant = 'default' | 'completed' | 'failed' + +export type ProgressBarProps = Omit, 'variant'> & { + percent: number + variant?: ProgressVariant +} + +// The fill width is a dynamic CSS variable — the single documented inline +// style (cssVar) exception allowed by the phase spec. +export function progressVar(percent: number): CSSProperties { + return { ['--progress' as string]: `${Math.min(100, Math.max(0, percent))}%` } as CSSProperties +} diff --git a/app/frontend/atoms/Select/Select.styles.ts b/app/frontend/atoms/Select/Select.styles.ts new file mode 100644 index 000000000..60351e763 --- /dev/null +++ b/app/frontend/atoms/Select/Select.styles.ts @@ -0,0 +1,19 @@ +import { css, styled, type VariantConfig } from '@/lib/styled' + +export const selectBase = css( + 'block w-full rounded-md border bg-paper px-3 py-2 text-base shadow-sm transition-colors focus:outline-none focus:ring-2 disabled:bg-line-soft disabled:text-ink-mute sm:text-sm', +) + +export const selectVariants = css({ + tone: { + normal: 'border-line text-ink focus:border-brand focus:ring-brand-ring', + invalid: 'border-danger text-ink focus:border-danger focus:ring-danger-ring', + }, +}) + +export const selectConfig: VariantConfig = { + variants: selectVariants, + defaults: { tone: 'normal' }, +} + +export const StyledSelect = styled.select(selectBase, selectConfig) diff --git a/app/frontend/atoms/Select/Select.test.tsx b/app/frontend/atoms/Select/Select.test.tsx new file mode 100644 index 000000000..5587e1d01 --- /dev/null +++ b/app/frontend/atoms/Select/Select.test.tsx @@ -0,0 +1,42 @@ +import userEvent from '@testing-library/user-event' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import { Select } from './Select' + +describe('Select', () => { + const options = ( + <> + + + + ) + + it('renders options and remembers the selection', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + render( + , + ) + const select = screen.getByRole('combobox', { name: 'Role' }) + await user.selectOptions(select, 'admin') + expect(onChange).toHaveBeenCalledWith('admin') + expect(select).toHaveValue('admin') + }) + + it('marks invalid tone', () => { + render( + , + ) + expect(screen.getByRole('combobox', { name: 'Role' })).toHaveClass('border-danger') + }) +}) diff --git a/app/frontend/atoms/Select/Select.tsx b/app/frontend/atoms/Select/Select.tsx new file mode 100644 index 000000000..bb7379638 --- /dev/null +++ b/app/frontend/atoms/Select/Select.tsx @@ -0,0 +1,12 @@ +import { forwardRef } from 'react' + +import { StyledSelect } from './Select.styles' +import type { SelectProps } from './Select.types' + +// Native select with the shared field styling (tone variants available). +export const Select = forwardRef(function Select( + { tone, ...rest }, + ref, +) { + return +}) diff --git a/app/frontend/atoms/Select/Select.types.ts b/app/frontend/atoms/Select/Select.types.ts new file mode 100644 index 000000000..e1c0bb90f --- /dev/null +++ b/app/frontend/atoms/Select/Select.types.ts @@ -0,0 +1,7 @@ +import type { ComponentProps } from 'react' + +export type SelectTone = 'normal' | 'invalid' + +export type SelectProps = Omit, 'tone'> & { + tone?: SelectTone +} diff --git a/app/frontend/atoms/Spinner/Spinner.styles.ts b/app/frontend/atoms/Spinner/Spinner.styles.ts new file mode 100644 index 000000000..af59a5546 --- /dev/null +++ b/app/frontend/atoms/Spinner/Spinner.styles.ts @@ -0,0 +1,18 @@ +import { css, styled, type VariantConfig } from '@/lib/styled' + +export const StyledSpinner = styled.span( + 'inline-block animate-spin rounded-full border-2 border-current border-r-transparent', + { + variants: css({ + size: { + sm: 'size-3', + md: 'size-4', + lg: 'size-6', + }, + }), + defaults: { size: 'md' }, + } satisfies VariantConfig, +) + +// Screen-reader-only companion (no utility classes allowed in JSX). +export const srOnlyClass = css('sr-only') diff --git a/app/frontend/atoms/Spinner/Spinner.test.tsx b/app/frontend/atoms/Spinner/Spinner.test.tsx new file mode 100644 index 000000000..ea85b6557 --- /dev/null +++ b/app/frontend/atoms/Spinner/Spinner.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { Spinner } from './Spinner' + +describe('Spinner', () => { + it('announces a status with sr-only text', () => { + render() + expect(screen.getByRole('status')).toBeInTheDocument() + expect(screen.getByText('Loading…')).toHaveClass('sr-only') + }) + + it('allows a custom label', () => { + render() + expect(screen.getByText('Saving…')).toBeInTheDocument() + }) +}) diff --git a/app/frontend/atoms/Spinner/Spinner.tsx b/app/frontend/atoms/Spinner/Spinner.tsx new file mode 100644 index 000000000..191f784fc --- /dev/null +++ b/app/frontend/atoms/Spinner/Spinner.tsx @@ -0,0 +1,12 @@ +import { StyledSpinner, srOnlyClass } from './Spinner.styles' +import type { SpinnerProps } from './Spinner.types' + +// Loading indicator with an accessible role="status" announcement. +export function Spinner({ size, label = 'Loading…', ...rest }: SpinnerProps) { + return ( + + + ) +} diff --git a/app/frontend/atoms/Spinner/Spinner.types.ts b/app/frontend/atoms/Spinner/Spinner.types.ts new file mode 100644 index 000000000..74b6fcd87 --- /dev/null +++ b/app/frontend/atoms/Spinner/Spinner.types.ts @@ -0,0 +1,9 @@ +import type { ComponentProps } from 'react' + +export type SpinnerSize = 'sm' | 'md' | 'lg' + +export type SpinnerProps = Omit, 'size'> & { + size?: SpinnerSize + /** Accessible label announced while loading (default: "Loading…"). */ + label?: string +} diff --git a/app/frontend/atoms/TextInput/TextInput.styles.ts b/app/frontend/atoms/TextInput/TextInput.styles.ts new file mode 100644 index 000000000..5287470e3 --- /dev/null +++ b/app/frontend/atoms/TextInput/TextInput.styles.ts @@ -0,0 +1,24 @@ +import { css, styled, type VariantConfig } from '@/lib/styled' + +export const inputBase = css( + 'block w-full rounded-md border bg-paper px-3 py-2 text-base shadow-sm transition-colors focus:outline-none focus:ring-2 disabled:bg-line-soft disabled:text-ink-mute sm:text-sm', +) + +export const inputVariants = css({ + tone: { + normal: + 'border-line text-ink placeholder:text-ink-mute focus:border-brand focus:ring-brand-ring', + invalid: + 'border-danger text-ink placeholder:text-ink-mute focus:border-danger focus:ring-danger-ring', + }, +}) + +export const inputConfig: VariantConfig = { + variants: inputVariants, + defaults: { tone: 'normal' }, +} + +export const StyledInput = styled.input(inputBase, inputConfig) + +// Same language for the select dropdown (tone-driven border/ring). +export const StyledSelect = styled.select(inputBase, inputConfig) diff --git a/app/frontend/atoms/TextInput/TextInput.test.tsx b/app/frontend/atoms/TextInput/TextInput.test.tsx new file mode 100644 index 000000000..cabf249bd --- /dev/null +++ b/app/frontend/atoms/TextInput/TextInput.test.tsx @@ -0,0 +1,45 @@ +import userEvent from '@testing-library/user-event' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import { TextInput } from './TextInput' + +describe('TextInput', () => { + it('renders a labelled input with normal tone by default', () => { + render() + const input = screen.getByRole('textbox', { name: 'Name' }) + expect(input).toHaveClass('border-line') + expect(input).not.toHaveAttribute('aria-invalid') + }) + + it('marks invalid inputs for assistive tech', () => { + render() + const input = screen.getByRole('textbox', { name: 'Name' }) + expect(input).toHaveClass('border-danger') + expect(input).toHaveAttribute('aria-invalid', 'true') + }) + + it('keeps native attributes and merges className', () => { + render() + expect(screen.getByLabelText('Password')).toHaveAttribute('type', 'password') + expect(screen.getByLabelText('Password')).toHaveClass('max-w-xs') + }) + + it('drives onChange and ref', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + const ref = vi.fn() + render( + onChange(event.target.value)} + ref={ref} + />, + ) + await user.type(screen.getByLabelText('Email'), 'a@b.com') + expect(onChange).toHaveBeenLastCalledWith('a@b.com') + expect(ref).toHaveBeenCalledTimes(1) + expect(ref.mock.calls[0][0]).toBeInstanceOf(HTMLInputElement) + }) +}) diff --git a/app/frontend/atoms/TextInput/TextInput.tsx b/app/frontend/atoms/TextInput/TextInput.tsx new file mode 100644 index 000000000..fdfa214d1 --- /dev/null +++ b/app/frontend/atoms/TextInput/TextInput.tsx @@ -0,0 +1,13 @@ +import { forwardRef } from 'react' + +import { StyledInput } from './TextInput.styles' +import type { TextInputProps } from './TextInput.types' + +export const TextInput = forwardRef(function TextInput( + { tone, ...rest }, + ref, +) { + return ( + + ) +}) diff --git a/app/frontend/atoms/TextInput/TextInput.types.ts b/app/frontend/atoms/TextInput/TextInput.types.ts new file mode 100644 index 000000000..e6d00bbe6 --- /dev/null +++ b/app/frontend/atoms/TextInput/TextInput.types.ts @@ -0,0 +1,8 @@ +import type { ComponentProps } from 'react' + +export type InputTone = 'normal' | 'invalid' + +// Native input props without the tone machinery (re-exported as strict union). +export type TextInputProps = Omit, 'tone'> & { + tone?: InputTone +} diff --git a/app/frontend/atoms/TextLink/TextLink.styles.ts b/app/frontend/atoms/TextLink/TextLink.styles.ts new file mode 100644 index 000000000..051fa9b66 --- /dev/null +++ b/app/frontend/atoms/TextLink/TextLink.styles.ts @@ -0,0 +1,5 @@ +import { css } from '@/lib/styled' + +export const textLinkClass = css( + 'font-medium text-brand underline underline-offset-2 hover:text-brand-strong focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-ring', +) diff --git a/app/frontend/atoms/TextLink/TextLink.tsx b/app/frontend/atoms/TextLink/TextLink.tsx new file mode 100644 index 000000000..0b47715bb --- /dev/null +++ b/app/frontend/atoms/TextLink/TextLink.tsx @@ -0,0 +1,14 @@ +import { Link } from '@inertiajs/react' +import type { ComponentProps } from 'react' +import { cn } from '@/lib/styled' + +import { textLinkClass } from './TextLink.styles' + +type TextLinkProps = ComponentProps & { + className?: string +} + +// Inline navigation link (SPA — always an Inertia ). +export function TextLink({ className, ...rest }: TextLinkProps) { + return +} diff --git a/app/frontend/entrypoints/application.css b/app/frontend/entrypoints/application.css new file mode 100644 index 000000000..5ad88f395 --- /dev/null +++ b/app/frontend/entrypoints/application.css @@ -0,0 +1 @@ +@import './../styles/base.css'; diff --git a/app/frontend/entrypoints/application.ts b/app/frontend/entrypoints/application.ts new file mode 100644 index 000000000..ff27427fd --- /dev/null +++ b/app/frontend/entrypoints/application.ts @@ -0,0 +1,28 @@ +// To see this message, add the following to the `` section in your +// views/layouts/application.html.erb +// +// <%= vite_client_tag %> +// <%= vite_javascript_tag 'application' %> +console.log('Vite ⚡️ Rails') + +// If using a TypeScript entrypoint file: +// <%= vite_typescript_tag 'application' %> +// +// If you want to use .jsx or .tsx, add the extension: +// <%= vite_javascript_tag 'application.jsx' %> + +console.log('Visit the guide for more information: ', 'https://vite-ruby.netlify.app/guide/rails') + +// Example: Load Rails libraries in Vite. +// +// import * as Turbo from '@hotwired/turbo' +// Turbo.start() +// +// import ActiveStorage from '@rails/activestorage' +// ActiveStorage.start() +// +// // Import all channels. +// const channels = import.meta.glob('./**/*_channel.js', { eager: true }) + +// Example: Import a stylesheet in app/frontend/index.css +// import '~/index.css' diff --git a/app/frontend/entrypoints/inertia.tsx b/app/frontend/entrypoints/inertia.tsx new file mode 100644 index 000000000..080b9cfa2 --- /dev/null +++ b/app/frontend/entrypoints/inertia.tsx @@ -0,0 +1,48 @@ +import { createInertiaApp } from '@inertiajs/react' +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' + +import { App } from '../App' +import { RootStoreProvider } from '../stores/store-context' + +void createInertiaApp({ + pages: '../pages', + + setup({ el, App: InertiaApp, props }) { + if (!el) return + createRoot(el).render( + + + + + + + , + ) + }, + + strictMode: true, + + defaults: { + form: { + forceIndicesArrayFormatInFormData: false, + withAllErrors: true, + }, + visitOptions: () => { + return { queryStringArrayFormat: 'brackets' } + }, + }, +}).catch((error) => { + // This ensures this entrypoint is only loaded on Inertia pages + // by checking for the presence of the root element (#app by default). + // Feel free to remove this `catch` if you don't need it. + if (document.getElementById('app')) { + throw error + } else { + console.error( + 'Missing root element.\n\n' + + 'If you see this error, it probably means you loaded Inertia.js on non-Inertia pages.\n' + + 'Consider moving <%= vite_typescript_tag "inertia.tsx" %> to the Inertia-specific layout instead.', + ) + } +}) diff --git a/app/frontend/hooks/useStore.ts b/app/frontend/hooks/useStore.ts new file mode 100644 index 000000000..b8f129302 --- /dev/null +++ b/app/frontend/hooks/useStore.ts @@ -0,0 +1,2 @@ +export { useStore } from '@/stores/store-context' +export type { ImportProgress } from '@/stores/imports-store' diff --git a/app/frontend/lib/cable.ts b/app/frontend/lib/cable.ts new file mode 100644 index 000000000..e9f93f95a --- /dev/null +++ b/app/frontend/lib/cable.ts @@ -0,0 +1,35 @@ +import { createConsumer } from '@rails/actioncable' +import { useEffect, useRef } from 'react' + +// WebSocket is an external boundary: the only way realtime enters the app is +// through store.ingest (handlers call an action — no emitters/bus inside). +const consumer = createConsumer() + +export type CableParams = Record + +// Subscribes once per (channel, params) and unsubscribes on unmount/change. +// The received handler is kept in a ref so identity churn never resubscribes. +export function useChannel( + channelName: string, + params: CableParams, + onReceived: (payload: TPayload) => void, +) { + const handlerRef = useRef(onReceived) + + // Keep the ref current without re-subscribing the cable subscription. + useEffect(() => { + handlerRef.current = onReceived + }) + + const identifier = JSON.stringify({ channel: channelName, ...params }) + + useEffect(() => { + const subscription = consumer.subscriptions.create(JSON.parse(identifier), { + received: (payload) => handlerRef.current(payload as TPayload), + }) + + return () => { + subscription.unsubscribe() + } + }, [identifier]) +} diff --git a/app/frontend/lib/styled/cn.ts b/app/frontend/lib/styled/cn.ts new file mode 100644 index 000000000..723f79601 --- /dev/null +++ b/app/frontend/lib/styled/cn.ts @@ -0,0 +1,8 @@ +import { clsx, type ClassValue } from 'clsx' +import { twMerge } from 'tailwind-merge' + +// Merge of class strings with Tailwind-aware override (base -> variants -> +// consumer className). Only lib/styled and *.styles.ts may use this. +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)) +} diff --git a/app/frontend/lib/styled/index.ts b/app/frontend/lib/styled/index.ts new file mode 100644 index 000000000..c56a09305 --- /dev/null +++ b/app/frontend/lib/styled/index.ts @@ -0,0 +1,6 @@ +export { styledWithShorthand as styled } from './styled' +export type { StyledComponentOutput as StyledComponent } from './styled' +export type { VariantProps } from './styled' +export { cn } from './cn' +export { css } from './styled' +export type { VariantConfig, VariantMap } from './variants' diff --git a/app/frontend/lib/styled/styled.tsx b/app/frontend/lib/styled/styled.tsx new file mode 100644 index 000000000..299ff69de --- /dev/null +++ b/app/frontend/lib/styled/styled.tsx @@ -0,0 +1,126 @@ +import { forwardRef } from 'react' +import type { + ComponentPropsWithoutRef, + ElementType, + ForwardRefExoticComponent, + RefAttributes, +} from 'react' + +import { cn } from './cn' +import { resolveVariants, stripVariantProps, type VariantConfig } from './variants' + +// Extract the trailing VariantConfig item (if any) from the args tuple. +type Last = T extends [...unknown[], infer L] ? L : never + +export type VariantProps = + Last extends { + variants: infer V extends Record> + } + ? { [K in keyof V]?: string } + : {} + +// The styled element accepts the variant props (each `?: string`), the native +// tag props, `as`, and a className. The index signature keeps unknown variant +// props pass-through while not blocking children/style/events. +export type StyledComponentOutput = ForwardRefExoticComponent< + { as?: ElementType; className?: string } & TProps & + ComponentPropsWithoutRef & { [key: string]: unknown } & RefAttributes // eslint-disable-line no-any/no-any -- boundary: React's tag-specific ref union is untyped here +> + +type OwnProps = { + as?: ElementType + className?: string + [key: string]: unknown +} + +// Factory: styled(tag, ...baseClasses, { variants, defaults }). +// Merge order (cn): base -> selected variants -> consumer className. +export function styled>( + Tag: TTag, + ...args: TArgs +): StyledComponentOutput> { + const base: string[] = [] + let config: VariantConfig | undefined + + for (const arg of args) { + if (typeof arg === 'string') { + base.push(arg) + } else { + config = arg + } + } + + const Styled = forwardRef(function Styled( + { as, className, ...rest }, + ref, + ) { + const Comp = (as ?? Tag) as ElementType + const variantClasses = resolveVariants(config, rest) + const domProps = stripVariantProps(config, { ...rest }) + delete domProps.as + delete domProps.className + + return ( + + ) + }) + Styled.displayName = typeof Tag === 'string' ? `Styled(${Tag})` : 'Styled(Component)' + + return Styled as unknown as StyledComponentOutput> +} + +// css(...): typed identity for class bundles and variant maps. The return type +// preserves the exact shape so it can feed `styled`'s VariantConfig trivially. +export function css(value: T): T { + return value +} + +const TAGS = [ + 'a', + 'button', + 'div', + 'fieldset', + 'form', + 'h1', + 'h2', + 'h3', + 'header', + 'img', + 'input', + 'label', + 'main', + 'nav', + 'p', + 'section', + 'select', + 'span', + 'svg', + 'table', + 'tbody', + 'td', + 'th', + 'thead', + 'tr', + 'ul', + 'li', + 'dt', + 'dd', +] as const + +type SupportedTag = (typeof TAGS)[number] + +type TagShorthand = { + [K in SupportedTag]: >( + ...args: TArgs + ) => StyledComponentOutput> +} + +// styled.button('base', {...}) — tag shorthand (spec §3.1). +export const styledWithShorthand = Object.assign( + styled, + Object.fromEntries(TAGS.map((tag) => [tag, (...args: never[]) => styled(tag, ...args)])) as never, +) as typeof styled & TagShorthand diff --git a/app/frontend/lib/styled/variants.ts b/app/frontend/lib/styled/variants.ts new file mode 100644 index 000000000..abe878843 --- /dev/null +++ b/app/frontend/lib/styled/variants.ts @@ -0,0 +1,33 @@ +// Minimal variant resolution (internal mini-CVA). Variant props are extracted +// so they never leak to the DOM; defaults fill unspecified props. Resolution +// order preserved: base -> selected variants -> consumer className (in cn). + +export type VariantMap = Record> + +export type VariantConfig = { + variants?: VariantMap + defaults?: Record +} + +export function resolveVariants( + config: VariantConfig | undefined, + props: Record, +): string[] { + if (!config?.variants) return [] + return Object.entries(config.variants).map(([name, choices]) => { + const prop = (props[name] ?? config.defaults?.[name]) as string | undefined + return prop ? (choices[prop] ?? '') : '' + }) +} + +// Variant placeholder keys are always stripped before spreading to the DOM. +export function stripVariantProps( + config: VariantConfig | undefined, + props: Record, +): Record { + if (!config?.variants) return props + for (const name of Object.keys(config.variants)) { + delete props[name] + } + return props +} diff --git a/app/frontend/molecules/ActionsRow/ActionsRow.styles.ts b/app/frontend/molecules/ActionsRow/ActionsRow.styles.ts new file mode 100644 index 000000000..47b929fbb --- /dev/null +++ b/app/frontend/molecules/ActionsRow/ActionsRow.styles.ts @@ -0,0 +1,3 @@ +import { styled } from '@/lib/styled' + +export const StyledActionsRow = styled.div('flex flex-wrap items-center gap-3') diff --git a/app/frontend/molecules/ActionsRow/ActionsRow.tsx b/app/frontend/molecules/ActionsRow/ActionsRow.tsx new file mode 100644 index 000000000..89843b92d --- /dev/null +++ b/app/frontend/molecules/ActionsRow/ActionsRow.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from 'react' + +import { StyledActionsRow } from './ActionsRow.styles' + +export type ActionsRowProps = { + children: ReactNode +} + +// Horizontal cluster for action buttons (edit/delete, submit/cancel). +export function ActionsRow({ children }: ActionsRowProps) { + return {children} +} diff --git a/app/frontend/molecules/Card/Card.styles.ts b/app/frontend/molecules/Card/Card.styles.ts new file mode 100644 index 000000000..84735f997 --- /dev/null +++ b/app/frontend/molecules/Card/Card.styles.ts @@ -0,0 +1,5 @@ +import { styled } from '@/lib/styled' + +export const StyledCard = styled.div( + 'space-y-4 rounded-md border border-line bg-paper p-5 shadow-card', +) diff --git a/app/frontend/molecules/Card/Card.tsx b/app/frontend/molecules/Card/Card.tsx new file mode 100644 index 000000000..7e037a64a --- /dev/null +++ b/app/frontend/molecules/Card/Card.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from 'react' + +import { StyledCard } from './Card.styles' + +export type CardProps = { + children: ReactNode +} + +// Bordered surface for content panels (details, profiles, inline sections). +export function Card({ children }: CardProps) { + return {children} +} diff --git a/app/frontend/molecules/DetailRow/DetailRow.styles.ts b/app/frontend/molecules/DetailRow/DetailRow.styles.ts new file mode 100644 index 000000000..89bf82cdf --- /dev/null +++ b/app/frontend/molecules/DetailRow/DetailRow.styles.ts @@ -0,0 +1,9 @@ +import { styled } from '@/lib/styled' + +export const StyledRow = styled.div( + 'grid grid-cols-1 items-start gap-1.5 border-t border-line-faint py-2 first:border-t-0 sm:grid-cols-[11rem_1fr] sm:gap-4', +) + +export const StyledDt = styled.dt('pt-0.5 text-sm font-medium text-ink-mute') + +export const StyledDd = styled.dd('flex items-center gap-2 text-sm text-ink') diff --git a/app/frontend/molecules/DetailRow/DetailRow.test.tsx b/app/frontend/molecules/DetailRow/DetailRow.test.tsx new file mode 100644 index 000000000..220e403ee --- /dev/null +++ b/app/frontend/molecules/DetailRow/DetailRow.test.tsx @@ -0,0 +1,12 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { DetailRow } from './DetailRow' + +describe('DetailRow', () => { + it('renders label and value', () => { + render(Ready) + expect(screen.getByText('Status')).toBeInTheDocument() + expect(screen.getByText('Ready')).toBeInTheDocument() + }) +}) diff --git a/app/frontend/molecules/DetailRow/DetailRow.tsx b/app/frontend/molecules/DetailRow/DetailRow.tsx new file mode 100644 index 000000000..a9feef516 --- /dev/null +++ b/app/frontend/molecules/DetailRow/DetailRow.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from 'react' + +import { StyledDd, StyledDt, StyledRow } from './DetailRow.styles' + +export type DetailRowProps = { + label: string + children: ReactNode +} + +// Label/value pair for presentational details (dl/div grid row). +export function DetailRow({ label, children }: DetailRowProps) { + return ( + + {label} + {children} + + ) +} diff --git a/app/frontend/molecules/Field/Field.styles.ts b/app/frontend/molecules/Field/Field.styles.ts new file mode 100644 index 000000000..ea0f44a18 --- /dev/null +++ b/app/frontend/molecules/Field/Field.styles.ts @@ -0,0 +1,7 @@ +import { styled } from '@/lib/styled' + +export const StyledField = styled.div('grid gap-1.5') + +export const StyledError = styled.p('text-sm text-danger') + +export const StyledHint = styled.p('text-xs text-ink-mute') diff --git a/app/frontend/molecules/Field/Field.test.tsx b/app/frontend/molecules/Field/Field.test.tsx new file mode 100644 index 000000000..e20527deb --- /dev/null +++ b/app/frontend/molecules/Field/Field.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { TextInput } from '@/atoms/TextInput/TextInput' + +import { Field } from './Field' + +describe('Field', () => { + it('wires label to the control', () => { + render( + + + , + ) + expect(screen.getByLabelText('Email address')).toBeInTheDocument() + }) + + it('shows the hint when no error', () => { + render( + + + , + ) + expect(screen.getByText('Provide a URL.')).toBeInTheDocument() + }) + + it('error wins over hint and is announced', () => { + render( + + + , + ) + expect(screen.getByRole('alert')).toHaveTextContent('Invalid URL.') + expect(screen.queryByText('Nope')).not.toBeInTheDocument() + }) +}) diff --git a/app/frontend/molecules/Field/Field.tsx b/app/frontend/molecules/Field/Field.tsx new file mode 100644 index 000000000..58fc41f0d --- /dev/null +++ b/app/frontend/molecules/Field/Field.tsx @@ -0,0 +1,19 @@ +import { Label } from '@/atoms/Label/Label' + +import { StyledError, StyledField, StyledHint } from './Field.styles' +import type { FieldProps } from './Field.types' + +// Label + control (atom) + error/hint wiring. Error takes precedence over hint. +export function Field({ label, htmlFor, hint, error, children }: FieldProps) { + return ( + + + {children} + {error ? ( + {error} + ) : hint ? ( + {hint} + ) : null} + + ) +} diff --git a/app/frontend/molecules/Field/Field.types.ts b/app/frontend/molecules/Field/Field.types.ts new file mode 100644 index 000000000..87bd4a582 --- /dev/null +++ b/app/frontend/molecules/Field/Field.types.ts @@ -0,0 +1,9 @@ +import type { ReactNode } from 'react' + +export type FieldProps = { + label: string + htmlFor?: string + hint?: string + error?: string + children: ReactNode +} diff --git a/app/frontend/molecules/FieldGroup/FieldGroup.styles.ts b/app/frontend/molecules/FieldGroup/FieldGroup.styles.ts new file mode 100644 index 000000000..d0c102b89 --- /dev/null +++ b/app/frontend/molecules/FieldGroup/FieldGroup.styles.ts @@ -0,0 +1,3 @@ +import { styled } from '@/lib/styled' + +export const StyledFieldGroup = styled.div('flex items-start gap-2') diff --git a/app/frontend/molecules/FieldGroup/FieldGroup.tsx b/app/frontend/molecules/FieldGroup/FieldGroup.tsx new file mode 100644 index 000000000..78c95f543 --- /dev/null +++ b/app/frontend/molecules/FieldGroup/FieldGroup.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from 'react' + +import { StyledFieldGroup } from './FieldGroup.styles' + +export type FieldGroupProps = { + children: ReactNode +} + +// Inline cluster for a labelled control plus a companion action (e.g. Go). +export function FieldGroup({ children }: FieldGroupProps) { + return {children} +} diff --git a/app/frontend/molecules/Form/Form.styles.ts b/app/frontend/molecules/Form/Form.styles.ts new file mode 100644 index 000000000..23439165f --- /dev/null +++ b/app/frontend/molecules/Form/Form.styles.ts @@ -0,0 +1,5 @@ +import { styled } from '@/lib/styled' + +export const StyledForm = styled.form('space-y-4') + +export const StyledFormActions = styled.div('flex items-center gap-3 pt-1') diff --git a/app/frontend/molecules/Form/Form.test.tsx b/app/frontend/molecules/Form/Form.test.tsx new file mode 100644 index 000000000..94f3a09ec --- /dev/null +++ b/app/frontend/molecules/Form/Form.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import type { FormEvent } from 'react' +import { describe, expect, it, vi } from 'vitest' + +import { Form } from './Form' + +describe('Form', () => { + it('submits on enter and passes the submit event', async () => { + const user = userEvent.setup() + const onSubmit = vi.fn((event: FormEvent) => { + event.preventDefault() + }) + render( +
+ +
, + ) + await user.type(screen.getByLabelText('Query'), 'ada{Enter}') + expect(onSubmit).toHaveBeenCalled() + }) +}) diff --git a/app/frontend/molecules/Form/Form.tsx b/app/frontend/molecules/Form/Form.tsx new file mode 100644 index 000000000..d61322958 --- /dev/null +++ b/app/frontend/molecules/Form/Form.tsx @@ -0,0 +1,13 @@ +import type { FormEvent, ReactNode } from 'react' + +import { StyledForm } from './Form.styles' + +export type FormProps = { + onSubmit: (event: FormEvent) => void + children: ReactNode +} + +// Themed
shell with consistent vertical rhythm for field stacks. +export function Form({ onSubmit, children }: FormProps) { + return {children} +} diff --git a/app/frontend/molecules/Form/FormActions.tsx b/app/frontend/molecules/Form/FormActions.tsx new file mode 100644 index 000000000..e47812a9b --- /dev/null +++ b/app/frontend/molecules/Form/FormActions.tsx @@ -0,0 +1,8 @@ +import type { ReactNode } from 'react' + +import { StyledFormActions } from './Form.styles' + +// Submit + cancel cluster at the end of a form. +export function FormActions({ children }: { children: ReactNode }) { + return {children} +} diff --git a/app/frontend/molecules/ImportProgressBar/ImportProgressBar.styles.ts b/app/frontend/molecules/ImportProgressBar/ImportProgressBar.styles.ts new file mode 100644 index 000000000..3eb5fb5fa --- /dev/null +++ b/app/frontend/molecules/ImportProgressBar/ImportProgressBar.styles.ts @@ -0,0 +1,5 @@ +import { styled } from '@/lib/styled' + +export const StyledFeedback = styled.span('text-sm font-medium text-ink-mute') + +export const StyledProgressWrap = styled.span('min-w-0 flex-1') diff --git a/app/frontend/molecules/ImportProgressBar/ImportProgressBar.tsx b/app/frontend/molecules/ImportProgressBar/ImportProgressBar.tsx new file mode 100644 index 000000000..34480cd50 --- /dev/null +++ b/app/frontend/molecules/ImportProgressBar/ImportProgressBar.tsx @@ -0,0 +1,92 @@ +import { observer } from 'mobx-react-lite' +import { useEffect } from 'react' + +import { ProgressBar } from '@/atoms/ProgressBar/ProgressBar' +import type { ProgressVariant } from '@/atoms/ProgressBar/ProgressBar.types' +import { useChannel } from '@/lib/cable' +import { useStore } from '@/stores' +import type { AdminImport, ImportStatus } from '@/types' +import { ImportStatusBadge } from '@/molecules/ImportStatusBadge/ImportStatusBadge' +import { DetailRow } from '@/molecules/DetailRow/DetailRow' +import type { ImportProgress } from '@/stores/imports-store' + +import { StyledFeedback, StyledProgressWrap } from './ImportProgressBar.styles' + +type ImportProgressBarProps = { + importId: number + initial: AdminImport +} + +// Presenter (AdminImport) -> store canonical shape. Done once here so the +// store never learns about the HTTP serialization (single shape in state). +function toProgress(userImport: AdminImport): ImportProgress { + return { + id: userImport.id, + status: userImport.status, + total: userImport.total_rows, + processed: userImport.processed_rows, + failed: userImport.failed_rows, + percent: userImport.progress, + } +} + +function feedbackFor(status: ImportStatus): string | null { + switch (status) { + case 'completed': + return 'Import finished.' + case 'failed': + return 'Import finished with errors.' + default: + return null + } +} + +function fillVariantFor(status: ImportStatus): ProgressVariant { + if (status === 'failed') return 'failed' + if (status === 'completed') return 'completed' + return 'default' +} + +// Live import progress: hydrated from the initial props (fallback = same state +// as the render) and updated by the import_progress stream. Reactivity is +// pure MobX (observer + actions) — the websocket only feeds store.ingest. +export const ImportProgressBar = observer(function ImportProgressBar({ + importId, + initial, +}: ImportProgressBarProps) { + const { imports } = useStore() + + useEffect(() => { + imports.hydrate(importId, toProgress(initial)) + }, [importId, imports, initial]) + + useChannel('ImportProgressChannel', { import_id: importId }, (payload) => { + imports.ingest(payload) + }) + + const live = imports.progressOf(importId) + const display = live ?? toProgress(initial) + const feedback = feedbackFor(display.status) + + return ( + <> + + + {feedback ? {feedback} : null} + + + + + + {display.percent}% + + + {display.processed} processed · {display.failed} failed · {display.total} total + + + ) +}) diff --git a/app/frontend/molecules/ImportStatusBadge/ImportStatusBadge.test.tsx b/app/frontend/molecules/ImportStatusBadge/ImportStatusBadge.test.tsx new file mode 100644 index 000000000..b1a69d57d --- /dev/null +++ b/app/frontend/molecules/ImportStatusBadge/ImportStatusBadge.test.tsx @@ -0,0 +1,16 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { ImportStatusBadge } from './ImportStatusBadge' + +describe('ImportStatusBadge', () => { + it.each(['pending', 'processing', 'completed', 'failed'] as const)( + 'maps %s to a human label + variant', + (status) => { + const { container } = render() + const badge = container.querySelector('[data-status]') + expect(badge).toHaveAttribute('data-status', status) + expect(screen.getByText(status.charAt(0).toUpperCase() + status.slice(1))).toBeInTheDocument() + }, + ) +}) diff --git a/app/frontend/molecules/ImportStatusBadge/ImportStatusBadge.tsx b/app/frontend/molecules/ImportStatusBadge/ImportStatusBadge.tsx new file mode 100644 index 000000000..b9dbabe45 --- /dev/null +++ b/app/frontend/molecules/ImportStatusBadge/ImportStatusBadge.tsx @@ -0,0 +1,30 @@ +import { Badge } from '@/atoms/Badge/Badge' +import type { BadgeVariant } from '@/atoms/Badge/Badge.types' +import type { ImportStatus } from '@/types' + +export type ImportStatusBadgeProps = { + status: ImportStatus +} + +const VARIANT: Record = { + pending: 'pending', + processing: 'processing', + completed: 'completed', + failed: 'failed', +} + +const LABEL: Record = { + pending: 'Pending', + processing: 'Processing', + completed: 'Completed', + failed: 'Failed', +} + +// Humanized import state chip. data-status stays on the DOM for tests/selectors. +export function ImportStatusBadge({ status }: ImportStatusBadgeProps) { + return ( + + {LABEL[status]} + + ) +} diff --git a/app/frontend/molecules/PageHeader/PageHeader.styles.ts b/app/frontend/molecules/PageHeader/PageHeader.styles.ts new file mode 100644 index 000000000..6dab88f35 --- /dev/null +++ b/app/frontend/molecules/PageHeader/PageHeader.styles.ts @@ -0,0 +1,5 @@ +import { styled } from '@/lib/styled' + +export const StyledWrap = styled.div('flex flex-wrap items-center justify-between gap-4') + +export const StyledDescription = styled.p('mt-1 text-sm text-ink-mute') diff --git a/app/frontend/molecules/PageHeader/PageHeader.tsx b/app/frontend/molecules/PageHeader/PageHeader.tsx new file mode 100644 index 000000000..9b869318f --- /dev/null +++ b/app/frontend/molecules/PageHeader/PageHeader.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from 'react' + +import { Heading } from '@/atoms/Heading/Heading' +import type { HeadingSize } from '@/atoms/Heading/Heading.types' + +import { StyledDescription, StyledWrap } from './PageHeader.styles' + +export type PageHeaderProps = { + title: string + description?: string + action?: ReactNode + size?: HeadingSize +} + +// Title + optional subtitle and trailing action, shared by every content page. +export function PageHeader({ title, description, action, size = 'lg' }: PageHeaderProps) { + return ( + +
+ {title} + {description ? {description} : null} +
+ {action ?
{action}
: null} +
+ ) +} diff --git a/app/frontend/molecules/ProfileHeader/ProfileHeader.styles.ts b/app/frontend/molecules/ProfileHeader/ProfileHeader.styles.ts new file mode 100644 index 000000000..a39964b14 --- /dev/null +++ b/app/frontend/molecules/ProfileHeader/ProfileHeader.styles.ts @@ -0,0 +1,5 @@ +import { styled } from '@/lib/styled' + +export const StyledWrap = styled.div('flex flex-col items-center gap-1 text-center') + +export const StyledEmail = styled.p('text-sm text-ink-mute') diff --git a/app/frontend/molecules/ProfileHeader/ProfileHeader.tsx b/app/frontend/molecules/ProfileHeader/ProfileHeader.tsx new file mode 100644 index 000000000..b504ce847 --- /dev/null +++ b/app/frontend/molecules/ProfileHeader/ProfileHeader.tsx @@ -0,0 +1,21 @@ +import { Avatar } from '@/atoms/Avatar/Avatar' +import { Heading } from '@/atoms/Heading/Heading' + +import { StyledEmail, StyledWrap } from './ProfileHeader.styles' + +export type ProfileHeaderProps = { + src: string | null | undefined + name: string + email?: string +} + +// Centered avatar + identity used at the top of the profile page. +export function ProfileHeader({ src, name, email }: ProfileHeaderProps) { + return ( + + + {name} + {email ? {email} : null} + + ) +} diff --git a/app/frontend/molecules/RoleBadge/RoleBadge.test.tsx b/app/frontend/molecules/RoleBadge/RoleBadge.test.tsx new file mode 100644 index 000000000..9aa72d713 --- /dev/null +++ b/app/frontend/molecules/RoleBadge/RoleBadge.test.tsx @@ -0,0 +1,16 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { RoleBadge } from './RoleBadge' + +describe('RoleBadge', () => { + it('maps admin to the brand variant', () => { + render() + expect(screen.getByText('admin')).toHaveClass('bg-brand-soft') + }) + + it('maps user to the neutral variant', () => { + render() + expect(screen.getByText('user')).toHaveClass('bg-line-soft') + }) +}) diff --git a/app/frontend/molecules/RoleBadge/RoleBadge.tsx b/app/frontend/molecules/RoleBadge/RoleBadge.tsx new file mode 100644 index 000000000..70144d038 --- /dev/null +++ b/app/frontend/molecules/RoleBadge/RoleBadge.tsx @@ -0,0 +1,12 @@ +import { Badge } from '@/atoms/Badge/Badge' +import type { BadgeVariant } from '@/atoms/Badge/Badge.types' + +export type RoleBadgeProps = { + role: 'user' | 'admin' +} + +// Role chip that reuses the badge style language (admin = brand tint). +export function RoleBadge({ role }: RoleBadgeProps) { + const variant: BadgeVariant = role === 'admin' ? 'admin' : 'user' + return {role} +} diff --git a/app/frontend/molecules/SearchBar/SearchBar.styles.ts b/app/frontend/molecules/SearchBar/SearchBar.styles.ts new file mode 100644 index 000000000..48628970b --- /dev/null +++ b/app/frontend/molecules/SearchBar/SearchBar.styles.ts @@ -0,0 +1,3 @@ +import { styled } from '@/lib/styled' + +export const StyledSearchForm = styled.form('flex items-center gap-2') diff --git a/app/frontend/molecules/SearchBar/SearchBar.test.tsx b/app/frontend/molecules/SearchBar/SearchBar.test.tsx new file mode 100644 index 000000000..99cea8a79 --- /dev/null +++ b/app/frontend/molecules/SearchBar/SearchBar.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' +import { describe, expect, it, vi } from 'vitest' + +import { SearchBar } from './SearchBar' + +describe('SearchBar', () => { + it('drives onChange and submits', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + const onSubmit = vi.fn() + + function Fixture() { + const [value, setValue] = useState('') + return ( + { + onChange(next) + setValue(next) + }} + onSubmit={onSubmit} + placeholder="Search people" + /> + ) + } + + render() + const input = screen.getByRole('searchbox') + await user.type(input, 'ada') + expect(onChange).toHaveBeenLastCalledWith('ada') + await user.click(screen.getByRole('button', { name: /search/i })) + expect(onSubmit).toHaveBeenCalledTimes(1) + }) +}) diff --git a/app/frontend/molecules/SearchBar/SearchBar.tsx b/app/frontend/molecules/SearchBar/SearchBar.tsx new file mode 100644 index 000000000..1ae8a8edc --- /dev/null +++ b/app/frontend/molecules/SearchBar/SearchBar.tsx @@ -0,0 +1,45 @@ +import { Button } from '@/atoms/Button/Button' +import { Icon } from '@/atoms/Icon/Icon' +import { TextInput } from '@/atoms/TextInput/TextInput' + +import { StyledSearchForm } from './SearchBar.styles' + +export type SearchBarProps = { + value: string + onChange: (value: string) => void + onSubmit: () => void + placeholder?: string + submitLabel?: string +} + +// Query input + submit action wired to a caller-provided search function. +export function SearchBar({ + value, + onChange, + onSubmit, + placeholder = 'Search', + submitLabel = 'Search', +}: SearchBarProps) { + return ( + { + event.preventDefault() + onSubmit() + }} + > + onChange(event.target.value)} + /> + + + ) +} diff --git a/app/frontend/organisms/ConfirmDialog/ConfirmDialog.styles.ts b/app/frontend/organisms/ConfirmDialog/ConfirmDialog.styles.ts new file mode 100644 index 000000000..712464169 --- /dev/null +++ b/app/frontend/organisms/ConfirmDialog/ConfirmDialog.styles.ts @@ -0,0 +1,11 @@ +import { styled } from '@/lib/styled' + +export const StyledBackdrop = styled.div('fixed inset-0 z-50 grid place-items-center bg-scrim p-4') + +export const StyledPanel = styled.div( + 'w-full max-w-sm max-h-[85vh] overflow-y-auto rounded-lg border border-line bg-paper p-5 shadow-pop', +) + +export const StyledDescription = styled.p('mt-2 text-sm text-ink-mute') + +export const StyledActions = styled.div('mt-5 flex flex-wrap justify-end gap-2') diff --git a/app/frontend/organisms/ConfirmDialog/ConfirmDialog.test.tsx b/app/frontend/organisms/ConfirmDialog/ConfirmDialog.test.tsx new file mode 100644 index 000000000..bbf47d1f9 --- /dev/null +++ b/app/frontend/organisms/ConfirmDialog/ConfirmDialog.test.tsx @@ -0,0 +1,40 @@ +import userEvent from '@testing-library/user-event' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import { ConfirmDialog } from './ConfirmDialog' + +const base = { + title: 'Delete user', + description: 'This cannot be undone.', + onConfirm: vi.fn(), + onCancel: vi.fn(), +} + +describe('ConfirmDialog', () => { + it('renders nothing when closed', () => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('renders a modal dialog with actions', () => { + render() + const dialog = screen.getByRole('dialog', { name: 'Delete user' }) + expect(dialog).toHaveAttribute('aria-modal', 'true') + expect(screen.getAllByRole('button', { name: 'Delete' }).length).toBeGreaterThan(0) + }) + + it('confirms on the danger button', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Delete' })) + expect(base.onConfirm).toHaveBeenCalledTimes(1) + }) + + it('cancels on Escape', async () => { + const user = userEvent.setup() + render() + await user.keyboard('{Escape}') + expect(base.onCancel).toHaveBeenCalledTimes(1) + }) +}) diff --git a/app/frontend/organisms/ConfirmDialog/ConfirmDialog.tsx b/app/frontend/organisms/ConfirmDialog/ConfirmDialog.tsx new file mode 100644 index 000000000..8d8e69323 --- /dev/null +++ b/app/frontend/organisms/ConfirmDialog/ConfirmDialog.tsx @@ -0,0 +1,117 @@ +import { useEffect, useRef } from 'react' +import { createPortal } from 'react-dom' + +import { Button } from '@/atoms/Button/Button' +import { Heading } from '@/atoms/Heading/Heading' + +import { + StyledActions, + StyledBackdrop, + StyledDescription, + StyledPanel, +} from './ConfirmDialog.styles' + +export type ConfirmDialogProps = { + open: boolean + title: string + description?: string + confirmLabel?: string + cancelLabel?: string + onConfirm: () => void + onCancel: () => void +} + +const FOCUSABLE = + 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])' +const TITLE_ID = 'confirm-dialog-title' + +// Confirmation modal (portal + overlay) with a Tab/Shift+Tab focus trap and +// Escape-to-cancel. Focus returns to the opener on close. +export function ConfirmDialog({ + open, + title, + description, + confirmLabel = 'Delete', + cancelLabel = 'Cancel', + onConfirm, + onCancel, +}: ConfirmDialogProps) { + const panelRef = useRef(null) + const onConfirmRef = useRef(onConfirm) + const onCancelRef = useRef(onCancel) + + // Keep the latest callbacks fresh without re-running the focus/keyboard + // effect below (react-hooks/refs). + useEffect(() => { + onConfirmRef.current = onConfirm + onCancelRef.current = onCancel + }) + + useEffect(() => { + if (!open) return + + const previousFocus = document.activeElement as HTMLElement | null + + function focusables() { + return Array.from(panelRef.current?.querySelectorAll(FOCUSABLE) ?? []).filter( + (el) => el.offsetParent !== null || el === document.activeElement, + ) + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + event.preventDefault() + onCancelRef.current() + return + } + if (event.key !== 'Tab') return + const items = focusables() + if (items.length === 0) { + event.preventDefault() + return + } + const first = items[0] + const last = items[items.length - 1] + if (event.shiftKey && document.activeElement === first) { + event.preventDefault() + last.focus() + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault() + first.focus() + } + } + + focusables()[0]?.focus() + document.addEventListener('keydown', handleKeyDown) + const previousOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + + return () => { + document.removeEventListener('keydown', handleKeyDown) + document.body.style.overflow = previousOverflow + previousFocus?.focus() + } + }, [open]) + + if (!open) return null + + return createPortal( + + + + {title} + + {description ? {description} : null} + + + + + + , + document.body, + ) +} diff --git a/app/frontend/organisms/DashboardStats/DashboardStats.styles.ts b/app/frontend/organisms/DashboardStats/DashboardStats.styles.ts new file mode 100644 index 000000000..7c5fab4f0 --- /dev/null +++ b/app/frontend/organisms/DashboardStats/DashboardStats.styles.ts @@ -0,0 +1,3 @@ +import { styled } from '@/lib/styled' + +export const StyledGrid = styled.div('grid grid-cols-2 gap-4 sm:grid-cols-3') diff --git a/app/frontend/organisms/DashboardStats/DashboardStats.tsx b/app/frontend/organisms/DashboardStats/DashboardStats.tsx new file mode 100644 index 000000000..650da94ea --- /dev/null +++ b/app/frontend/organisms/DashboardStats/DashboardStats.tsx @@ -0,0 +1,37 @@ +import { observer } from 'mobx-react-lite' +import { useEffect } from 'react' + +import { useChannel } from '@/lib/cable' +import { useStore } from '@/stores' +import type { UsersOverview } from '@/types' + +import { StatCard } from '@/organisms/StatCard/StatCard' +import { StyledGrid } from './DashboardStats.styles' + +export type DashboardStatsProps = { + overview?: UsersOverview +} + +// Real-time dashboard totals: hydrated from the initial Inertia props (same +// Users::Overview serialized in Phase 3 — DRY fallback) and kept live by the +// dashboard_stats stream. Receive -> store.ingest is the only realtime entry. +export const DashboardStats = observer(function DashboardStats({ overview }: DashboardStatsProps) { + const { dashboard } = useStore() + + useEffect(() => { + dashboard.hydrate(overview) + }, [dashboard, overview]) + + useChannel('DashboardChannel', {}, (payload) => { + dashboard.ingest(payload) + }) + + return ( + + + {Object.entries(dashboard.by_role).map(([role, count]) => ( + + ))} + + ) +}) diff --git a/app/frontend/organisms/FlashMessages/FlashMessages.styles.ts b/app/frontend/organisms/FlashMessages/FlashMessages.styles.ts new file mode 100644 index 000000000..0d670e9bd --- /dev/null +++ b/app/frontend/organisms/FlashMessages/FlashMessages.styles.ts @@ -0,0 +1,3 @@ +import { styled } from '@/lib/styled' + +export const StyledWrap = styled.div('space-y-2') diff --git a/app/frontend/organisms/FlashMessages/FlashMessages.test.tsx b/app/frontend/organisms/FlashMessages/FlashMessages.test.tsx new file mode 100644 index 000000000..b612c5bd7 --- /dev/null +++ b/app/frontend/organisms/FlashMessages/FlashMessages.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from '@testing-library/react' +import { runInAction } from 'mobx' +import { describe, expect, it } from 'vitest' + +import type { FlashData } from '@/types' + +import { FlashMessages } from './FlashMessages' +import { RootStoreProvider, useStore } from '@/stores' + +function setup(flash: FlashData) { + function Probe() { + const { flashStore } = { flashStore: useStore().flash } + runInAction(() => { + flashStore.notice = flash.notice + flashStore.alert = flash.alert + }) + return + } + return render( + + + , + ) +} + +describe('FlashMessages', () => { + it('renders nothing without a message', () => { + const { container } = setup({}) + expect(container).toBeEmptyDOMElement() + }) + + it('renders the notice as a success alert', () => { + setup({ notice: 'Saved!' }) + expect(screen.getByText('Saved!')).toBeInTheDocument() + }) + + it('renders the alert as a danger alert', () => { + setup({ alert: 'Access denied' }) + expect(screen.getByRole('alert')).toHaveTextContent('Access denied') + }) +}) diff --git a/app/frontend/organisms/FlashMessages/FlashMessages.tsx b/app/frontend/organisms/FlashMessages/FlashMessages.tsx new file mode 100644 index 000000000..8e3d686bf --- /dev/null +++ b/app/frontend/organisms/FlashMessages/FlashMessages.tsx @@ -0,0 +1,20 @@ +import { observer } from 'mobx-react-lite' + +import { Alert } from '@/atoms/Alert/Alert' +import { useStore } from '@/stores' + +import { StyledWrap } from './FlashMessages.styles' + +// One-off user feedback rendered from the session flash. Observer reads the +// FlashStore (hydrated on every navigation by the App bridge). +export const FlashMessages = observer(function FlashMessages() { + const { flash } = useStore() + if (!flash.hasMessage) return null + + return ( + + {flash.notice ? {flash.notice} : null} + {flash.alert ? {flash.alert} : null} + + ) +}) diff --git a/app/frontend/organisms/FormErrorAlert/FormErrorAlert.test.tsx b/app/frontend/organisms/FormErrorAlert/FormErrorAlert.test.tsx new file mode 100644 index 000000000..51a544ed4 --- /dev/null +++ b/app/frontend/organisms/FormErrorAlert/FormErrorAlert.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { FormErrorAlert } from './FormErrorAlert' + +describe('FormErrorAlert', () => { + it('renders nothing without errors', () => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('shows the base error as a danger alert', () => { + render() + const alert = screen.getByRole('alert') + expect(alert).toHaveTextContent('Credentials are invalid.') + }) + + it('falls back to the form key', () => { + render() + expect(screen.getByRole('alert')).toHaveTextContent('File rejected.') + }) +}) diff --git a/app/frontend/organisms/FormErrorAlert/FormErrorAlert.tsx b/app/frontend/organisms/FormErrorAlert/FormErrorAlert.tsx new file mode 100644 index 000000000..19b0c5279 --- /dev/null +++ b/app/frontend/organisms/FormErrorAlert/FormErrorAlert.tsx @@ -0,0 +1,17 @@ +import { Alert } from '@/atoms/Alert/Alert' + +export type FormErrorAlertProps = { + errors?: Record +} + +// Renders the form-level ("base"/"form") error banner, nothing otherwise. +export function FormErrorAlert({ errors }: FormErrorAlertProps) { + const base = errors?.base ?? errors?.form + if (!base || base.length === 0) return null + + return ( + + {base.join(' ')} + + ) +} diff --git a/app/frontend/organisms/ImportsTable/ImportsTable.styles.ts b/app/frontend/organisms/ImportsTable/ImportsTable.styles.ts new file mode 100644 index 000000000..e8d17082f --- /dev/null +++ b/app/frontend/organisms/ImportsTable/ImportsTable.styles.ts @@ -0,0 +1,21 @@ +import { styled } from '@/lib/styled' + +// Scroll container so wide tables degrade to horizontal scroll on phones +// instead of crushing the columns. Mobile Safari gets momentum scrolling. +export const StyledScroll = styled.div( + 'overflow-x-auto rounded-md border border-line shadow-card [-webkit-overflow-scrolling:touch]', +) + +export const StyledTable = styled.table('w-full min-w-[38rem] border-collapse bg-paper text-sm') + +export const StyledHead = styled.thead('bg-line-soft-strong') + +export const StyledRow = styled.tr( + 'border-b border-line transition-colors last:border-b-0 hover:bg-line-soft-tint', +) + +export const StyledHeadCell = styled.th( + 'px-4 py-2 text-left text-xs font-semibold uppercase tracking-wide text-ink-mute', +) + +export const StyledCell = styled.td('px-4 py-3 align-middle') diff --git a/app/frontend/organisms/ImportsTable/ImportsTable.tsx b/app/frontend/organisms/ImportsTable/ImportsTable.tsx new file mode 100644 index 000000000..eb1b01edc --- /dev/null +++ b/app/frontend/organisms/ImportsTable/ImportsTable.tsx @@ -0,0 +1,66 @@ +import { ButtonLink } from '@/atoms/Button/ButtonLink' +import { EmptyState } from '@/atoms/EmptyState/EmptyState' +import { ImportStatusBadge } from '@/molecules/ImportStatusBadge/ImportStatusBadge' +import type { AdminImportListItem } from '@/types' + +import { + StyledCell, + StyledHead, + StyledHeadCell, + StyledRow, + StyledScroll, + StyledTable, +} from './ImportsTable.styles' + +export type ImportsTableProps = { + imports: AdminImportListItem[] + showCreated?: boolean +} + +// Import listings for both the recent-imports summary and the full index. +export function ImportsTable({ imports, showCreated = false }: ImportsTableProps) { + if (imports.length === 0) { + return ( + + ) + } + + return ( + + + + + File + Status + Rows + Progress + {showCreated ? Created : null} + + + + {imports.map((item) => ( + + + + {item.file_name ?? `Import #${item.id}`} + + + + + + + {item.processed_rows}/{item.total_rows} + + {item.progress}% + {showCreated ? {item.created_at} : null} + + ))} + + + + ) +} diff --git a/app/frontend/organisms/Navbar/Navbar.styles.ts b/app/frontend/organisms/Navbar/Navbar.styles.ts new file mode 100644 index 000000000..761f689c0 --- /dev/null +++ b/app/frontend/organisms/Navbar/Navbar.styles.ts @@ -0,0 +1,49 @@ +import { Link } from '@inertiajs/react' +import type { InertiaLinkProps } from '@inertiajs/react' +import type { ForwardRefExoticComponent, RefAttributes } from 'react' + +import { Button } from '@/atoms/Button/Button' +import { styled } from '@/lib/styled' + +export const StyledHeader = styled.header( + 'sticky top-0 z-10 flex items-center gap-4 border-b border-line bg-paper px-4 py-3 shadow-sm sm:gap-6 sm:px-6', +) + +export const StyledBrand = styled.span( + 'min-w-0 truncate text-sm font-bold uppercase tracking-wider text-ink', +) + +// Inline links: hidden below `sm`, where the hamburger menu takes over. +export const StyledNav = styled.nav('hidden items-center gap-5 sm:flex') + +// SPA link via Inertia (never a plain anchor) with header-nav styling. +export type StyledNavLinkProps = InertiaLinkProps & { className?: string } + +export const StyledNavLink = styled( + Link, + 'shrink-0 text-sm font-medium text-ink-soft transition-colors hover:text-ink', +) as ForwardRefExoticComponent> + +export const StyledSpacer = styled.span('min-w-0 flex-1') + +// Desktop-only sign-out; mobile gets the action inside the dropdown panel. +export const StyledSignOut = styled.span('hidden sm:block') + +export const StyledMenuButton = styled.button( + 'inline-flex items-center justify-center rounded-md border border-line bg-paper p-2 text-ink transition-colors hover:bg-line-soft focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-ring sm:hidden', +) + +export const StyledMobilePanel = styled.div( + 'absolute inset-x-0 top-full z-10 border-b border-line bg-paper px-4 pb-3 pt-2 shadow-pop sm:hidden', +) + +export const StyledMobileLink = styled( + Link, + 'block rounded-md px-3 py-2.5 text-sm font-medium text-ink-soft transition-colors hover:bg-line-soft hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-ring', +) as ForwardRefExoticComponent> + +export const StyledMobileSignOut = styled.div('mt-2 border-t border-line pt-2') + +// Full-width sign-out for the mobile dropdown (style rule: utility classes +// stay in *.styles.ts). +export const StyledMobileSignOutButton = styled(Button, 'w-full') diff --git a/app/frontend/organisms/Navbar/Navbar.tsx b/app/frontend/organisms/Navbar/Navbar.tsx new file mode 100644 index 000000000..1dabd635c --- /dev/null +++ b/app/frontend/organisms/Navbar/Navbar.tsx @@ -0,0 +1,112 @@ +import { router } from '@inertiajs/react' +import { useEffect, useRef, useState } from 'react' + +import { Button } from '@/atoms/Button/Button' +import { Icon } from '@/atoms/Icon/Icon' + +import { + StyledBrand, + StyledHeader, + StyledMenuButton, + StyledMobileLink, + StyledMobilePanel, + StyledMobileSignOut, + StyledMobileSignOutButton, + StyledNav, + StyledNavLink, + StyledSignOut, + StyledSpacer, +} from './Navbar.styles' + +export type NavLink = { + label: string + href: string +} + +export type NavbarProps = { + title: string + links: NavLink[] + signOutUrl?: string +} + +// Top navigation bar for authenticated layouts. Links use Inertia +// (SPA); sign out issues a DELETE via the shared navigation lifecycle. Below +// the `sm` breakpoint the links collapse into a hamburger dropdown panel. +export function Navbar({ title, links, signOutUrl = '/session' }: NavbarProps) { + const [menuOpen, setMenuOpen] = useState(false) + const headerRef = useRef(null) + const closeMenu = () => setMenuOpen(false) + + useEffect(() => { + if (!menuOpen) return + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') setMenuOpen(false) + } + + function handlePointerDown(event: PointerEvent) { + if (!headerRef.current?.contains(event.target as Node)) setMenuOpen(false) + } + + document.addEventListener('keydown', handleKeyDown) + document.addEventListener('pointerdown', handlePointerDown, true) + return () => { + document.removeEventListener('keydown', handleKeyDown) + document.removeEventListener('pointerdown', handlePointerDown, true) + } + }, [menuOpen]) + + return ( + + {title} + + {links.map((link) => ( + + {link.label} + + ))} + + + + + + setMenuOpen((open) => !open)} + > + + + {menuOpen ? ( + + {links.map((link) => ( + + {link.label} + + ))} + + router.delete(signOutUrl)} + > + + Sign out + + + + ) : null} + + ) +} diff --git a/app/frontend/organisms/Pagination/Pagination.styles.ts b/app/frontend/organisms/Pagination/Pagination.styles.ts new file mode 100644 index 000000000..620af9cf8 --- /dev/null +++ b/app/frontend/organisms/Pagination/Pagination.styles.ts @@ -0,0 +1,7 @@ +import { styled } from '@/lib/styled' + +export const StyledNavigation = styled.nav( + 'flex flex-wrap items-center justify-center gap-3 sm:justify-between sm:gap-4', +) + +export const StyledSummary = styled.span('text-sm text-ink-mute') diff --git a/app/frontend/organisms/Pagination/Pagination.tsx b/app/frontend/organisms/Pagination/Pagination.tsx new file mode 100644 index 000000000..2c92e168c --- /dev/null +++ b/app/frontend/organisms/Pagination/Pagination.tsx @@ -0,0 +1,36 @@ +import { Button } from '@/atoms/Button/Button' + +import { StyledNavigation, StyledSummary } from './Pagination.styles' + +export type PaginationProps = { + page: number + pages: number + count: number + countLabel: string + onPrevious: () => void + onNext: () => void +} + +// Previous / summary / Next cluster for server-driven listing pages. +export function Pagination({ + page, + pages, + count, + countLabel, + onPrevious, + onNext, +}: PaginationProps) { + return ( + + + + Page {page} of {pages} ({count} {countLabel}) + + + + ) +} diff --git a/app/frontend/organisms/StatCard/StatCard.styles.ts b/app/frontend/organisms/StatCard/StatCard.styles.ts new file mode 100644 index 000000000..c6d95e028 --- /dev/null +++ b/app/frontend/organisms/StatCard/StatCard.styles.ts @@ -0,0 +1,7 @@ +import { styled } from '@/lib/styled' + +export const StyledCard = styled.div('rounded-md border border-line bg-paper p-4 shadow-card') + +export const StyledLabel = styled.p('text-xs font-semibold uppercase tracking-wide text-ink-mute') + +export const StyledValue = styled.p('mt-1 text-2xl font-bold text-ink') diff --git a/app/frontend/organisms/StatCard/StatCard.test.tsx b/app/frontend/organisms/StatCard/StatCard.test.tsx new file mode 100644 index 000000000..392153c24 --- /dev/null +++ b/app/frontend/organisms/StatCard/StatCard.test.tsx @@ -0,0 +1,12 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { StatCard } from './StatCard' + +describe('StatCard', () => { + it('renders the label and count', () => { + render() + expect(screen.getByText('Total users')).toBeInTheDocument() + expect(screen.getByText('42')).toBeInTheDocument() + }) +}) diff --git a/app/frontend/organisms/StatCard/StatCard.tsx b/app/frontend/organisms/StatCard/StatCard.tsx new file mode 100644 index 000000000..4ffbc956f --- /dev/null +++ b/app/frontend/organisms/StatCard/StatCard.tsx @@ -0,0 +1,16 @@ +import { StyledCard, StyledLabel, StyledValue } from './StatCard.styles' + +export type StatCardProps = { + label: string + value: number +} + +// Numeric summary tile for the dashboard overview. +export function StatCard({ label, value }: StatCardProps) { + return ( + + {label} + {value} + + ) +} diff --git a/app/frontend/organisms/UserForm/UserForm.tsx b/app/frontend/organisms/UserForm/UserForm.tsx new file mode 100644 index 000000000..05c40bc80 --- /dev/null +++ b/app/frontend/organisms/UserForm/UserForm.tsx @@ -0,0 +1,152 @@ +import { useForm } from '@inertiajs/react' +import type { FormEvent } from 'react' + +import { Avatar } from '@/atoms/Avatar/Avatar' +import { Button } from '@/atoms/Button/Button' +import { ButtonLink } from '@/atoms/Button/ButtonLink' +import { Select } from '@/atoms/Select/Select' +import { TextInput } from '@/atoms/TextInput/TextInput' +import { Field } from '@/molecules/Field/Field' +import { Form } from '@/molecules/Form/Form' + +import { FormActions } from '@/molecules/Form/FormActions' +import { FormErrorAlert } from '@/organisms/FormErrorAlert/FormErrorAlert' +import type { UserFormData } from '@/types' + +export type UserFormProps = { + isEdit: boolean + submitLabel: string + avatarSource?: string + urls: { submit_url: string; cancel_url: string } + form?: UserFormData +} + +// Admin create/edit user form (SPA put/post with multipart for the avatar). +export function UserForm({ isEdit, submitLabel, avatarSource, urls, form }: UserFormProps) { + const { data, setData, post, put, processing, errors } = useForm({ + full_name: form?.full_name ?? '', + email_address: form?.email_address ?? '', + avatar_url: form?.avatar_url ?? '', + role: form?.role ?? 'user', + password: '', + password_confirmation: '', + avatar: null as File | null, + }) + + function handleSubmit(event: FormEvent) { + event.preventDefault() + const options = { forceFormData: true } + if (isEdit) { + put(urls.submit_url, options) + } else { + post(urls.submit_url, options) + } + } + + return ( + + {avatarSource ? ( + + ) : null} + + setData('full_name', event.target.value)} + required + /> + + + setData('email_address', event.target.value)} + required + /> + + + setData('avatar', event.target.files?.[0] ?? null)} + /> + + + setData('avatar_url', event.target.value)} + /> + + + + + + setData('password', event.target.value)} + minLength={8} + maxLength={72} + required={!isEdit} + /> + + + setData('password_confirmation', event.target.value)} + maxLength={72} + required={!isEdit} + /> + + } /> + + + + Cancel + + + + ) +} diff --git a/app/frontend/organisms/UserTable/UserTable.styles.ts b/app/frontend/organisms/UserTable/UserTable.styles.ts new file mode 100644 index 000000000..f2b2527bb --- /dev/null +++ b/app/frontend/organisms/UserTable/UserTable.styles.ts @@ -0,0 +1,27 @@ +import { styled } from '@/lib/styled' + +// Scroll container so wide tables degrade to horizontal scroll on phones +// instead of crushing the columns. Mobile Safari gets momentum scrolling. +export const StyledScroll = styled.div( + 'overflow-x-auto rounded-md border border-line shadow-card [-webkit-overflow-scrolling:touch]', +) + +export const StyledTable = styled.table('w-full min-w-[42rem] border-collapse bg-paper text-sm') + +export const StyledHead = styled.thead('bg-line-soft-strong') + +export const StyledRow = styled.tr( + 'border-b border-line transition-colors last:border-b-0 hover:bg-line-soft-tint', +) + +export const StyledHeadCell = styled.th( + 'px-4 py-2 text-left text-xs font-semibold uppercase tracking-wide text-ink-mute', +) + +export const StyledCell = styled.td('px-4 py-3 align-middle') + +export const StyledUserCell = styled.span('flex items-center gap-2 font-medium text-ink') + +export const StyledActions = styled.td('px-4 py-3 whitespace-nowrap') + +export const StyledActionsWrap = styled.span('flex items-center justify-end gap-2') diff --git a/app/frontend/organisms/UserTable/UserTable.tsx b/app/frontend/organisms/UserTable/UserTable.tsx new file mode 100644 index 000000000..7e2fa2716 --- /dev/null +++ b/app/frontend/organisms/UserTable/UserTable.tsx @@ -0,0 +1,109 @@ +import { router } from '@inertiajs/react' +import { useState } from 'react' + +import { Avatar } from '@/atoms/Avatar/Avatar' +import { Button } from '@/atoms/Button/Button' +import { ButtonLink } from '@/atoms/Button/ButtonLink' +import { EmptyState } from '@/atoms/EmptyState/EmptyState' +import { RoleBadge } from '@/molecules/RoleBadge/RoleBadge' +import type { AdminUser } from '@/types' + +import { ConfirmDialog } from '@/organisms/ConfirmDialog/ConfirmDialog' +import { + StyledActions, + StyledActionsWrap, + StyledCell, + StyledHead, + StyledHeadCell, + StyledRow, + StyledScroll, + StyledTable, + StyledUserCell, +} from './UserTable.styles' + +export type UserTableProps = { + users: AdminUser[] +} + +// Admin user listing with SPA actions. Destructive delete routes through the +// ConfirmDialog (focus-trapped) instead of a browser confirm. +export function UserTable({ users }: UserTableProps) { + const [pendingDelete, setPendingDelete] = useState(null) + + function handleToggle(user: AdminUser) { + router.patch(user.toggle_url) + } + + function confirmDelete() { + if (!pendingDelete) return + router.delete(pendingDelete.delete_url) + setPendingDelete(null) + } + + if (users.length === 0) { + return + } + + return ( + <> + + + + + User + Email + Role + Actions + + + + {users.map((user) => ( + + + + + {user.full_name} + + + {user.email_address} + + + + + + + Edit + + + + + + + ))} + + + + setPendingDelete(null)} + /> + + ) +} diff --git a/app/frontend/pages/Admin/Dashboard.tsx b/app/frontend/pages/Admin/Dashboard.tsx new file mode 100644 index 000000000..6febe5f8a --- /dev/null +++ b/app/frontend/pages/Admin/Dashboard.tsx @@ -0,0 +1,35 @@ +import { Head, usePage } from '@inertiajs/react' + +import { TextLink } from '@/atoms/TextLink/TextLink' +import { DashboardStats } from '@/organisms/DashboardStats/DashboardStats' +import { PageHeader } from '@/molecules/PageHeader/PageHeader' +import { AdminLayout } from '@/templates/AdminLayout/AdminLayout' +import type { AdminPageUrls, UsersOverview } from '@/types' + +type AdminDashboardProps = { + overview?: UsersOverview + urls?: AdminPageUrls +} + +export default function AdminDashboard({ overview, urls }: AdminDashboardProps) { + const user = usePage().props.auth.user + + const navUrls: AdminPageUrls = { + dashboard_url: '/admin', + users_url: urls?.users_url ?? '/admin/users', + imports_url: urls?.imports_url ?? '/admin/user_imports', + } + + return ( + <> + + + + +
+ Manage users +
+
+ + ) +} diff --git a/app/frontend/pages/Admin/UserImports/Index.tsx b/app/frontend/pages/Admin/UserImports/Index.tsx new file mode 100644 index 000000000..3a942d140 --- /dev/null +++ b/app/frontend/pages/Admin/UserImports/Index.tsx @@ -0,0 +1,76 @@ +import { Head, router, useForm } from '@inertiajs/react' +import type { FormEvent } from 'react' + +import { Button } from '@/atoms/Button/Button' +import { ButtonLink } from '@/atoms/Button/ButtonLink' +import { TextInput } from '@/atoms/TextInput/TextInput' +import { Field } from '@/molecules/Field/Field' +import { FieldGroup } from '@/molecules/FieldGroup/FieldGroup' +import { Form } from '@/molecules/Form/Form' +import { PageHeader } from '@/molecules/PageHeader/PageHeader' +import { ImportsTable } from '@/organisms/ImportsTable/ImportsTable' +import { Pagination } from '@/organisms/Pagination/Pagination' +import { AdminLayout } from '@/templates/AdminLayout/AdminLayout' +import type { AdminImportListItem, AdminPageUrls, Pagination as PaginationData } from '@/types' + +type ImportsIndexProps = { + imports: AdminImportListItem[] + pagination: PaginationData + urls: AdminPageUrls & { new_import_url: string } +} + +export default function ImportsIndex({ imports, pagination, urls }: ImportsIndexProps) { + const { data, setData } = useForm({ page: String(pagination.page) }) + + function go(page: number) { + router.reload({ data: { page: String(page) }, only: ['imports', 'pagination'] }) + } + + function handleSearch(event: FormEvent) { + event.preventDefault() + const page = Number.parseInt(data.page, 10) || 1 + go(page) + } + + return ( + <> + + + + New import + + } + /> +
+ + + setData('page', event.target.value)} + /> + + + +
+ + go(pagination.prev ?? 1)} + onNext={() => go(pagination.next ?? 1)} + /> +
+ + ) +} diff --git a/app/frontend/pages/Admin/UserImports/New.tsx b/app/frontend/pages/Admin/UserImports/New.tsx new file mode 100644 index 000000000..28a5d9ce5 --- /dev/null +++ b/app/frontend/pages/Admin/UserImports/New.tsx @@ -0,0 +1,68 @@ +import { Head, useForm } from '@inertiajs/react' +import type { FormEvent } from 'react' + +import { Button } from '@/atoms/Button/Button' +import { ButtonLink } from '@/atoms/Button/ButtonLink' +import { TextInput } from '@/atoms/TextInput/TextInput' +import { Field } from '@/molecules/Field/Field' +import { Form } from '@/molecules/Form/Form' +import { PageHeader } from '@/molecules/PageHeader/PageHeader' +import { ImportsTable } from '@/organisms/ImportsTable/ImportsTable' +import { AdminLayout } from '@/templates/AdminLayout/AdminLayout' +import type { AdminImportListItem, AdminPageUrls } from '@/types' + +type ImportFormUrls = AdminPageUrls & { submit_url: string } + +type ImportsNewProps = { + imports: AdminImportListItem[] + errors?: Record + urls: ImportFormUrls +} + +export default function ImportsNew({ imports, errors, urls }: ImportsNewProps) { + const { setData, post, processing } = useForm<{ file: File | null }>({ file: null }) + + function handleSubmit(event: FormEvent) { + event.preventDefault() + post(urls.submit_url, { forceFormData: true }) + } + + return ( + <> + + + + All imports + + } + /> +
+ + setData('file', event.target.files?.[0] ?? null)} + /> + + +
+
+ + +
+
+ + ) +} diff --git a/app/frontend/pages/Admin/UserImports/Show.tsx b/app/frontend/pages/Admin/UserImports/Show.tsx new file mode 100644 index 000000000..86f3ed194 --- /dev/null +++ b/app/frontend/pages/Admin/UserImports/Show.tsx @@ -0,0 +1,43 @@ +import { Head } from '@inertiajs/react' + +import { Alert } from '@/atoms/Alert/Alert' +import { ButtonLink } from '@/atoms/Button/ButtonLink' +import { Card } from '@/molecules/Card/Card' +import { DetailRow } from '@/molecules/DetailRow/DetailRow' +import { ImportProgressBar } from '@/molecules/ImportProgressBar/ImportProgressBar' +import { PageHeader } from '@/molecules/PageHeader/PageHeader' +import { AdminLayout } from '@/templates/AdminLayout/AdminLayout' +import type { AdminImport, AdminPageUrls } from '@/types' + +type ImportShowProps = { + import: AdminImport + urls: AdminPageUrls & { imports_url: string } +} + +export default function ImportShow({ import: userImport, urls }: ImportShowProps) { + return ( + <> + + + + Back to imports + + } + /> + + + {userImport.created_at} + {userImport.finished_at ?? '—'} + {userImport.error_message ? ( + + {userImport.error_message} + + ) : null} + + + + ) +} diff --git a/app/frontend/pages/Admin/Users/Edit.tsx b/app/frontend/pages/Admin/Users/Edit.tsx new file mode 100644 index 000000000..fe238fc34 --- /dev/null +++ b/app/frontend/pages/Admin/Users/Edit.tsx @@ -0,0 +1,30 @@ +import { Head } from '@inertiajs/react' + +import { PageHeader } from '@/molecules/PageHeader/PageHeader' +import { UserForm } from '@/organisms/UserForm/UserForm' +import { AdminLayout } from '@/templates/AdminLayout/AdminLayout' +import type { AdminPageUrls, User, UserFormData } from '@/types' + +type EditUserProps = { + urls: AdminPageUrls & { submit_url: string; cancel_url: string } + user?: User + form?: UserFormData +} + +export default function EditUser({ urls, user, form }: EditUserProps) { + return ( + <> + + + + + + + ) +} diff --git a/app/frontend/pages/Admin/Users/Index.tsx b/app/frontend/pages/Admin/Users/Index.tsx new file mode 100644 index 000000000..55aec7707 --- /dev/null +++ b/app/frontend/pages/Admin/Users/Index.tsx @@ -0,0 +1,62 @@ +import { Head, router, useForm } from '@inertiajs/react' + +import { ButtonLink } from '@/atoms/Button/ButtonLink' +import { PageHeader } from '@/molecules/PageHeader/PageHeader' +import { SearchBar } from '@/molecules/SearchBar/SearchBar' +import { Pagination } from '@/organisms/Pagination/Pagination' +import { UserTable } from '@/organisms/UserTable/UserTable' +import { AdminLayout } from '@/templates/AdminLayout/AdminLayout' +import type { AdminPageUrls, AdminUser, Pagination as PaginationData } from '@/types' + +type UsersIndexProps = { + users: AdminUser[] + pagination: PaginationData + filters: { search: string } + urls: AdminPageUrls & { new_url: string } +} + +export default function UsersIndex({ users, pagination, filters, urls }: UsersIndexProps) { + const { data, setData } = useForm({ search: filters.search }) + + function reload(search: string, page: number) { + router.reload({ + data: { search, page: String(page) }, + only: ['users', 'pagination', 'filters'], + }) + } + + function handleSearch() { + reload(data.search, 1) + } + + return ( + <> + + + + New user + + } + /> + setData('search', value)} + onSubmit={handleSearch} + placeholder="Search by name or email" + /> + + reload(filters.search, pagination.prev ?? 1)} + onNext={() => reload(filters.search, pagination.next ?? 1)} + /> + + + ) +} diff --git a/app/frontend/pages/Admin/Users/New.tsx b/app/frontend/pages/Admin/Users/New.tsx new file mode 100644 index 000000000..aba701072 --- /dev/null +++ b/app/frontend/pages/Admin/Users/New.tsx @@ -0,0 +1,28 @@ +import { Head } from '@inertiajs/react' + +import { PageHeader } from '@/molecules/PageHeader/PageHeader' +import { UserForm } from '@/organisms/UserForm/UserForm' +import { AdminLayout } from '@/templates/AdminLayout/AdminLayout' +import type { AdminPageUrls, UserFormData } from '@/types' + +type NewUserProps = { + urls: AdminPageUrls & { submit_url: string; cancel_url: string } + form?: UserFormData +} + +export default function NewUser({ urls, form }: NewUserProps) { + return ( + <> + + + + + + + ) +} diff --git a/app/frontend/pages/Auth/ForgotPassword.tsx b/app/frontend/pages/Auth/ForgotPassword.tsx new file mode 100644 index 000000000..6fd64a308 --- /dev/null +++ b/app/frontend/pages/Auth/ForgotPassword.tsx @@ -0,0 +1,54 @@ +import { Head, useForm } from '@inertiajs/react' +import type { FormEvent } from 'react' + +import { Button } from '@/atoms/Button/Button' +import { TextInput } from '@/atoms/TextInput/TextInput' +import { TextLink } from '@/atoms/TextLink/TextLink' +import { Field } from '@/molecules/Field/Field' +import { Form } from '@/molecules/Form/Form' +import { GuestLayout } from '@/templates/GuestLayout/GuestLayout' + +type ForgotPasswordPageProps = { + urls: { + login_url: string + } +} + +export default function ForgotPassword({ urls }: ForgotPasswordPageProps) { + const { data, setData, post, processing, errors } = useForm({ + email_address: '', + }) + + function handleSubmit(event: FormEvent) { + event.preventDefault() + post('/passwords') + } + + return ( + <> + + Back to sign in} + > +
+ + setData('email_address', event.target.value)} + autoFocus + required + /> + + +
+
+ + ) +} diff --git a/app/frontend/pages/Auth/Login.tsx b/app/frontend/pages/Auth/Login.tsx new file mode 100644 index 000000000..264e9677c --- /dev/null +++ b/app/frontend/pages/Auth/Login.tsx @@ -0,0 +1,74 @@ +import { Head, useForm } from '@inertiajs/react' +import type { FormEvent } from 'react' + +import { Button } from '@/atoms/Button/Button' +import { TextInput } from '@/atoms/TextInput/TextInput' +import { TextLink } from '@/atoms/TextLink/TextLink' +import { Field } from '@/molecules/Field/Field' +import { Form } from '@/molecules/Form/Form' +import { GuestLayout } from '@/templates/GuestLayout/GuestLayout' + +type LoginPageProps = { + urls: { + login_url: string + register_url: string + forgot_password_url: string + } +} + +export default function Login({ urls }: LoginPageProps) { + const { data, setData, post, processing, errors } = useForm({ + email_address: '', + password: '', + }) + + function handleSubmit(event: FormEvent) { + event.preventDefault() + post(urls.login_url) + } + + return ( + <> + + + Create an account + Forgot your password? + + } + > +
+ + setData('email_address', event.target.value)} + autoFocus + required + /> + + + setData('password', event.target.value)} + maxLength={72} + required + /> + + +
+
+ + ) +} diff --git a/app/frontend/pages/Auth/Register.tsx b/app/frontend/pages/Auth/Register.tsx new file mode 100644 index 000000000..c5ccd88e6 --- /dev/null +++ b/app/frontend/pages/Auth/Register.tsx @@ -0,0 +1,130 @@ +import { Head, useForm } from '@inertiajs/react' +import type { FormEvent } from 'react' + +import { Button } from '@/atoms/Button/Button' +import { TextInput } from '@/atoms/TextInput/TextInput' +import { TextLink } from '@/atoms/TextLink/TextLink' +import { Field } from '@/molecules/Field/Field' +import { Form } from '@/molecules/Form/Form' +import { FormErrorAlert } from '@/organisms/FormErrorAlert/FormErrorAlert' +import { GuestLayout } from '@/templates/GuestLayout/GuestLayout' + +type RegisterPageProps = { + urls: { + register_url: string + login_url: string + } + form?: { + full_name?: string + email_address?: string + avatar_url?: string + } +} + +export default function Register({ urls, form }: RegisterPageProps) { + const { data, setData, post, processing, errors } = useForm({ + full_name: form?.full_name ?? '', + email_address: form?.email_address ?? '', + avatar_url: form?.avatar_url ?? '', + password: '', + password_confirmation: '', + avatar: null as File | null, + }) + + function handleSubmit(event: FormEvent) { + event.preventDefault() + post(urls.register_url, { forceFormData: true }) + } + + return ( + <> + + Already have an account? Sign in} + > +
+ + setData('full_name', event.target.value)} + autoFocus + required + /> + + + setData('email_address', event.target.value)} + required + /> + + + setData('avatar', event.target.files?.[0] ?? null)} + /> + + + setData('avatar_url', event.target.value)} + /> + + + setData('password', event.target.value)} + minLength={8} + maxLength={72} + required + /> + + + setData('password_confirmation', event.target.value)} + maxLength={72} + required + /> + + } /> + + +
+ + ) +} diff --git a/app/frontend/pages/Auth/ResetPassword.tsx b/app/frontend/pages/Auth/ResetPassword.tsx new file mode 100644 index 000000000..92a14838b --- /dev/null +++ b/app/frontend/pages/Auth/ResetPassword.tsx @@ -0,0 +1,74 @@ +import { Head, useForm } from '@inertiajs/react' +import type { FormEvent } from 'react' + +import { Button } from '@/atoms/Button/Button' +import { TextInput } from '@/atoms/TextInput/TextInput' +import { TextLink } from '@/atoms/TextLink/TextLink' +import { Field } from '@/molecules/Field/Field' +import { Form } from '@/molecules/Form/Form' +import { GuestLayout } from '@/templates/GuestLayout/GuestLayout' + +type ResetPasswordPageProps = { + token: string + urls: { + login_url: string + } +} + +export default function ResetPassword({ token, urls }: ResetPasswordPageProps) { + const { data, setData, put, processing, errors } = useForm({ + password: '', + password_confirmation: '', + }) + + function handleSubmit(event: FormEvent) { + event.preventDefault() + put(`/passwords/${encodeURIComponent(token)}`) + } + + return ( + <> + + Back to sign in} + > +
+ + setData('password', event.target.value)} + minLength={8} + maxLength={72} + autoFocus + required + /> + + + setData('password_confirmation', event.target.value)} + maxLength={72} + required + /> + + +
+
+ + ) +} diff --git a/app/frontend/pages/Profile/Edit.tsx b/app/frontend/pages/Profile/Edit.tsx new file mode 100644 index 000000000..76dc7cf11 --- /dev/null +++ b/app/frontend/pages/Profile/Edit.tsx @@ -0,0 +1,142 @@ +import { Head, useForm } from '@inertiajs/react' +import type { FormEvent } from 'react' + +import { Button } from '@/atoms/Button/Button' +import { ButtonLink } from '@/atoms/Button/ButtonLink' +import { TextInput } from '@/atoms/TextInput/TextInput' +import { Card } from '@/molecules/Card/Card' +import { Field } from '@/molecules/Field/Field' +import { Form } from '@/molecules/Form/Form' +import { FormActions } from '@/molecules/Form/FormActions' +import { PageHeader } from '@/molecules/PageHeader/PageHeader' +import { FormErrorAlert } from '@/organisms/FormErrorAlert/FormErrorAlert' +import { AuthenticatedLayout } from '@/templates/AuthenticatedLayout/AuthenticatedLayout' + +type ProfileEditProps = { + urls: { + profile_url: string + admin_url: string + submit_url: string + cancel_url: string + } + form?: { + full_name?: string + email_address?: string + avatar_url?: string + } +} + +export default function ProfileEdit({ urls, form }: ProfileEditProps) { + const { data, setData, put, processing, errors } = useForm({ + full_name: form?.full_name ?? '', + email_address: form?.email_address ?? '', + avatar_url: form?.avatar_url ?? '', + password: '', + password_confirmation: '', + avatar: null as File | null, + }) + + function handleSubmit(event: FormEvent) { + event.preventDefault() + put(urls.submit_url, { forceFormData: true }) + } + + return ( + <> + + + + +
+ + setData('full_name', event.target.value)} + required + /> + + + setData('email_address', event.target.value)} + required + /> + + + setData('avatar', event.target.files?.[0] ?? null)} + /> + + + setData('avatar_url', event.target.value)} + /> + + + setData('password', event.target.value)} + minLength={8} + maxLength={72} + /> + + + setData('password_confirmation', event.target.value)} + maxLength={72} + /> + + } /> + + + + Cancel + + + +
+
+ + ) +} diff --git a/app/frontend/pages/Profile/Show.tsx b/app/frontend/pages/Profile/Show.tsx new file mode 100644 index 000000000..b6b41669a --- /dev/null +++ b/app/frontend/pages/Profile/Show.tsx @@ -0,0 +1,63 @@ +import { Head, router } from '@inertiajs/react' +import { useState } from 'react' + +import { Button } from '@/atoms/Button/Button' +import { ButtonLink } from '@/atoms/Button/ButtonLink' +import { ActionsRow } from '@/molecules/ActionsRow/ActionsRow' +import { Card } from '@/molecules/Card/Card' +import { DetailRow } from '@/molecules/DetailRow/DetailRow' +import { ProfileHeader } from '@/molecules/ProfileHeader/ProfileHeader' +import { ConfirmDialog } from '@/organisms/ConfirmDialog/ConfirmDialog' +import { AuthenticatedLayout } from '@/templates/AuthenticatedLayout/AuthenticatedLayout' +import type { User } from '@/types' + +type ProfileShowProps = { + user: User + urls: { + profile_url: string + admin_url: string + edit_url: string + delete_url: string + } +} + +export default function ProfileShow({ user, urls }: ProfileShowProps) { + const [confirmOpen, setConfirmOpen] = useState(false) + + return ( + <> + + + + + {user.email_address} + {user.role} + + Edit profile + + + + + router.delete(urls.delete_url)} + onCancel={() => setConfirmOpen(false)} + /> + + ) +} diff --git a/app/frontend/stores/dashboard-store.ts b/app/frontend/stores/dashboard-store.ts new file mode 100644 index 000000000..bd1e2567c --- /dev/null +++ b/app/frontend/stores/dashboard-store.ts @@ -0,0 +1,25 @@ +import { makeAutoObservable } from 'mobx' + +import type { UsersOverview } from '@/types' + +// Real-time snapshots of the admin dashboard totals. Same Users::Overview +// shape as the initial Inertia render (DRY — one payload contract). +export class DashboardStore { + total_users = 0 + by_role: Record = {} + + constructor() { + makeAutoObservable(this) + } + + hydrate(overview?: UsersOverview) { + if (!overview) return + this.total_users = overview.total_users + this.by_role = { ...overview.by_role } + } + + ingest(payload: UsersOverview) { + this.total_users = payload.total_users + this.by_role = { ...payload.by_role } + } +} diff --git a/app/frontend/stores/flash-store.ts b/app/frontend/stores/flash-store.ts new file mode 100644 index 000000000..84b8e132d --- /dev/null +++ b/app/frontend/stores/flash-store.ts @@ -0,0 +1,28 @@ +import { makeAutoObservable } from 'mobx' + +import type { FlashData } from '@/types' + +// One-off user-feedback message carried by the session (Rails flash), hydrated +// from Inertia shared props on navigation and cleared on read. +export class FlashStore { + notice: string | undefined + alert: string | undefined + + constructor() { + makeAutoObservable(this) + } + + hydrate(flash: FlashData) { + this.notice = flash.notice + this.alert = flash.alert + } + + get hasMessage(): boolean { + return this.notice != null || this.alert != null + } + + clear() { + this.notice = undefined + this.alert = undefined + } +} diff --git a/app/frontend/stores/imports-store.ts b/app/frontend/stores/imports-store.ts new file mode 100644 index 000000000..ee7f907bc --- /dev/null +++ b/app/frontend/stores/imports-store.ts @@ -0,0 +1,40 @@ +import { makeAutoObservable, observable } from 'mobx' + +import type { ImportStatus } from '@/types' + +// Canonical, DB-shaped progress snapshot. The ProgressBroadcaster payload +// (Phase 5/6) is already in this shape; the presenter's AdminImport is mapped +// in the consumer before it touches the store (single shape in the store). +export type ImportProgress = { + id: number + status: ImportStatus + total: number + processed: number + failed: number + percent: number +} + +// Real-time import progress keyed by UserImport id. +export class ImportsStore { + imports = observable.map() + + constructor() { + makeAutoObservable(this) + } + + hydrate(id: number, data: ImportProgress) { + this.imports.set(id, data) + } + + ingest(payload: ImportProgress) { + this.imports.set(payload.id, payload) + } + + isTracked(id: number) { + return this.imports.has(id) + } + + progressOf(id: number) { + return this.imports.get(id) + } +} diff --git a/app/frontend/stores/index.ts b/app/frontend/stores/index.ts new file mode 100644 index 000000000..d15b64fee --- /dev/null +++ b/app/frontend/stores/index.ts @@ -0,0 +1,12 @@ +import { configure } from 'mobx' + +// Strict mode: state changes only allowed inside actions (applyAction / +// makeAutoObservable methods). UI must never mutate a store directly. +configure({ enforceActions: 'always' }) + +export { RootStore } from './root' +export { RootStoreProvider, useStore } from './store-context' +export { SessionStore } from './session-store' +export { FlashStore } from './flash-store' +export { DashboardStore } from './dashboard-store' +export { ImportsStore, type ImportProgress } from './imports-store' diff --git a/app/frontend/stores/root.ts b/app/frontend/stores/root.ts new file mode 100644 index 000000000..a8a7f0510 --- /dev/null +++ b/app/frontend/stores/root.ts @@ -0,0 +1,21 @@ +import { DashboardStore } from './dashboard-store' +import { FlashStore } from './flash-store' +import { ImportsStore } from './imports-store' +import { SessionStore } from './session-store' + +// Composition root: every store is constructed here and provided down the +// tree through React Context (no global singleton). Stores only talk to each +// other through this root when composition requires it. +export class RootStore { + session: SessionStore + flash: FlashStore + dashboard: DashboardStore + imports: ImportsStore + + constructor() { + this.session = new SessionStore() + this.flash = new FlashStore() + this.dashboard = new DashboardStore() + this.imports = new ImportsStore() + } +} diff --git a/app/frontend/stores/session-store.ts b/app/frontend/stores/session-store.ts new file mode 100644 index 000000000..a636eb36d --- /dev/null +++ b/app/frontend/stores/session-store.ts @@ -0,0 +1,38 @@ +import { makeAutoObservable } from 'mobx' + +import type { User } from '@/types' + +// Authenticated session snapshot. Hydrated from Inertia shared props on every +// navigation (reaction in App.tsx). No emitters — pages read via observer. +export class SessionStore { + user: User | null = null + + constructor() { + makeAutoObservable(this) + } + + hydrate(user: User | null) { + this.user = user + } + + get isAuthenticated(): boolean { + return this.user != null + } + + get isAdmin(): boolean { + return this.user?.role === 'admin' + } + + get displayName(): string { + return this.user?.full_name ?? this.user?.email_address ?? '' + } + + get initials(): string { + const name = this.displayName.trim() + if (!name) return '?' + const parts = name.split(/\s+/) + return parts.length === 1 + ? parts[0].slice(0, 2).toUpperCase() + : (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() + } +} diff --git a/app/frontend/stores/store-context.tsx b/app/frontend/stores/store-context.tsx new file mode 100644 index 000000000..891dd7d03 --- /dev/null +++ b/app/frontend/stores/store-context.tsx @@ -0,0 +1,16 @@ +import { createContext, useContext, useState, type ReactNode } from 'react' + +import { RootStore } from './root' + +const StoreContext = createContext(null) + +export function RootStoreProvider({ children }: { children: ReactNode }) { + const [root] = useState(() => new RootStore()) + return {children} +} + +export function useStore(): RootStore { + const store = useContext(StoreContext) + if (!store) throw new Error('useStore must be used within RootStoreProvider') + return store +} diff --git a/app/frontend/styles/base.css b/app/frontend/styles/base.css new file mode 100644 index 000000000..e428a0544 --- /dev/null +++ b/app/frontend/styles/base.css @@ -0,0 +1,59 @@ +@import 'tailwindcss'; + +@plugin '@tailwindcss/typography'; +@plugin '@tailwindcss/forms'; + +@theme { + --font-sans: + ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + + --color-ink: #111827; + --color-ink-soft: #374151; + --color-ink-mute: #6b7280; + --color-paper: #ffffff; + --color-canvas: #f9fafb; + --color-line: #e5e7eb; + --color-line-soft: #f3f4f6; + + --color-brand: #2563eb; + --color-brand-strong: #1d4ed8; + --color-brand-soft: #eff6ff; + + --color-danger: #dc2626; + --color-danger-strong: #b91c1c; + --color-danger-soft: #fef2f2; + + --color-ok: #16a34a; + --color-ok-soft: #f0fdf4; + + --color-warn: #b45309; + --color-warn-strong: #d97706; + --color-warn-soft: #fffbeb; + + /* Alpha variants as plain rgb() values. Tailwind opacity utilities (e.g. + bg-ink over 50) compile to color-mix(), which older browsers (Safari <16.2 + / iPhone 7, Chrome ~53 era Android) do not support — these tokens keep the + same tints working without it. */ + --color-scrim: rgb(17 24 39 / 0.4); + --color-line-soft-strong: rgb(243 244 246 / 0.6); + --color-line-soft-tint: rgb(243 244 246 / 0.4); + --color-line-faint: rgb(229 231 235 / 0.7); + --color-brand-faint: rgb(37 99 235 / 0.2); + --color-brand-ring: rgb(37 99 235 / 0.3); + --color-brand-ring-strong: rgb(37 99 235 / 0.4); + --color-ok-faint: rgb(22 163 74 / 0.2); + --color-danger-faint: rgb(220 38 38 / 0.3); + --color-danger-ring: rgb(220 38 38 / 0.3); + --color-danger-ring-strong: rgb(220 38 38 / 0.4); + --color-warn-faint: rgb(180 83 9 / 0.3); + + --shadow-card: 0 1px 3px 0 rgb(0 0 0 / 0.08), 0 1px 2px -1px rgb(0 0 0 / 0.04); + --shadow-pop: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.06); +} + +@layer base { + body { + @apply bg-canvas text-ink antialiased; + font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11'; + } +} diff --git a/app/frontend/templates/AdminLayout/AdminLayout.styles.ts b/app/frontend/templates/AdminLayout/AdminLayout.styles.ts new file mode 100644 index 000000000..7037c9428 --- /dev/null +++ b/app/frontend/templates/AdminLayout/AdminLayout.styles.ts @@ -0,0 +1,7 @@ +import { styled } from '@/lib/styled' + +export const StyledShell = styled.div('flex min-h-screen flex-col bg-canvas') + +export const StyledMain = styled.main( + 'mx-auto w-full max-w-5xl px-4 py-6 space-y-6 sm:px-6 sm:py-8', +) diff --git a/app/frontend/templates/AdminLayout/AdminLayout.tsx b/app/frontend/templates/AdminLayout/AdminLayout.tsx new file mode 100644 index 000000000..92ad2dc07 --- /dev/null +++ b/app/frontend/templates/AdminLayout/AdminLayout.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from 'react' + +import type { AdminPageUrls } from '@/types' +import { FlashMessages } from '@/organisms/FlashMessages/FlashMessages' +import { Navbar } from '@/organisms/Navbar/Navbar' + +import { StyledMain, StyledShell } from './AdminLayout.styles' + +export type AdminLayoutProps = { + urls: AdminPageUrls + children: ReactNode +} + +// Admin-area shell: navigation + flash + constrained content column. +export function AdminLayout({ urls, children }: AdminLayoutProps) { + return ( + + + + + {children} + + + ) +} diff --git a/app/frontend/templates/AuthenticatedLayout/AuthenticatedLayout.styles.ts b/app/frontend/templates/AuthenticatedLayout/AuthenticatedLayout.styles.ts new file mode 100644 index 000000000..a969a6b37 --- /dev/null +++ b/app/frontend/templates/AuthenticatedLayout/AuthenticatedLayout.styles.ts @@ -0,0 +1,7 @@ +import { styled } from '@/lib/styled' + +export const StyledShell = styled.div('flex min-h-screen flex-col bg-canvas') + +export const StyledMain = styled.main( + 'mx-auto w-full max-w-3xl px-4 py-6 space-y-6 sm:px-6 sm:py-8', +) diff --git a/app/frontend/templates/AuthenticatedLayout/AuthenticatedLayout.tsx b/app/frontend/templates/AuthenticatedLayout/AuthenticatedLayout.tsx new file mode 100644 index 000000000..92540a35e --- /dev/null +++ b/app/frontend/templates/AuthenticatedLayout/AuthenticatedLayout.tsx @@ -0,0 +1,41 @@ +import { observer } from 'mobx-react-lite' +import type { ReactNode } from 'react' + +import { useStore } from '@/stores' +import { Navbar } from '@/organisms/Navbar/Navbar' +import { FlashMessages } from '@/organisms/FlashMessages/FlashMessages' + +import { StyledMain, StyledShell } from './AuthenticatedLayout.styles' + +export type AuthenticatedLayoutProps = { + navTitle?: string + profileUrl: string + adminUrl?: string + children: ReactNode +} + +// Post-login shell for non-admin areas (profile). Admin visibility comes from +// the SessionStore (single source of truth), so no prop drilling of roles. +export const AuthenticatedLayout = observer(function AuthenticatedLayout({ + navTitle = 'IAM', + profileUrl, + adminUrl, + children, +}: AuthenticatedLayoutProps) { + const { session } = useStore() + + const links = [{ label: 'Profile', href: profileUrl }] + if (session.isAdmin && adminUrl) { + links.push({ label: 'Admin panel', href: adminUrl }) + } + + return ( + + + + + {children} + + + ) +}) diff --git a/app/frontend/templates/GuestLayout/GuestLayout.styles.ts b/app/frontend/templates/GuestLayout/GuestLayout.styles.ts new file mode 100644 index 000000000..e8062255f --- /dev/null +++ b/app/frontend/templates/GuestLayout/GuestLayout.styles.ts @@ -0,0 +1,15 @@ +import { styled } from '@/lib/styled' + +export const StyledShell = styled.main('grid min-h-screen place-items-center bg-canvas px-4 py-10') + +export const StyledMain = styled.div('w-full max-w-md space-y-4') + +export const StyledBrand = styled.p( + 'text-center text-xs font-bold uppercase tracking-[0.2em] text-ink-mute', +) + +export const StyledCard = styled.section( + 'space-y-5 rounded-lg border border-line bg-paper p-6 shadow-card', +) + +export const StyledLinks = styled.nav('flex flex-col items-center gap-2 text-center text-sm') diff --git a/app/frontend/templates/GuestLayout/GuestLayout.tsx b/app/frontend/templates/GuestLayout/GuestLayout.tsx new file mode 100644 index 000000000..62b983605 --- /dev/null +++ b/app/frontend/templates/GuestLayout/GuestLayout.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from 'react' + +import { Heading } from '@/atoms/Heading/Heading' +import { FlashMessages } from '@/organisms/FlashMessages/FlashMessages' + +import { StyledBrand, StyledCard, StyledLinks, StyledMain, StyledShell } from './GuestLayout.styles' + +export type GuestLayoutProps = { + title: string + brand?: string + children: ReactNode + links?: ReactNode +} + +// Frameless (pre-auth) shell: centered card with flash and optional text links. +export function GuestLayout({ title, brand = 'IAM', children, links }: GuestLayoutProps) { + return ( + + + {brand} + + + {title} + {children} + + {links ? {links} : null} + + + ) +} diff --git a/app/frontend/test/setup.ts b/app/frontend/test/setup.ts new file mode 100644 index 000000000..a9d0dd31a --- /dev/null +++ b/app/frontend/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest' diff --git a/app/frontend/types/actioncable.d.ts b/app/frontend/types/actioncable.d.ts new file mode 100644 index 000000000..feed0e09a --- /dev/null +++ b/app/frontend/types/actioncable.d.ts @@ -0,0 +1,21 @@ +declare module '@rails/actioncable' { + export interface Subscription { + unsubscribe(): void + connected(): void + } + + export interface Subscriptions { + create>( + channel: Params, + mixin?: { + received?: (payload: unknown) => void + }, + ): Subscription + } + + export interface Consumer { + subscriptions: Subscriptions + } + + export function createConsumer(url?: string): Consumer +} diff --git a/app/frontend/types/globals.d.ts b/app/frontend/types/globals.d.ts new file mode 100644 index 000000000..506babdce --- /dev/null +++ b/app/frontend/types/globals.d.ts @@ -0,0 +1,9 @@ +import type { FlashData, SharedProps } from '@/types' + +declare module '@inertiajs/core' { + export interface InertiaConfig { + sharedPageProps: SharedProps + flashDataType: FlashData + errorValueType: string[] + } +} diff --git a/app/frontend/types/index.ts b/app/frontend/types/index.ts new file mode 100644 index 000000000..6cc25c7c9 --- /dev/null +++ b/app/frontend/types/index.ts @@ -0,0 +1,75 @@ +export type FlashData = { + notice?: string + alert?: string +} + +export type User = { + id: number + full_name: string + email_address: string + role: 'user' | 'admin' + avatar_source: string +} + +export type SharedProps = { + auth: { + user: User | null + } +} + +export type SessionDP = SharedProps & { + flash: FlashData +} + +export type Pagination = { + page: number + pages: number + count: number + items: number + prev: number | null + next: number | null +} + +export type UsersOverview = { + total_users: number + by_role: Record +} + +export type AdminPageUrls = { + dashboard_url: string + users_url: string + imports_url: string +} + +export type ImportStatus = 'pending' | 'processing' | 'completed' | 'failed' + +export type AdminImport = { + id: number + file_name: string | null + status: ImportStatus + status_human: string + total_rows: number + processed_rows: number + failed_rows: number + progress: number + finished_at: string | null + error_message: string | null + created_at: string | null +} + +export type AdminImportListItem = AdminImport & { + show_url: string +} + +export type AdminUser = User & { + edit_url: string + toggle_url: string + delete_url: string +} + +export type UserFormData = { + full_name?: string + email_address?: string + avatar_url?: string + role?: string +} diff --git a/app/frontend/types/vite-env.d.ts b/app/frontend/types/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/app/frontend/types/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb new file mode 100644 index 000000000..de6be7945 --- /dev/null +++ b/app/helpers/application_helper.rb @@ -0,0 +1,2 @@ +module ApplicationHelper +end diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb new file mode 100644 index 000000000..d394c3d10 --- /dev/null +++ b/app/jobs/application_job.rb @@ -0,0 +1,7 @@ +class ApplicationJob < ActiveJob::Base + # Automatically retry jobs that encountered a deadlock + # retry_on ActiveRecord::Deadlocked + + # Most jobs are safe to ignore if the underlying records are no longer available + # discard_on ActiveJob::DeserializationError +end diff --git a/app/jobs/user_imports/job.rb b/app/jobs/user_imports/job.rb new file mode 100644 index 000000000..5dd34ff3e --- /dev/null +++ b/app/jobs/user_imports/job.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module UserImports + # Thin job: only sets the processing state and delegates to the service. + # No business rule lives here (SRP). + class Job < ApplicationJob + queue_as :imports + + def perform(user_import) + user_import.update!(status: :processing) unless user_import.processing? + UserImports::Processor.new.call(user_import) + end + end +end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb new file mode 100644 index 000000000..1779ba0e3 --- /dev/null +++ b/app/mailers/application_mailer.rb @@ -0,0 +1,6 @@ +class ApplicationMailer < ActionMailer::Base + # Fallback keeps tests/console runnable; production enforces MAIL_FROM in + # config/environments/production.rb (fail-fast). + default from: ENV.fetch("MAIL_FROM", "no-reply@localhost") + layout "mailer" +end diff --git a/app/mailers/passwords_mailer.rb b/app/mailers/passwords_mailer.rb new file mode 100644 index 000000000..4f0ac7fd9 --- /dev/null +++ b/app/mailers/passwords_mailer.rb @@ -0,0 +1,6 @@ +class PasswordsMailer < ApplicationMailer + def reset(user) + @user = user + mail subject: "Reset your password", to: user.email_address + end +end diff --git a/app/models/application_record.rb b/app/models/application_record.rb new file mode 100644 index 000000000..b63caeb8a --- /dev/null +++ b/app/models/application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class +end diff --git a/app/models/concerns/.keep b/app/models/concerns/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/app/models/current.rb b/app/models/current.rb new file mode 100644 index 000000000..2bef56dad --- /dev/null +++ b/app/models/current.rb @@ -0,0 +1,4 @@ +class Current < ActiveSupport::CurrentAttributes + attribute :session + delegate :user, to: :session, allow_nil: true +end diff --git a/app/models/session.rb b/app/models/session.rb new file mode 100644 index 000000000..cf376fb28 --- /dev/null +++ b/app/models/session.rb @@ -0,0 +1,3 @@ +class Session < ApplicationRecord + belongs_to :user +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 000000000..d815ead4a --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,20 @@ +class User < ApplicationRecord + has_secure_password + has_many :sessions, dependent: :destroy + has_many :user_imports, dependent: :nullify + has_one_attached :avatar + + enum :role, { user: 0, admin: 1 } + + generates_token_for :password_reset, expires_in: 15.minutes do + password_salt.last(10) + end + + normalizes :email_address, with: ->(e) { e.strip.downcase } + validates :email_address, presence: true, uniqueness: true + + def avatar_source + return avatar if avatar.attached? + avatar_url + end +end diff --git a/app/models/user_import.rb b/app/models/user_import.rb new file mode 100644 index 000000000..0e31111ce --- /dev/null +++ b/app/models/user_import.rb @@ -0,0 +1,34 @@ +class UserImport < ApplicationRecord + belongs_to :user + has_one_attached :file + + enum :status, { pending: 0, processing: 1, completed: 2, failed: 3 } + + validate :file_presence, on: :create + validate :supported_file_format + + # Single intent method (progress is not spread across the app): + # 0..1 completed share, never dividing by zero. + def progress + return 0.0 if total_rows.zero? + + processed_rows.fdiv(total_rows) + end + + private + + def file_presence + errors.add(:file, :blank) unless file.attached? + end + + # Accepted formats are defined by the parser registry — the model does not + # carry its own contract of formats (DRY single source). + def supported_file_format + return unless file.attached? + + extension = file.blob.filename.extension_with_delimiter.downcase + return if UserImports::Parser::REGISTRY.key?(extension) + + errors.add(:file, :unsupported_format) + end +end diff --git a/app/parsers/user_imports/parser.rb b/app/parsers/user_imports/parser.rb new file mode 100644 index 000000000..baa993eff --- /dev/null +++ b/app/parsers/user_imports/parser.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module UserImports + # The only strategy structure allowed in this project (arquitetura §2.1, + # regra 1): an interface with #parse and a registry of extension -> impl. + # Formats are selected by DATA (a constant hash), not by a Factory object. + class Parser + REGISTRY = { + ".csv" => Roo, + ".xlsx" => Roo, + ".xls" => Roo + }.freeze + + # Selects the implementation for a file name — the single place that + # knows which format maps to which strategy (OCP: new format = new entry). + def self.for(file_name) + implementation = REGISTRY[File.extname(file_name.to_s).downcase] + raise ArgumentError, "unsupported spreadsheet format: #{file_name}" unless implementation + + implementation + end + + # Contract: yields UserImports::Result { headers:, rows:, total_rows: }. + def parse(_uploaded_file) + raise NotImplementedError, "#{self.class} must implement #parse" + end + end +end diff --git a/app/parsers/user_imports/parser/roo.rb b/app/parsers/user_imports/parser/roo.rb new file mode 100644 index 000000000..a9a72f2e0 --- /dev/null +++ b/app/parsers/user_imports/parser/roo.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +module UserImports + class Parser + # Roo backs every extension in the registry (csv/xlsx/xls). It reads the + # first sheet, normalizes headers (case/underscore) onto the canonical + # form attributes, skips the header row and blank lines, and exposes the + # remaining rows lazily (Enumerator#lazy over the sheet). + # Substitutable implementation of the UserImports::Parser interface (LSP). + class Roo + # Canonical attribute key after header normalization. + HEADER_MAP = { + "full_name" => :full_name, + "fullname" => :full_name, + "full name" => :full_name, + "email_address" => :email_address, + "emailaddress" => :email_address, + "email" => :email_address, + "e-mail" => :email_address, + "e_mail" => :email_address, + "avatar_url" => :avatar_url, + "avatar url" => :avatar_url, + "avatar" => :avatar_url + }.freeze + + CSV_OPTIONS = { col_sep: ",", encoding: "BOM|UTF-8" }.freeze + + # uploaded_file is either an ActiveStorage attachment (responds to + # #download) or a path String/Pathname — normalized the same way. + def parse(uploaded_file) + with_source(uploaded_file) do |path, extension| + spreadsheet = ::Roo::Spreadsheet.open(path, extension: extension, csv_options: CSV_OPTIONS) + rows_from(spreadsheet) + end + end + + private + + def with_source(uploaded_file) + extension = extension_for(uploaded_file) + if uploaded_file.respond_to?(:download) + Tempfile.create([ "user_import", extension ]) do |file| + file.binmode + file.write(uploaded_file.download) + file.rewind + yield file.path, extension.delete(".").to_sym + end + else + yield uploaded_file.to_s, extension.delete(".").to_sym + end + end + + def extension_for(uploaded_file) + name = + if uploaded_file.respond_to?(:filename) + uploaded_file.filename.to_s + else + uploaded_file.to_s + end + File.extname(name).downcase + end + + def rows_from(spreadsheet) + sheet = spreadsheet.sheet(0) + map = header_map(sheet.row(1)) + data_rows = data_rows_of(sheet) + + rows = data_rows.lazy.map do |row_number, cells| + Row.new(row_number: row_number, attributes: attributes_for(cells, map)) + end + + Result.new(headers: map.keys, rows: rows, total_rows: data_rows.size) + end + + def header_map(header_row) + header_row.to_a.each_with_index.each_with_object({}) do |(cell, index), map| + key = HEADER_MAP[normalize_header(cell)] + map[key] = index if key + end + end + + # Lazily consumed list of [original_row_number, cells] for non-blank + # data rows (line 1 is the header). Range is bounded by sheet.last_row. + def data_rows_of(sheet) + (2..sheet.last_row).filter_map do |row_number| + cells = sheet.row(row_number) + next if blank_row?(cells) + + [ row_number, cells ] + end + end + + def attributes_for(cells, map) + map.each_with_object({}) do |(key, index), attributes| + attributes[key] = normalize_value(cells[index]) + end + end + + def blank_row?(cells) + cells.nil? || cells.all? { |cell| cell.nil? || cell.to_s.strip.empty? } + end + + def normalize_header(cell) + return "" if cell.nil? + + cell.to_s.strip.downcase.gsub(/[\s\-]+/, "_") + end + + def normalize_value(cell) + return nil if cell.nil? + + cell.to_s.strip + end + end + end +end diff --git a/app/policies/users/policy.rb b/app/policies/users/policy.rb new file mode 100644 index 000000000..fe5cef0e0 --- /dev/null +++ b/app/policies/users/policy.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Users + # Policy object: single source of role invariants (DRY). Pure domain rules, + # no HTTP. New role-based rules are added here, never in controllers. + class Policy + def self.admin?(user) + new(user).admin? + end + + def self.admin_reader?(user) + new(user).admin? + end + + def initialize(user) + @user = user + end + + def admin? + @user&.admin? + end + + def destructible?(actor, target) + return false if actor == target + return false if last_admin?(target) + + true + end + + def demotable?(actor, target) + return false if actor == target + return false if last_admin?(target) + + true + end + + private + + attr_reader :user + + def last_admin?(target) + target.admin? && role_count_for(:admin) == 1 + end + + def role_count_for(role) + User.where(role: role).count + end + end +end diff --git a/app/presenters/user_import_presenter.rb b/app/presenters/user_import_presenter.rb new file mode 100644 index 000000000..36d597f45 --- /dev/null +++ b/app/presenters/user_import_presenter.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +# Pure presentation of a UserImport for Inertia (SRP — no business rule): +# date formatting, status label and rounded percentage come from the model's +# single progress intent. Contract mirrored by AdminImport in the TS types. +class UserImportPresenter + def initialize(user_import) + @user_import = user_import + end + + def to_inertia + { + id: id, + file_name: file_name, + status: status, + status_human: status_human, + total_rows: total_rows, + processed_rows: processed_rows, + failed_rows: failed_rows, + progress: percent, + finished_at: finished_at, + error_message: error_message, + created_at: created_at + } + end + + private + + attr_reader :user_import + + delegate :id, :status, :total_rows, :processed_rows, :failed_rows, :error_message, to: :user_import + + def file_name + user_import.file.attached? ? user_import.file.filename.to_s : nil + end + + def status_human + status&.capitalize + end + + def percent + (user_import.progress * 100).round + end + + def finished_at + user_import.finished_at&.iso8601 + end + + def created_at + user_import.created_at&.iso8601 + end +end diff --git a/app/presenters/user_presenter.rb b/app/presenters/user_presenter.rb new file mode 100644 index 000000000..5065a7bfa --- /dev/null +++ b/app/presenters/user_presenter.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class UserPresenter + AVATAR_PLACEHOLDER = "/images/avatar-placeholder.png" + + def initialize(user) + @user = user + end + + def to_inertia + { + id: id, + full_name: full_name, + email_address: email_address, + role: role, + avatar_source: avatar_source + } + end + + private + + attr_reader :user + + delegate :id, :full_name, :email_address, :role, to: :user + + def avatar_source + return user.avatar if user.avatar.attached? + return user.avatar_url if user.avatar_url.present? + + AVATAR_PLACEHOLDER + end +end diff --git a/app/queries/dashboard/totals_query.rb b/app/queries/dashboard/totals_query.rb new file mode 100644 index 000000000..a0c99f355 --- /dev/null +++ b/app/queries/dashboard/totals_query.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Dashboard + # Single source of dashboard totals, reused by the initial render and by the + # real-time broadcast (Phase 6) — DRY. + class TotalsQuery + def self.call(scope) + new(scope).call + end + + def initialize(scope) + @scope = scope + end + + def call + Users::Overview.new( + total_users: scope.count, + by_role: scope.group(:role).count + ) + end + + private + + attr_reader :scope + end +end diff --git a/app/queries/user_imports/listing_query.rb b/app/queries/user_imports/listing_query.rb new file mode 100644 index 000000000..aea05ace6 --- /dev/null +++ b/app/queries/user_imports/listing_query.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module UserImports + # Reads the admin import history: newest first, paginated. Pagy is isolated + # here — the controller never sees it (DIP), mirroring Users::ListingQuery. + class ListingQuery + PER_PAGE = 10 + ORDER = Arel.sql("id DESC").freeze + + def self.call(scope: UserImport.all, page: nil) + new(scope: scope, page: page).call + end + + def initialize(scope: UserImport.all, page: nil) + @scope = scope + @page = page + end + + def call + relation = scope.order(ORDER) + count = relation.count + pagy = Pagy.new(count: count, page: safe_page(count), limit: PER_PAGE) + + UserImports::Listing.new( + imports: relation.offset(pagy.offset).limit(pagy.in), + pagination: { + page: pagy.page, + pages: pagy.last, + count: pagy.count, + items: pagy.limit, + prev: pagy.prev, + next: pagy.next + } + ) + end + + private + + attr_reader :scope, :page + + def safe_page(count) + requested = Integer(page, exception: false) || 1 + last = [ 1, (count.to_f / PER_PAGE).ceil ].max + requested.clamp(1, last) + end + end +end diff --git a/app/queries/users/listing_query.rb b/app/queries/users/listing_query.rb new file mode 100644 index 000000000..efad3ef93 --- /dev/null +++ b/app/queries/users/listing_query.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Users + # Reads the admin user list: search, stable ordering, avatar preloading and + # pagination. Pagy is isolated here — the controller never sees it (DIP). + class ListingQuery + ORDER = Arel.sql("LOWER(full_name) ASC, id ASC").freeze + PER_PAGE = 10 + + def self.call(scope: User.all, search: nil, page: nil) + new(scope: scope, search: search, page: page).call + end + + def initialize(scope: User.all, search: nil, page: nil) + @scope = scope + @search = search + @page = page + end + + def call + filtered = search_filter(scope.with_attached_avatar.order(ORDER)) + pagy = Pagy.new(count: filtered.count, page: safe_page(filtered.count), limit: PER_PAGE) + + Users::Listing.new( + users: filtered.offset(pagy.offset).limit(pagy.in), + pagination: { + page: pagy.page, + pages: pagy.last, + count: pagy.count, + items: pagy.limit, + prev: pagy.prev, + next: pagy.next + } + ) + end + + private + + attr_reader :scope, :search, :page + + def search_filter(relation) + return relation if search.blank? + + term = "%#{ActiveRecord::Base.sanitize_sql_like(search.to_s).downcase}%" + relation.where("LOWER(full_name) LIKE :term OR LOWER(email_address) LIKE :term", term: term) + end + + def safe_page(count) + requested = Integer(page, exception: false) || 1 + last = [ 1, (count.to_f / PER_PAGE).ceil ].max + requested.clamp(1, last) + end + end +end diff --git a/app/services/dashboard/publisher.rb b/app/services/dashboard/publisher.rb new file mode 100644 index 000000000..2cf218d7e --- /dev/null +++ b/app/services/dashboard/publisher.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Dashboard + # Publishes the current dashboard totals to the "dashboard_stats" stream so + # the admin panel updates without a reload. Reuses Dashboard::TotalsQuery + + # Users::Overview#to_inertia — identical shape to the initial render (DRY). + class Publisher + STREAM = "dashboard_stats" + + def self.call + new.call + end + + def call + overview = Dashboard::TotalsQuery.call(User.all) + ActionCable.server.broadcast(STREAM, overview.to_inertia) + end + end +end diff --git a/app/services/user_imports/processor.rb b/app/services/user_imports/processor.rb new file mode 100644 index 000000000..d59acf5d5 --- /dev/null +++ b/app/services/user_imports/processor.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +module UserImports + # Orchestrates the async import (SRP + DIP): each collaborator is injected + # in the constructor with a real default; tests swap in fakes. The Processor + # composes — it does not own parser/form/broadcast logic. + class Processor + BATCH_SIZE = 25 + # Attribute-level errors that map cleanly onto a spreadsheet cell. + ATTRIBUTES = %i[full_name email_address avatar_url].freeze + + def initialize( + parser_class: UserImports::Parser::Roo, + form_class: Users::RegistrationForm, + broadcaster: UserImports::ProgressBroadcaster.new, + dashboard_publisher: Dashboard::Publisher.new + ) + @parser_class = parser_class + @form_class = form_class + @broadcaster = broadcaster + @dashboard_publisher = dashboard_publisher + end + + # - parses once (total_rows persisted upfront) + # - persists each row through a shared Form Object (RegistrationForm + + # generated invite password) inside per-batch transactions + # - counts processed/failed, aggregates row errors into a summary + # - broadcasts progress after every batch and always writes a final state + # (the last broadcast carries the completion/failure status — Phase 6) + def call(user_import) + result = parser_class.new.parse(user_import.file) + user_import.update!( + status: :processing, + total_rows: result.total_rows, + processed_rows: 0, + failed_rows: 0 + ) + + row_errors = [] + result.rows.each_slice(BATCH_SIZE) do |batch| + process_batch(user_import, batch, row_errors) + broadcaster.call(user_import) + end + + finish(user_import, row_errors) + rescue StandardError => error + fail_import(user_import, error) + end + + private + + attr_reader :parser_class, :form_class, :broadcaster, :dashboard_publisher + + def process_batch(user_import, batch, row_errors) + User.transaction do + batch.each { |row| process_row(user_import, row, row_errors) } + end + end + + def process_row(user_import, row, row_errors) + invite = generated_password + form = form_class.new( + row.attributes.merge( + password: invite, + password_confirmation: invite, + ignore_duplicate_emails: true, + require_avatar: false + ) + ) + + if form.save + user_import.increment!(:processed_rows) + else + user_import.increment!(:failed_rows) + row_errors.concat(row_errors(form, row)) + end + end + + def row_errors(form, row) + names = form.errors.attribute_names & ATTRIBUTES + names = form.errors.attribute_names if names.empty? + names.flat_map do |attribute| + form.errors.full_messages_for(attribute).map do |message| + RowError.new(row_number: row.row_number, attribute: attribute, message: message) + end + end + end + + # Any failed row makes the outcome :failed with an aggregated summary + # (count + row numbers only — never leaked row data). + def finish(user_import, row_errors) + user_import.update!( + status: row_errors.any? ? :failed : :completed, + error_message: row_errors.any? ? summary(row_errors) : nil, + finished_at: Time.current + ) + broadcaster.call(user_import) + dashboard_publisher.call + end + + # Fatal errors (parse/IO/DB) also land in a final :failed state — no + # re-raise, so a broken file is not retried forever by the queue. + def fail_import(user_import, error) + warn "===IMPORT ERROR=== #{error.class}: #{error.message}" if ENV["DEBUG_IMPORT_ERROR"] == "1" + user_import.update!( + status: :failed, + error_message: "Could not process the file (#{error.class}).", + finished_at: Time.current + ) + broadcaster.call(user_import) + end + + def summary(row_errors) + rows = row_errors.map(&:row_number).uniq + "#{row_errors.size} error(s) in #{rows.size} row(s): #{rows.join(", ")}." + end + + def generated_password + SecureRandom.urlsafe_base64(12) + end + end +end diff --git a/app/services/user_imports/progress_broadcaster.rb b/app/services/user_imports/progress_broadcaster.rb new file mode 100644 index 000000000..8bbc2898f --- /dev/null +++ b/app/services/user_imports/progress_broadcaster.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module UserImports + # The single place that broadcasts import progress (DRY — Fase 6 will feed + # the Inertia front end with this same payload). Injected into the + # Processor as the real default; tests replace it with a fake (DIP). + class ProgressBroadcaster + def call(user_import) + ActionCable::Channel::Base.broadcast_to(user_import, payload(user_import)) + end + + private + + def payload(user_import) + { + id: user_import.id, + status: user_import.status, + total: user_import.total_rows, + processed: user_import.processed_rows, + failed: user_import.failed_rows, + percent: user_import.progress + } + end + end +end diff --git a/app/services/users/account_closer.rb b/app/services/users/account_closer.rb new file mode 100644 index 000000000..f7d0d6f57 --- /dev/null +++ b/app/services/users/account_closer.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Users + # Closes an account: destroys the user. Sessions fall with + # dependent: :destroy; user_imports are kept via dependent: :nullify so the + # import history survives. Session/cookie cleanup stays in the controller. + class AccountCloser + def self.call(user) + new.call(user) + end + + def call(user) + user.destroy! + end + end +end diff --git a/app/services/users/role_toggler.rb b/app/services/users/role_toggler.rb new file mode 100644 index 000000000..1ba5ab20c --- /dev/null +++ b/app/services/users/role_toggler.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Users + # Toggles a user's role ("user" <-> "admin"). An admin write use case, so it + # persists through Admin::UserForm — controllers never touch ActiveRecord for + # a User write (DIP). The authorization question (demotable?) stays in the + # controller/policy; this service only applies the mutation through the + # shared validation contract (DRY single source). + class RoleToggler + def self.call(target) + new.call(target) + end + + def call(target) + form = Admin::UserForm.new( + { + full_name: target.full_name, + email_address: target.email_address, + role: toggled_role(target), + require_avatar: false + }, + user: target + ) + form.update + end + + private + + def toggled_role(target) + target.role == "admin" ? "user" : "admin" + end + end +end diff --git a/app/value_objects/user_imports/listing.rb b/app/value_objects/user_imports/listing.rb new file mode 100644 index 000000000..1f00b647e --- /dev/null +++ b/app/value_objects/user_imports/listing.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module UserImports + # Immutable result of UserImports::ListingQuery — keeps pagy out of the + # controller (DIP), mirroring Users::Listing. + class Listing + def initialize(imports:, pagination:) + @imports = imports.to_a.freeze + @pagination = pagination.freeze + freeze + end + + attr_reader :imports, :pagination + end +end diff --git a/app/value_objects/user_imports/result.rb b/app/value_objects/user_imports/result.rb new file mode 100644 index 000000000..c79197dbf --- /dev/null +++ b/app/value_objects/user_imports/result.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module UserImports + # Parser boundary contract: normalized headers plus a lazy, re-readable + # row stream and its total count. The parser does not touch the domain. + class Result + def initialize(headers:, rows:, total_rows:) + @headers = headers.freeze + @rows = rows + @total_rows = total_rows + freeze + end + + attr_reader :headers, :rows, :total_rows + end +end diff --git a/app/value_objects/user_imports/row.rb b/app/value_objects/user_imports/row.rb new file mode 100644 index 000000000..8c05b9549 --- /dev/null +++ b/app/value_objects/user_imports/row.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module UserImports + # One normalized spreadsheet row crossing the parser boundary. + # The parser never validates domain rules (SRP): a Row only transports + # data, tagged with its original spreadsheet line number. + class Row + def initialize(row_number:, attributes:) + @row_number = row_number + @attributes = attributes.freeze + freeze + end + + attr_reader :row_number, :attributes + end +end diff --git a/app/value_objects/user_imports/row_error.rb b/app/value_objects/user_imports/row_error.rb new file mode 100644 index 000000000..9366b0460 --- /dev/null +++ b/app/value_objects/user_imports/row_error.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module UserImports + # A typed validation error for a single attribute of a single row. + # Only {row_number, attribute, message} — never the whole row data + # (privacy + typing). + class RowError + def initialize(row_number:, attribute:, message:) + @row_number = row_number + @attribute = attribute + @message = message + freeze + end + + attr_reader :row_number, :attribute, :message + end +end diff --git a/app/value_objects/users/listing.rb b/app/value_objects/users/listing.rb new file mode 100644 index 000000000..2f95c0252 --- /dev/null +++ b/app/value_objects/users/listing.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Users + # Immutable result of Users::ListingQuery: the page of users plus its + # pagination metadata. Keeps the query's contract explicit (no loose hash). + class Listing + def initialize(users:, pagination:) + @users = users.to_a.freeze + @pagination = pagination.freeze + freeze + end + + attr_reader :users, :pagination + end +end diff --git a/app/value_objects/users/overview.rb b/app/value_objects/users/overview.rb new file mode 100644 index 000000000..e76140482 --- /dev/null +++ b/app/value_objects/users/overview.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Users + # Immutable frontier data for the admin dashboard. #to_inertia is the + # serialization contract shared with the real-time broadcast (Phase 6). + class Overview + def initialize(total_users:, by_role:) + @total_users = total_users + @by_role = by_role.freeze + freeze + end + + attr_reader :total_users, :by_role + + def to_inertia + { + total_users: total_users, + by_role: by_role + } + end + end +end diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb new file mode 100644 index 000000000..30df57925 --- /dev/null +++ b/app/views/layouts/application.html.erb @@ -0,0 +1,41 @@ + + + + <%= content_for(:title) || "Mini Rails8 Iam With Inertia Js" %> + + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= yield :head %> + + <%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %> + <%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %> + + + + + + <%= vite_stylesheet_tag "application" %> + <%= vite_react_refresh_tag %> + <%= vite_client_tag %> + <%= vite_typescript_tag "inertia.tsx" %> + <%= inertia_ssr_head %> + <%= vite_typescript_tag 'application' %> + + + + + + <%= yield %> + + diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb new file mode 100644 index 000000000..3aac9002e --- /dev/null +++ b/app/views/layouts/mailer.html.erb @@ -0,0 +1,13 @@ + + + + + + + + + <%= yield %> + + diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb new file mode 100644 index 000000000..37f0bddbd --- /dev/null +++ b/app/views/layouts/mailer.text.erb @@ -0,0 +1 @@ +<%= yield %> diff --git a/app/views/passwords_mailer/reset.html.erb b/app/views/passwords_mailer/reset.html.erb new file mode 100644 index 000000000..b3fa025b8 --- /dev/null +++ b/app/views/passwords_mailer/reset.html.erb @@ -0,0 +1,4 @@ +

+ You can reset your password within the next 15 minutes on + <%= link_to "this password reset page", edit_password_url(@user.generate_token_for(:password_reset)) %>. +

diff --git a/app/views/passwords_mailer/reset.text.erb b/app/views/passwords_mailer/reset.text.erb new file mode 100644 index 000000000..adaa18915 --- /dev/null +++ b/app/views/passwords_mailer/reset.text.erb @@ -0,0 +1,2 @@ +You can reset your password within the next 15 minutes on this password reset page: +<%= edit_password_url(@user.generate_token_for(:password_reset)) %> diff --git a/app/views/pwa/manifest.json.erb b/app/views/pwa/manifest.json.erb new file mode 100644 index 000000000..d3c90e32f --- /dev/null +++ b/app/views/pwa/manifest.json.erb @@ -0,0 +1,22 @@ +{ + "name": "MiniRails8IamWithInertiaJs", + "icons": [ + { + "src": "/icon.png", + "type": "image/png", + "sizes": "512x512" + }, + { + "src": "/icon.png", + "type": "image/png", + "sizes": "512x512", + "purpose": "maskable" + } + ], + "start_url": "/", + "display": "standalone", + "scope": "/", + "description": "MiniRails8IamWithInertiaJs.", + "theme_color": "red", + "background_color": "red" +} diff --git a/app/views/pwa/service-worker.js b/app/views/pwa/service-worker.js new file mode 100644 index 000000000..b3a13fb7b --- /dev/null +++ b/app/views/pwa/service-worker.js @@ -0,0 +1,26 @@ +// Add a service worker for processing Web Push notifications: +// +// self.addEventListener("push", async (event) => { +// const { title, options } = await event.data.json() +// event.waitUntil(self.registration.showNotification(title, options)) +// }) +// +// self.addEventListener("notificationclick", function(event) { +// event.notification.close() +// event.waitUntil( +// clients.matchAll({ type: "window" }).then((clientList) => { +// for (let i = 0; i < clientList.length; i++) { +// let client = clientList[i] +// let clientPath = (new URL(client.url)).pathname +// +// if (clientPath == event.notification.data.path && "focus" in client) { +// return client.focus() +// } +// } +// +// if (clients.openWindow) { +// return clients.openWindow(event.notification.data.path) +// } +// }) +// ) +// }) diff --git a/bin/brakeman b/bin/brakeman new file mode 100755 index 000000000..ace1c9ba0 --- /dev/null +++ b/bin/brakeman @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +ARGV.unshift("--ensure-latest") + +load Gem.bin_path("brakeman", "brakeman") diff --git a/bin/dev b/bin/dev new file mode 100755 index 000000000..2ed76b259 --- /dev/null +++ b/bin/dev @@ -0,0 +1,26 @@ +#!/usr/bin/env sh + +export PORT="${PORT:-3000}" + +if command -v overmind 1> /dev/null 2>&1 +then + overmind start -f Procfile.dev "$@" + exit $? +fi + +if command -v hivemind 1> /dev/null 2>&1 +then + echo "Hivemind is installed. Running the application with Hivemind..." + exec hivemind Procfile.dev "$@" + exit $? +fi + +if command -v foreman 1> /dev/null 2>&1 +then + foreman start -f Procfile.dev "$@" + exit $? +fi + +echo "Installing foreman..." +gem install foreman +foreman start -f Procfile.dev "$@" diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint new file mode 100755 index 000000000..57567d69b --- /dev/null +++ b/bin/docker-entrypoint @@ -0,0 +1,14 @@ +#!/bin/bash -e + +# Enable jemalloc for reduced memory usage and latency. +if [ -z "${LD_PRELOAD+x}" ]; then + LD_PRELOAD=$(find /usr/lib -name libjemalloc.so.2 -print -quit) + export LD_PRELOAD +fi + +# If running the rails server then create or migrate existing database +if [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then + ./bin/rails db:prepare +fi + +exec "${@}" diff --git a/bin/jobs b/bin/jobs new file mode 100755 index 000000000..dcf59f309 --- /dev/null +++ b/bin/jobs @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby + +require_relative "../config/environment" +require "solid_queue/cli" + +SolidQueue::Cli.start(ARGV) diff --git a/bin/kamal b/bin/kamal new file mode 100755 index 000000000..d9ba27670 --- /dev/null +++ b/bin/kamal @@ -0,0 +1,16 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'kamal' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("kamal", "kamal") diff --git a/bin/rails b/bin/rails new file mode 100755 index 000000000..efc037749 --- /dev/null +++ b/bin/rails @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/bin/rake b/bin/rake new file mode 100755 index 000000000..4fbf10b96 --- /dev/null +++ b/bin/rake @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "rake" +Rake.application.run diff --git a/bin/rubocop b/bin/rubocop new file mode 100755 index 000000000..40330c0ff --- /dev/null +++ b/bin/rubocop @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +# explicit rubocop config increases performance slightly while avoiding config confusion. +ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) + +load Gem.bin_path("rubocop", "rubocop") diff --git a/bin/setup b/bin/setup new file mode 100755 index 000000000..4ad8145a5 --- /dev/null +++ b/bin/setup @@ -0,0 +1,35 @@ +#!/usr/bin/env ruby +require "fileutils" + +APP_ROOT = File.expand_path("..", __dir__) + +def system!(*args) + system(*args, exception: true) +end + +FileUtils.chdir APP_ROOT do + # This script is a way to set up or update your development environment automatically. + # This script is idempotent, so that you can run it at any time and get an expectable outcome. + # Add necessary setup steps to this file. + + puts "== Installing dependencies ==" + system("bundle check") || system!("bundle install") + system! "npm install" + + # puts "\n== Copying sample files ==" + # unless File.exist?("config/database.yml") + # FileUtils.cp "config/database.yml.sample", "config/database.yml" + # end + + puts "\n== Preparing database ==" + system! "bin/rails db:prepare" + + puts "\n== Removing old logs and tempfiles ==" + system! "bin/rails log:clear tmp:clear" + + unless ARGV.include?("--skip-server") + puts "\n== Starting development server ==" + STDOUT.flush # flush the output before exec(2) so that it displays + exec "bin/dev" + end +end diff --git a/bin/thrust b/bin/thrust new file mode 100755 index 000000000..36bde2d83 --- /dev/null +++ b/bin/thrust @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("thruster", "thrust") diff --git a/bin/vite b/bin/vite new file mode 100755 index 000000000..9664d0d98 --- /dev/null +++ b/bin/vite @@ -0,0 +1,16 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'vite' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("vite_ruby", "vite") diff --git a/config.ru b/config.ru new file mode 100644 index 000000000..4a3c09a68 --- /dev/null +++ b/config.ru @@ -0,0 +1,6 @@ +# This file is used by Rack-based servers to start the application. + +require_relative "config/environment" + +run Rails.application +Rails.application.load_server diff --git a/config/application.rb b/config/application.rb new file mode 100644 index 000000000..5666b8084 --- /dev/null +++ b/config/application.rb @@ -0,0 +1,27 @@ +require_relative "boot" + +require "rails/all" + +# Require the gems listed in Gemfile, including any gems +# you've limited to :test, :development, or :production. +Bundler.require(*Rails.groups) + +module MiniRails8IamWithInertiaJs + class Application < Rails::Application + # Initialize configuration defaults for originally generated Rails version. + config.load_defaults 8.0 + + # Please, add to the `ignore` list any other `lib` subdirectories that do + # not contain `.rb` files, or that should not be reloaded or eager loaded. + # Common ones are `templates`, `generators`, or `middleware`, for example. + config.autoload_lib(ignore: %w[assets tasks]) + + # Configuration for the application, engines, and railties goes here. + # + # These settings can be overridden in specific environments using the files + # in config/environments, which are processed later. + # + # config.time_zone = "Central Time (US & Canada)" + # config.eager_load_paths << Rails.root.join("extras") + end +end diff --git a/config/boot.rb b/config/boot.rb new file mode 100644 index 000000000..988a5ddc4 --- /dev/null +++ b/config/boot.rb @@ -0,0 +1,4 @@ +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "bundler/setup" # Set up gems listed in the Gemfile. +require "bootsnap/setup" # Speed up boot time by caching expensive operations. diff --git a/config/brakeman.yml b/config/brakeman.yml new file mode 100644 index 000000000..063ff00a0 --- /dev/null +++ b/config/brakeman.yml @@ -0,0 +1 @@ +ignore_file: .brakeman.ignore diff --git a/config/cable.yml b/config/cable.yml new file mode 100644 index 000000000..b9adc5aa3 --- /dev/null +++ b/config/cable.yml @@ -0,0 +1,17 @@ +# Async adapter only works within the same process, so for manually triggering cable updates from a console, +# and seeing results in the browser, you must do so from the web console (running inside the dev process), +# not a terminal started via bin/rails console! Add "console" to any action or any ERB template view +# to make the web console appear. +development: + adapter: async + +test: + adapter: test + +production: + adapter: solid_cable + connects_to: + database: + writing: cable + polling_interval: 0.1.seconds + message_retention: 1.day diff --git a/config/cache.yml b/config/cache.yml new file mode 100644 index 000000000..19d490843 --- /dev/null +++ b/config/cache.yml @@ -0,0 +1,16 @@ +default: &default + store_options: + # Cap age of oldest cache entry to fulfill retention policies + # max_age: <%= 60.days.to_i %> + max_size: <%= 256.megabytes %> + namespace: <%= Rails.env %> + +development: + <<: *default + +test: + <<: *default + +production: + database: cache + <<: *default diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc new file mode 100644 index 000000000..b20848650 --- /dev/null +++ b/config/credentials.yml.enc @@ -0,0 +1 @@ +mReyuaRqlVTTZXbbXorhAbGNrpj1pcyPRyHo601YSC5Fn7LA3CW+FNOodW979dfwCT6ZvysAc77PPJN5vMj/p3qqpkQ2eRy1jjVFYJE559bJguITmoV0pUFEr2LDZt+XmMohpAeXjHtRUtLIvV0wSGJKuX8Awwj+5aya8gcu4CktVPak+LFBrTW1tZ2qRA+1o1u0nsCwV7bta9Wdr0fLMa2sGEa9kKZz7+8fGfptY5O7lkAr+JxYBRIape0XGXC5pSliTIy00NDh5EMbEN2hI/jIncUfTbmHu2nyRd8gnUC+dVwxuxji2RvrvjeGxFCIOoi4wE4UcSV/h/5JjpM+jQ+i0BSdi//kh9CNVjMbLHUM4ZOtUAOZ29jqMo3IJ6x2LddOPFPLwJ+YS1DwzkALoRPyU7D1dVtTMFJZocT/eAjUD/C0JCF38U18Zsz2i7nyklswBNdLbU/KEZbVzrnudM2EIKpWjeJTgt0Dv0sqkt9AihsS6cLKn5Gc--660bbinU6Saalqua--a5WBEwmaf5Cz62/Sv0ME+Q== \ No newline at end of file diff --git a/config/database.yml b/config/database.yml new file mode 100644 index 000000000..2640cb5f3 --- /dev/null +++ b/config/database.yml @@ -0,0 +1,41 @@ +# SQLite. Versions 3.8.0 and up are supported. +# gem install sqlite3 +# +# Ensure the SQLite 3 gem is defined in your Gemfile +# gem "sqlite3" +# +default: &default + adapter: sqlite3 + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + timeout: 5000 + +development: + <<: *default + database: storage/development.sqlite3 + +# Warning: The database defined as "test" will be erased and +# re-generated from your development database when you run "rake". +# Do not set this db to the same as development or production. +test: + <<: *default + database: storage/test.sqlite3 + + +# Store production database in the storage/ directory, which by default +# is mounted as a persistent Docker volume in config/deploy.yml. +production: + primary: + <<: *default + database: storage/production.sqlite3 + cache: + <<: *default + database: storage/production_cache.sqlite3 + migrations_paths: db/cache_migrate + queue: + <<: *default + database: storage/production_queue.sqlite3 + migrations_paths: db/queue_migrate + cable: + <<: *default + database: storage/production_cable.sqlite3 + migrations_paths: db/cable_migrate diff --git a/config/deploy.yml b/config/deploy.yml new file mode 100644 index 000000000..125267701 --- /dev/null +++ b/config/deploy.yml @@ -0,0 +1,118 @@ +# Name of your application. Used to uniquely configure containers. +service: <%= ENV.fetch("KAMAL_SERVICE", "mini_rails8_iam_with_inertia_js") %> + +# Name of the container image. +image: <%= ENV.fetch("DOCKER_IMAGE") %> + +# Deploy to these servers. +servers: + web: + - <%= ENV.fetch("APP_SERVER_HOST") %> + # job: + # hosts: + # - <%= ENV.fetch("APP_SERVER_HOST") %> + # cmd: bin/jobs + +# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server. +# Remove this section when using multiple web servers and ensure you terminate SSL at your load balancer. +# +# Note: If using Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption. +proxy: + ssl: true + host: <%= ENV.fetch("APP_HOST") %> + +# Credentials for your image host. +registry: + # Specify the registry server, if you're not using Docker Hub + # server: registry.digitalocean.com / ghcr.io / ... + username: <%= ENV.fetch("REGISTRY_USERNAME") %> + + # Always use an access token rather than real password when possible. + password: + - KAMAL_REGISTRY_PASSWORD + +# Inject ENV variables into containers (secrets come from .kamal/secrets). +env: + secret: + - RAILS_MASTER_KEY + clear: + # Run the Solid Queue Supervisor inside the web server's Puma process to do jobs. + # When you start using multiple servers, you should split out job processing to a dedicated machine. + SOLID_QUEUE_IN_PUMA: true + RAILS_SERVE_STATIC_FILES: true + + # Set number of processes dedicated to Solid Queue (default: 1) + # JOB_CONCURRENCY: 3 + + # Set number of cores available to the application on each server (default: 1). + # WEB_CONCURRENCY: 2 + + # Match this to any external database server to configure Active Record correctly + # Use mini_rails8_iam_with_inertia_js-db for a db accessory server on same machine via local kamal docker network. + # DB_HOST: 192.168.0.2 + + # Log everything from Rails + # RAILS_LOG_LEVEL: debug + +# Aliases are triggered with "bin/kamal ". You can overwrite arguments on invocation: +# "bin/kamal logs -r job" will tail logs from the first server in the job section. +aliases: + console: app exec --interactive --reuse "bin/rails console" + shell: app exec --interactive --reuse "bash" + logs: app logs -f + dbc: app exec --interactive --reuse "bin/rails dbconsole" + + +# Use a persistent storage volume for sqlite database files and local Active Storage files. +# Recommended to change this to a mounted volume path that is backed up off server. +volumes: + - "mini_rails8_iam_with_inertia_js_storage:/rails/storage" + + +# Bridge fingerprinted assets, like JS and CSS, between versions to avoid +# hitting 404 on in-flight requests. Combines all files from new and old +# version inside the asset_path. This app's fingerprinted artifacts are the +# Vite-compiled bundle, not a Rails asset pipeline output. +asset_path: /rails/public/vite + +# Configure the image builder. +builder: + arch: amd64 + + # # Build image via remote server (useful for faster amd64 builds on arm64 computers) + # remote: ssh://docker@docker-builder-server + # + # # Pass arguments and secrets to the Docker build process + # args: + # RUBY_VERSION: ruby-4.0.6 + # secrets: + # - GITHUB_TOKEN + # - RAILS_MASTER_KEY + +# Use a different ssh user than root +# ssh: +# user: app + +# Use accessory services (secrets come from .kamal/secrets). +# accessories: +# db: +# image: mysql:8.0 +# host: 192.168.0.2 +# # Change to 3306 to expose port to the world instead of just local network. +# port: "127.0.0.1:3306:3306" +# env: +# clear: +# MYSQL_ROOT_HOST: '%' +# secret: +# - MYSQL_ROOT_PASSWORD +# files: +# - config/mysql/production.cnf:/etc/mysql/my.cnf +# - db/production.sql:/docker-entrypoint-initdb.d/setup.sql +# directories: +# - data:/var/lib/mysql +# redis: +# image: redis:7.0 +# host: 192.168.0.2 +# port: 6379 +# directories: +# - data:/data diff --git a/config/environment.rb b/config/environment.rb new file mode 100644 index 000000000..cac531577 --- /dev/null +++ b/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/config/environments/development.rb b/config/environments/development.rb new file mode 100644 index 000000000..4cc21c4eb --- /dev/null +++ b/config/environments/development.rb @@ -0,0 +1,72 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Make code changes take effect immediately without server restart. + config.enable_reloading = true + + # Do not eager load code on boot. + config.eager_load = false + + # Show full error reports. + config.consider_all_requests_local = true + + # Enable server timing. + config.server_timing = true + + # Enable/disable Action Controller caching. By default Action Controller caching is disabled. + # Run rails dev:cache to toggle Action Controller caching. + if Rails.root.join("tmp/caching-dev.txt").exist? + config.action_controller.perform_caching = true + config.action_controller.enable_fragment_cache_logging = true + config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" } + else + config.action_controller.perform_caching = false + end + + # Change to :null_store to avoid any caching. + config.cache_store = :memory_store + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Don't care if the mailer can't send. + config.action_mailer.raise_delivery_errors = false + + # Make template changes take effect immediately. + config.action_mailer.perform_caching = false + + # Set localhost to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "localhost", port: 3000 } + + # Print deprecation notices to the Rails logger. + config.active_support.deprecation = :log + + # Raise an error on page load if there are pending migrations. + config.active_record.migration_error = :page_load + + # Highlight code that triggered database queries in logs. + config.active_record.verbose_query_logs = true + + # Append comments with runtime information tags to SQL queries in logs. + config.active_record.query_log_tags_enabled = true + + # Highlight code that enqueued background job in logs. + config.active_job.verbose_enqueue_logs = true + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + config.action_view.annotate_rendered_view_with_filenames = true + + # Uncomment if you wish to allow Action Cable access from any origin. + # config.action_cable.disable_request_forgery_protection = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true + + # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. + # config.generators.apply_rubocop_autocorrect_after_generate! +end diff --git a/config/environments/production.rb b/config/environments/production.rb new file mode 100644 index 000000000..48972ee92 --- /dev/null +++ b/config/environments/production.rb @@ -0,0 +1,102 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Code is not reloaded between requests. + config.enable_reloading = false + + # Eager load code on boot for better performance and memory savings (ignored by Rake tasks). + config.eager_load = true + + # Full error reports are disabled. + config.consider_all_requests_local = false + + # Turn on fragment caching in view templates. + config.action_controller.perform_caching = true + + # Cache assets for far-future expiry since they are all digest stamped. + config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } + config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present? + + # Enable serving of images, stylesheets, and JavaScripts from an asset server. + # config.asset_host = "http://assets.example.com" + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Assume all access to the app is happening through a SSL-terminating reverse proxy. + config.assume_ssl = true + + # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. + config.force_ssl = true + + # Skip http-to-https redirect for the default health check endpoint. + # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } + + # Log to STDOUT with the current request id as a default log tag. + config.log_tags = [ :request_id ] + config.logger = ActiveSupport::TaggedLogging.logger(STDOUT) + + # Change to "debug" to log everything (including potentially personally-identifiable information!) + config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") + + # Prevent health checks from clogging up the logs. + config.silence_healthcheck_path = "/up" + + # Don't log any deprecations. + config.active_support.report_deprecations = false + + # Replace the default in-process memory cache store with a durable alternative. + config.cache_store = :solid_cache_store + + # Replace the default in-process and non-durable queuing backend for Active Job. + config.active_job.queue_adapter = :solid_queue + config.solid_queue.connects_to = { database: { writing: :queue } } + + # Mail delivery: raise_delivery_errors is enabled when SMTP is configured below. + + # Absolute URL base for mailer links (password reset). Fail-fast: APP_HOST + # is required in production, supplied by the deploy environment (.env/Kamal). + config.action_mailer.default_url_options = { + host: ENV.fetch("APP_HOST"), + protocol: ENV.fetch("APP_PROTOCOL", "https"), + port: ENV["APP_PORT"] + }.compact + + # The mailer sender is required in production — no silently-wrong fallback. + raise "MAIL_FROM is required in production (see .env.example)" if ENV["MAIL_FROM"].blank? + + # SMTP transport is optional and activated only when SMTP_HOST is provided. + # Without it, password-reset emails cannot be delivered, so fail loudly. + if ENV["SMTP_HOST"].present? + config.action_mailer.delivery_method = :smtp + config.action_mailer.raise_delivery_errors = true + config.action_mailer.smtp_settings = { + address: ENV.fetch("SMTP_HOST"), + port: Integer(ENV.fetch("SMTP_PORT", 587)), + user_name: ENV["SMTP_USER"], + password: ENV["SMTP_PASSWORD"], + authentication: (ENV.fetch("SMTP_AUTH", "plain") || "plain").to_sym, + starttls_auto: ENV.fetch("SMTP_STARTTLS_AUTO", "true") == "true" + } + else + config.action_mailer.delivery_method = :test + Rails.logger&.warn "SMTP_HOST is not set: mail delivery is disabled (password reset will not reach recipients)." + end + + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to + # the I18n.default_locale when a translation cannot be found). + config.i18n.fallbacks = true + + # Do not dump schema after migrations. + config.active_record.dump_schema_after_migration = false + + # Only use :id for inspections in production. + config.active_record.attributes_for_inspect = [ :id ] + + # Enable DNS rebinding protection and other `Host` header attacks. + config.hosts = [ ENV.fetch("APP_HOST") ] + # Allow the health check to be reached without a matching Host header. + config.host_authorization = { exclude: ->(request) { request.path == "/up" } } +end diff --git a/config/environments/test.rb b/config/environments/test.rb new file mode 100644 index 000000000..f3cc198cf --- /dev/null +++ b/config/environments/test.rb @@ -0,0 +1,53 @@ +# The test environment is used exclusively to run your application's +# test suite. You never need to work with it otherwise. Remember that +# your test database is "scratch space" for the test suite and is wiped +# and recreated between test runs. Don't rely on the data there! + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # While tests run files are not watched, reloading is not necessary. + config.enable_reloading = false + + # Eager loading loads your entire application. When running a single test locally, + # this is usually not necessary, and can slow down your test suite. However, it's + # recommended that you enable it in continuous integration systems to ensure eager + # loading is working properly before deploying your code. + config.eager_load = ENV["CI"].present? + + # Configure public file server for tests with cache-control for performance. + config.public_file_server.headers = { "cache-control" => "public, max-age=3600" } + + # Show full error reports. + config.consider_all_requests_local = true + config.cache_store = :memory_store + + # Render exception templates for rescuable exceptions and raise for other exceptions. + config.action_dispatch.show_exceptions = :rescuable + + # Disable request forgery protection in test environment. + config.action_controller.allow_forgery_protection = false + + # Store uploaded files on the local file system in a temporary directory. + config.active_storage.service = :test + + # Tell Action Mailer not to deliver emails to the real world. + # The :test delivery method accumulates sent emails in the + # ActionMailer::Base.deliveries array. + config.action_mailer.delivery_method = :test + + # Set host to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "example.com" } + + # Print deprecation notices to the stderr. + config.active_support.deprecation = :stderr + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true +end diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb new file mode 100644 index 000000000..0b3195444 --- /dev/null +++ b/config/initializers/content_security_policy.rb @@ -0,0 +1,34 @@ +# Be sure to restart your server when you modify this file. + +# Define an application-wide content security policy. +# See the Securing Rails Applications Guide for more information: +# https://guides.rubyonrails.org/security.html#content-security-policy-header + +# Rails.application.configure do +# config.content_security_policy do |policy| +# policy.default_src :self, :https +# policy.font_src :self, :https, :data +# policy.img_src :self, :https, :data +# policy.object_src :none +# policy.script_src :self, :https +# Allow @vite/client to hot reload javascript changes in development +# policy.script_src *policy.script_src, :unsafe_eval, "http://#{ ViteRuby.config.host_with_port }" if Rails.env.development? + +# You may need to enable this in production as well depending on your setup. +# policy.script_src *policy.script_src, :blob if Rails.env.test? + +# policy.style_src :self, :https +# Allow @vite/client to hot reload style changes in development +# policy.style_src *policy.style_src, :unsafe_inline if Rails.env.development? + +# # Specify URI for violation reports +# # policy.report_uri "/csp-violation-report-endpoint" +# end +# +# # Generate session nonces for permitted importmap, inline scripts, and inline styles. +# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } +# config.content_security_policy_nonce_directives = %w(script-src style-src) +# +# # Report violations without enforcing the policy. +# # config.content_security_policy_report_only = true +# end diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb new file mode 100644 index 000000000..c0b717f7e --- /dev/null +++ b/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,8 @@ +# Be sure to restart your server when you modify this file. + +# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. +# Use this to limit dissemination of sensitive information. +# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. +Rails.application.config.filter_parameters += [ + :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc +] diff --git a/config/initializers/inertia_rails.rb b/config/initializers/inertia_rails.rb new file mode 100644 index 000000000..4349da9f7 --- /dev/null +++ b/config/initializers/inertia_rails.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +InertiaRails.configure do |config| + config.version = ViteRuby.digest + config.encrypt_history = true + config.always_include_errors_hash = true + config.use_script_element_for_initial_page = true + config.use_data_inertia_head_attribute = true +end diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb new file mode 100644 index 000000000..3860f659e --- /dev/null +++ b/config/initializers/inflections.rb @@ -0,0 +1,16 @@ +# Be sure to restart your server when you modify this file. + +# Add new inflection rules using the following format. Inflections +# are locale specific, and you may define rules for as many different +# locales as you wish. All of these examples are active by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.plural /^(ox)$/i, "\\1en" +# inflect.singular /^(ox)en/i, "\\1" +# inflect.irregular "person", "people" +# inflect.uncountable %w( fish sheep ) +# end + +# These inflection rules are supported but not enabled by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.acronym "RESTful" +# end diff --git a/config/initializers/sqlite_wal.rb b/config/initializers/sqlite_wal.rb new file mode 100644 index 000000000..9ec907113 --- /dev/null +++ b/config/initializers/sqlite_wal.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +ActiveSupport.on_load(:active_record_sqlite3adapter) do + prepend(Module.new { + def configure_connection + super + # Switch SQLite to Write-Ahead Logging. + # + # Instead of overwriting the main .sqlite3 file on every transaction + # (the default rollback-journal mode, which blocks readers while writing), + # SQLite appends changes to a sidecar -wal file. This keeps the main + # database readable by many concurrent processes (e.g. the parallelized + # test suite and Workers) while a single writer is active, and only + # checkpoints back into the main file periodically. + # + # raw_connection is the underlying SQLite3::Database exposed by the + # adapter, used here because Active Record does not expose this pragma. + # journal_mode is per-database and sticky, but running it on every + # connection (dev/test/prod, including the cache/queue/cable dbs) makes + # sure a connection never starts in the slower rollback mode. + # + # Paired with busy_timeout from config/database.yml (timeout: 5000), + # which prevents SQLITE_BUSY errors when processes contend for the + # single writer slot that WAL allows. + raw_connection.execute("PRAGMA journal_mode=WAL") + end + }) +end diff --git a/config/locales/en.yml b/config/locales/en.yml new file mode 100644 index 000000000..d23b4c5e7 --- /dev/null +++ b/config/locales/en.yml @@ -0,0 +1,18 @@ +en: + hello: "Hello world" + activemodel: + attributes: + users/form: + full_name: "Full name" + email_address: "Email address" + avatar_url: "Avatar URL" + password: "Password" + password_confirmation: "Password confirmation" + errors: + messages: + avatar_required: "Provide an avatar by upload or a remote URL" + invalid_http_url: "must be a valid http(s) URL" + # Shared error messages resolving for both ActiveModel forms and AR models. + errors: + messages: + unsupported_format: "must be a .csv, .xlsx or .xls file" diff --git a/config/puma.rb b/config/puma.rb new file mode 100644 index 000000000..a248513b2 --- /dev/null +++ b/config/puma.rb @@ -0,0 +1,41 @@ +# This configuration file will be evaluated by Puma. The top-level methods that +# are invoked here are part of Puma's configuration DSL. For more information +# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. +# +# Puma starts a configurable number of processes (workers) and each process +# serves each request in a thread from an internal thread pool. +# +# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You +# should only set this value when you want to run 2 or more workers. The +# default is already 1. +# +# The ideal number of threads per worker depends both on how much time the +# application spends waiting for IO operations and on how much you wish to +# prioritize throughput over latency. +# +# As a rule of thumb, increasing the number of threads will increase how much +# traffic a given process can handle (throughput), but due to CRuby's +# Global VM Lock (GVL) it has diminishing returns and will degrade the +# response time (latency) of the application. +# +# The default is set to 3 threads as it's deemed a decent compromise between +# throughput and latency for the average Rails application. +# +# Any libraries that use a connection pool or another resource pool should +# be configured to provide at least as many connections as the number of +# threads. This includes Active Record's `pool` parameter in `database.yml`. +threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) +threads threads_count, threads_count + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +port ENV.fetch("PORT", 3000) + +# Allow puma to be restarted by `bin/rails restart` command. +plugin :tmp_restart + +# Run the Solid Queue supervisor inside of Puma for single-server deployments +plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"] + +# Specify the PID file. Defaults to tmp/pids/server.pid in development. +# In other environments, only set the PID file if requested. +pidfile ENV["PIDFILE"] if ENV["PIDFILE"] diff --git a/config/queue.yml b/config/queue.yml new file mode 100644 index 000000000..6b1436086 --- /dev/null +++ b/config/queue.yml @@ -0,0 +1,18 @@ +default: &default + dispatchers: + - polling_interval: 1 + batch_size: 500 + workers: + - queues: "*" + threads: 3 + processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %> + polling_interval: 1 + +development: + <<: *default + +test: + <<: *default + +production: + <<: *default diff --git a/config/recurring.yml b/config/recurring.yml new file mode 100644 index 000000000..b4207f9b0 --- /dev/null +++ b/config/recurring.yml @@ -0,0 +1,15 @@ +# examples: +# periodic_cleanup: +# class: CleanSoftDeletedRecordsJob +# queue: background +# args: [ 1000, { batch_size: 500 } ] +# schedule: every hour +# periodic_cleanup_with_command: +# command: "SoftDeletedRecord.due.delete_all" +# priority: 2 +# schedule: at 5am every day + +production: + clear_solid_queue_finished_jobs: + command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)" + schedule: every hour at minute 12 diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 000000000..874d62054 --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,32 @@ +Rails.application.routes.draw do + # Authentication + resource :session, only: %i[new create destroy] + resources :passwords, param: :token, only: %i[new create edit update] + + # Registration (unauthenticated) + resource :registration, only: %i[new create], path: "register" + + # Profile (authenticated) + resource :profile, only: %i[show edit update destroy] + + # Admin (Phase 3 CRUD) + namespace :admin do + root "dashboard#index" + resources :users, only: %i[index new create edit update destroy] do + member do + patch :toggle_role + end + end + resources :user_imports, only: %i[index new create show] + end + + # Redirect to localhost from 127.0.0.1 to use same IP address with Vite server + constraints(host: "127.0.0.1") do + get "(*path)", to: redirect { |params, req| "#{req.protocol}localhost:#{req.port}/#{params[:path]}" } + end + + root "root#index" + + # Health check + get "up" => "rails/health#show", as: :rails_health_check +end diff --git a/config/storage.yml b/config/storage.yml new file mode 100644 index 000000000..4942ab669 --- /dev/null +++ b/config/storage.yml @@ -0,0 +1,34 @@ +test: + service: Disk + root: <%= Rails.root.join("tmp/storage") %> + +local: + service: Disk + root: <%= Rails.root.join("storage") %> + +# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) +# amazon: +# service: S3 +# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> +# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> +# region: us-east-1 +# bucket: your_own_bucket-<%= Rails.env %> + +# Remember not to checkin your GCS keyfile to a repository +# google: +# service: GCS +# project: your_project +# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> +# bucket: your_own_bucket-<%= Rails.env %> + +# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) +# microsoft: +# service: AzureStorage +# storage_account_name: your_account_name +# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> +# container: your_container_name-<%= Rails.env %> + +# mirror: +# service: Mirror +# primary: local +# mirrors: [ amazon, google, microsoft ] diff --git a/config/vite.json b/config/vite.json new file mode 100644 index 000000000..8a2855f86 --- /dev/null +++ b/config/vite.json @@ -0,0 +1,17 @@ +{ + "all": { + "sourceCodeDir": "app/frontend", + "watchAdditionalPaths": [] + }, + "development": { + "autoBuild": true, + "skipProxy": true, + "publicOutputDir": "vite-dev", + "port": 3036 + }, + "test": { + "autoBuild": true, + "publicOutputDir": "vite-test", + "port": 3037 + } +} diff --git a/coverage/.history.json b/coverage/.history.json new file mode 100644 index 000000000..8f119693d --- /dev/null +++ b/coverage/.history.json @@ -0,0 +1,235 @@ +{ + "simplecov_history": { + "format_version": 1, + "entries": [ + { + "created_at": "2026-09-08T18:55:55Z", + "branch": "rewrite", + "commit": "3b4de2cbce931933f6e4ec2163eb8436fa6b157c", + "totals": { + "line": 99.86 + }, + "groups": { + "Controllers": { + "line": 99.51 + }, + "Channels": { + "line": 100.0 + }, + "Forms": { + "line": 100.0 + }, + "Helpers": { + "line": 100.0 + }, + "Jobs": { + "line": 100.0 + }, + "Mailers": { + "line": 100.0 + }, + "Models": { + "line": 100.0 + }, + "Parsers": { + "line": 100.0 + }, + "Policies": { + "line": 100.0 + }, + "Presenters": { + "line": 100.0 + }, + "Queries": { + "line": 100.0 + }, + "Services": { + "line": 100.0 + }, + "Value Objects": { + "line": 100.0 + }, + "Ungrouped": { + "line": 100.0 + } + }, + "files": { + "app/channels/application_cable/channel.rb": { + "line": 100.0 + }, + "app/channels/application_cable/connection.rb": { + "line": 100.0 + }, + "app/channels/dashboard_channel/channel.rb": { + "line": 100.0 + }, + "app/channels/import_progress_channel/channel.rb": { + "line": 100.0 + }, + "app/controllers/admin/base_controller.rb": { + "line": 100.0 + }, + "app/controllers/admin/dashboard_controller.rb": { + "line": 100.0 + }, + "app/controllers/admin/user_imports_controller.rb": { + "line": 100.0 + }, + "app/controllers/admin/users_controller.rb": { + "line": 100.0 + }, + "app/controllers/application_controller.rb": { + "line": 100.0 + }, + "app/controllers/concerns/authentication.rb": { + "line": 96.77 + }, + "app/controllers/concerns/authorization.rb": { + "line": 100.0 + }, + "app/controllers/passwords_controller.rb": { + "line": 100.0 + }, + "app/controllers/profiles_controller.rb": { + "line": 100.0 + }, + "app/controllers/registrations_controller.rb": { + "line": 100.0 + }, + "app/controllers/root_controller.rb": { + "line": 100.0 + }, + "app/controllers/sessions_controller.rb": { + "line": 100.0 + }, + "app/forms/admin/user_form.rb": { + "line": 100.0 + }, + "app/forms/users/form.rb": { + "line": 100.0 + }, + "app/forms/users/password_reset_form.rb": { + "line": 100.0 + }, + "app/forms/users/profile_form.rb": { + "line": 100.0 + }, + "app/forms/users/registration_form.rb": { + "line": 100.0 + }, + "app/helpers/application_helper.rb": { + "line": 100.0 + }, + "app/jobs/application_job.rb": { + "line": 100.0 + }, + "app/jobs/user_imports/job.rb": { + "line": 100.0 + }, + "app/mailers/application_mailer.rb": { + "line": 100.0 + }, + "app/mailers/passwords_mailer.rb": { + "line": 100.0 + }, + "app/models/application_record.rb": { + "line": 100.0 + }, + "app/models/current.rb": { + "line": 100.0 + }, + "app/models/session.rb": { + "line": 100.0 + }, + "app/models/user.rb": { + "line": 100.0 + }, + "app/models/user_import.rb": { + "line": 100.0 + }, + "app/parsers/user_imports/parser.rb": { + "line": 100.0 + }, + "app/parsers/user_imports/parser/roo.rb": { + "line": 100.0 + }, + "app/policies/users/policy.rb": { + "line": 100.0 + }, + "app/presenters/user_import_presenter.rb": { + "line": 100.0 + }, + "app/presenters/user_presenter.rb": { + "line": 100.0 + }, + "app/queries/dashboard/totals_query.rb": { + "line": 100.0 + }, + "app/queries/user_imports/listing_query.rb": { + "line": 100.0 + }, + "app/queries/users/listing_query.rb": { + "line": 100.0 + }, + "app/services/dashboard/publisher.rb": { + "line": 100.0 + }, + "app/services/user_imports/processor.rb": { + "line": 100.0 + }, + "app/services/user_imports/progress_broadcaster.rb": { + "line": 100.0 + }, + "app/services/users/account_closer.rb": { + "line": 100.0 + }, + "app/services/users/role_toggler.rb": { + "line": 100.0 + }, + "app/value_objects/user_imports/listing.rb": { + "line": 100.0 + }, + "app/value_objects/user_imports/result.rb": { + "line": 100.0 + }, + "app/value_objects/user_imports/row.rb": { + "line": 100.0 + }, + "app/value_objects/user_imports/row_error.rb": { + "line": 100.0 + }, + "app/value_objects/users/listing.rb": { + "line": 100.0 + }, + "app/value_objects/users/overview.rb": { + "line": 100.0 + }, + "config/environment.rb": { + "line": 100.0 + }, + "config/environments/test.rb": { + "line": 100.0 + }, + "config/initializers/content_security_policy.rb": { + "line": 100.0 + }, + "config/initializers/filter_parameter_logging.rb": { + "line": 100.0 + }, + "config/initializers/inertia_rails.rb": { + "line": 100.0 + }, + "config/initializers/inflections.rb": { + "line": 100.0 + }, + "config/initializers/sqlite_wal.rb": { + "line": 100.0 + }, + "config/routes.rb": { + "line": 100.0 + } + } + } + ] + } +} diff --git a/coverage/.last_run.json b/coverage/.last_run.json new file mode 100644 index 000000000..ee9de444b --- /dev/null +++ b/coverage/.last_run.json @@ -0,0 +1,5 @@ +{ + "result": { + "line": 99.86 + } +} diff --git a/coverage/.report_stamp b/coverage/.report_stamp new file mode 100644 index 000000000..e69de29bb diff --git a/coverage/.resultset.json b/coverage/.resultset.json new file mode 100644 index 000000000..3e5f7e8c2 --- /dev/null +++ b/coverage/.resultset.json @@ -0,0 +1 @@ +{"Integration Tests":{"coverage":{"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/controllers/application_controller.rb":{"lines":[1,1,1,null,1,null,1,null,1,21,null,null,1,2,null,1,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/controllers/concerns/authentication.rb":{"lines":[1,1,null,1,1,1,null,null,1,1,1,null,null,null,1,1,0,null,null,1,5,null,null,1,5,null,null,1,5,null,null,1,3,3,null,null,1,4,null,null,1,2,null,null,1,4,4,4,null,null,null,1,1,1,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/controllers/profiles_controller.rb":{"lines":[null,null,1,null,null,null,1,1,null,null,null,null,null,null,null,null,null,null,1,0,null,null,null,null,null,1,0,null,0,0,null,0,null,null,null,null,null,null,null,1,0,0,0,null,null,1,null,1,0,null,null,1,null,0,null,null,null,null,null,1,0,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/controllers/root_controller.rb":{"lines":[null,null,1,1,0,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/controllers/sessions_controller.rb":{"lines":[1,1,2,null,1,1,null,null,null,null,null,null,null,null,1,15,4,4,null,11,null,null,null,1,1,1,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/helpers/application_helper.rb":{"lines":[1,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/models/application_record.rb":{"lines":[1,1,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/models/current.rb":{"lines":[1,1,1,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/models/session.rb":{"lines":[1,1,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/models/user.rb":{"lines":[1,1,1,1,1,null,1,null,1,0,null,null,16,1,null,1,0,0,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/models/user_import.rb":{"lines":[1,1,1,null,1,null,1,1,null,null,null,1,0,null,0,null,null,1,null,1,0,null,null,null,null,1,0,null,0,0,null,0,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/policies/users/policy.rb":{"lines":[null,null,1,null,null,1,1,2,null,null,1,0,null,null,1,2,null,null,1,2,null,null,1,0,0,null,0,null,null,1,0,0,null,0,null,null,1,null,1,null,1,0,null,null,1,0,null,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/presenters/user_presenter.rb":{"lines":[null,null,1,1,null,1,2,null,null,1,null,2,null,null,null,null,null,null,null,1,null,1,null,1,null,1,2,2,null,0,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/config/application.rb":{"lines":[1,null,1,null,null,null,1,null,1,1,null,1,null,null,null,null,1,null,null,null,null,null,null,null,null,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/config/environment.rb":{"lines":[null,1,null,null,1]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/config/environments/test.rb":{"lines":[null,null,null,null,null,1,null,null,null,1,null,null,null,null,null,1,null,null,1,null,null,1,1,null,null,1,null,null,1,null,null,1,null,null,null,null,1,null,null,1,null,null,1,null,null,null,null,null,null,null,null,1,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/config/initializers/content_security_policy.rb":{"lines":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/config/initializers/filter_parameter_logging.rb":{"lines":[null,null,null,null,null,1,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/config/initializers/inertia_rails.rb":{"lines":[null,null,1,1,1,1,1,1,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/config/initializers/inflections.rb":{"lines":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/config/initializers/sqlite_wal.rb":{"lines":[null,null,1,1,1,3,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,3,null,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/config/routes.rb":{"lines":[1,null,1,1,null,null,1,null,null,1,null,null,1,1,1,1,1,null,null,1,null,null,null,1,1,null,null,1,null,null,1,null]}},"timestamp":1788824742.1834452,"run_id":"28b0a3ab-4c93-4d98-b62d-923c53460e7f","worker_id":"310127","tracked_files":["/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/channels/application_cable/channel.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/channels/application_cable/connection.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/channels/dashboard_channel/channel.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/channels/import_progress_channel/channel.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/controllers/admin/base_controller.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/controllers/admin/dashboard_controller.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/controllers/admin/user_imports_controller.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/controllers/admin/users_controller.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/controllers/concerns/authorization.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/controllers/passwords_controller.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/controllers/registrations_controller.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/forms/admin/user_form.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/forms/users/form.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/forms/users/password_reset_form.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/forms/users/profile_form.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/forms/users/registration_form.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/jobs/application_job.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/jobs/user_imports/job.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/mailers/application_mailer.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/mailers/passwords_mailer.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/parsers/user_imports/parser/roo.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/parsers/user_imports/parser.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/presenters/user_import_presenter.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/queries/dashboard/totals_query.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/queries/user_imports/listing_query.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/queries/users/listing_query.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/services/dashboard/publisher.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/services/user_imports/processor.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/services/user_imports/progress_broadcaster.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/services/users/account_closer.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/services/users/role_toggler.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/value_objects/user_imports/listing.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/value_objects/user_imports/result.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/value_objects/user_imports/row.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/value_objects/user_imports/row_error.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/value_objects/users/listing.rb","/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/value_objects/users/overview.rb"]},"Minitest":{"coverage":{"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/channels/application_cable/channel.rb":{"lines":[null,null,1,1,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/channels/application_cable/connection.rb":{"lines":[1,1,1,null,1,3,null,null,1,1,3,1,null,null,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/channels/dashboard_channel/channel.rb":{"lines":[null,null,1,1,1,2,null,1,null,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/channels/import_progress_channel/channel.rb":{"lines":[null,null,1,1,1,3,3,null,1,null,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/controllers/admin/base_controller.rb":{"lines":[null,null,1,1,1,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/controllers/admin/dashboard_controller.rb":{"lines":[null,null,1,1,1,4,null,4,null,null,null,null,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/controllers/admin/user_imports_controller.rb":{"lines":[null,null,1,null,null,null,1,1,1,null,1,null,null,null,null,null,null,1,1,null,null,null,null,null,1,2,2,null,2,1,1,null,null,1,null,null,null,null,null,null,null,1,1,null,1,null,null,null,null,null,1,null,1,null,4,null,null,null,null,null,1,2,null,null,1,3,6,null,null,null,null,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/controllers/admin/users_controller.rb":{"lines":[null,null,1,null,null,1,1,3,null,null,null,null,null,3,6,null,null,null,null,null,null,1,1,null,null,null,null,1,4,null,4,1,1,null,3,null,null,null,null,null,null,null,1,1,1,null,null,null,null,null,null,1,3,3,null,3,2,2,null,1,null,null,null,null,null,null,null,null,1,2,null,2,1,1,1,null,1,null,null,null,1,3,null,3,2,2,null,1,null,null,null,1,null,1,5,null,null,1,null,9,null,null,null,null,null,1,7,null,null,1,6,null,null,null,null,null,null,1,4,null,null,1,null,1,null,null,null,null,null,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/controllers/application_controller.rb":{"lines":[1,1,1,null,1,null,1,null,1,161,null,null,1,38,null,22,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/controllers/concerns/authentication.rb":{"lines":[1,1,null,1,1,1,null,null,1,1,3,null,null,null,1,1,0,null,null,1,51,null,null,1,51,null,null,1,51,null,null,1,7,7,null,null,1,47,null,null,1,45,null,null,1,46,46,46,null,null,null,1,5,5,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/controllers/concerns/authorization.rb":{"lines":[null,null,1,1,null,1,1,1,null,null,1,null,1,30,null,null,1,30,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/controllers/passwords_controller.rb":{"lines":[1,1,1,null,1,1,null,null,null,null,1,2,1,null,null,2,null,null,1,1,null,null,null,null,null,1,2,null,2,1,null,1,null,null,null,null,null,null,null,1,1,4,null,1,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/controllers/profiles_controller.rb":{"lines":[null,null,1,null,null,null,1,3,null,null,null,null,null,null,null,null,null,null,1,1,null,null,null,null,null,1,4,null,4,3,null,1,null,null,null,null,null,null,null,1,1,1,1,null,null,1,null,1,4,null,null,1,null,1,null,null,null,null,null,1,1,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/controllers/registrations_controller.rb":{"lines":[null,null,1,1,1,1,null,1,1,null,null,null,null,null,null,null,1,14,null,14,3,3,null,11,null,null,null,null,null,null,null,null,null,null,1,null,1,14,null,null,1,11,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/controllers/root_controller.rb":{"lines":[null,null,1,1,1,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/controllers/sessions_controller.rb":{"lines":[1,1,2,null,1,1,null,null,null,null,null,null,null,null,1,54,43,43,null,11,null,null,null,1,4,4,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/forms/admin/user_form.rb":{"lines":[null,null,1,null,null,null,1,1,1,1,null,1,1,1,1,1,null,1,null,1,11,11,null,6,null,null,1,34,null,null,1,51,null,null,1,9,null,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/forms/users/form.rb":{"lines":[null,null,1,null,null,null,1,1,1,null,1,1,null,1,1,1,null,null,null,1,null,null,null,1,null,1,1,125,null,1,126,126,null,null,1,100,null,null,1,26,null,null,1,null,1,126,124,null,106,106,null,15,15,15,null,null,null,null,1,126,null,70,70,null,2,2,null,null,1,106,106,null,null,1,null,103,null,null,null,null,1,200,null,null,1,106,1,1,105,33,33,null,null,null,1,124,46,45,2,null,2,null,null,1,46,null,1,null,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/forms/users/password_reset_form.rb":{"lines":[null,null,1,null,null,null,null,null,1,1,1,1,null,1,1,1,null,1,null,6,null,null,null,6,null,null,1,null,1,null,3,null,null,null,null,1,6,null,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/forms/users/profile_form.rb":{"lines":[null,null,1,null,null,1,1,1,null,1,1,1,null,1,null,1,8,null,2,null,null,1,33,null,null,1,4,null,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/forms/users/registration_form.rb":{"lines":[null,null,1,null,null,1,1,1,null,1,1,1,null,1,92,null,null,1,null,1,84,null,null,null,null,null,1,90,null,89,null,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/helpers/application_helper.rb":{"lines":[1,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/jobs/application_job.rb":{"lines":[1,null,null,null,null,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/jobs/user_imports/job.rb":{"lines":[null,null,1,null,null,1,1,null,1,1,1,null,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/mailers/application_mailer.rb":{"lines":[1,null,null,1,1,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/mailers/passwords_mailer.rb":{"lines":[1,1,1,1,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/models/application_record.rb":{"lines":[1,1,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/models/current.rb":{"lines":[1,1,1,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/models/session.rb":{"lines":[1,1,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/models/user.rb":{"lines":[1,1,1,1,1,null,1,null,1,11,null,null,364,1,null,1,3,1,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/models/user_import.rb":{"lines":[1,1,1,null,1,null,1,1,null,null,null,1,18,null,16,null,null,1,null,1,15,null,null,null,null,1,29,null,26,26,null,2,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/parsers/user_imports/parser.rb":{"lines":[null,null,1,null,null,null,1,null,1,null,null,null,null,null,null,1,11,11,null,9,null,null,null,1,1,null,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/parsers/user_imports/parser/roo.rb":{"lines":[null,null,1,1,null,null,null,null,null,1,null,1,null,null,null,null,null,null,null,null,null,null,null,null,null,1,null,null,null,1,4,4,4,null,null,null,1,null,1,4,4,1,1,1,1,1,null,null,3,null,null,null,1,null,4,1,null,3,null,4,null,null,1,4,4,4,null,4,5,null,null,4,null,null,1,4,10,10,null,null,null,null,null,1,4,8,8,null,7,null,null,null,1,5,12,null,null,null,1,18,null,null,1,10,null,10,null,null,1,12,null,11,null,null,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/policies/users/policy.rb":{"lines":[null,null,1,null,null,1,1,77,null,null,1,2,null,null,1,90,null,null,1,79,null,null,1,6,4,null,3,null,null,1,7,5,null,4,null,null,1,null,1,null,1,9,null,null,1,5,null,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/presenters/user_import_presenter.rb":{"lines":[null,null,null,null,null,1,1,9,null,null,1,null,9,null,null,null,null,null,null,null,null,null,null,null,null,null,1,null,1,null,1,null,1,9,null,null,1,9,null,null,1,9,null,null,1,9,null,null,1,9,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/presenters/user_presenter.rb":{"lines":[null,null,1,1,null,1,38,null,null,1,null,38,null,null,null,null,null,null,null,1,null,1,null,1,null,1,38,38,null,1,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/queries/dashboard/totals_query.rb":{"lines":[null,null,1,null,null,1,1,20,null,null,1,20,null,null,1,20,null,null,null,null,null,1,null,1,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/queries/user_imports/listing_query.rb":{"lines":[null,null,1,null,null,1,1,1,null,1,1,null,null,1,1,1,null,null,1,1,1,1,null,1,null,null,null,null,null,null,null,null,null,null,null,null,1,null,1,null,1,1,1,1,null,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/queries/users/listing_query.rb":{"lines":[null,null,1,null,null,1,1,1,null,1,11,null,null,1,11,11,11,null,null,1,11,11,null,11,null,null,null,null,null,null,null,null,null,null,null,null,1,null,1,null,1,11,null,4,4,null,null,1,11,11,11,null,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/services/dashboard/publisher.rb":{"lines":[null,null,1,null,null,null,1,1,null,1,8,null,null,1,12,12,null,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/services/user_imports/processor.rb":{"lines":[null,null,1,null,null,null,1,1,null,1,null,1,null,null,null,null,null,6,6,6,6,null,null,null,null,null,null,null,null,1,6,5,null,null,null,null,null,null,5,5,7,7,null,null,5,null,1,null,null,1,null,1,null,1,7,76,null,null,null,1,69,69,null,null,null,null,null,null,null,null,69,67,null,2,2,null,null,null,1,2,2,2,2,2,null,null,null,null,null,null,1,5,5,5,null,null,5,5,null,null,null,null,1,1,1,null,null,null,null,1,null,null,1,1,1,null,null,1,69,null,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/services/user_imports/progress_broadcaster.rb":{"lines":[null,null,1,null,null,null,1,1,7,null,null,1,null,1,null,7,null,null,null,null,null,null,null,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/services/users/account_closer.rb":{"lines":[null,null,1,null,null,null,1,1,5,null,null,1,5,null,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/services/users/role_toggler.rb":{"lines":[null,null,1,null,null,null,null,null,1,1,2,null,null,1,2,null,null,null,null,null,null,null,null,2,null,null,1,null,1,2,null,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/value_objects/user_imports/listing.rb":{"lines":[null,null,1,null,null,1,1,1,1,1,null,null,1,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/value_objects/user_imports/result.rb":{"lines":[null,null,1,null,null,1,1,8,8,8,8,null,null,1,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/value_objects/user_imports/row.rb":{"lines":[null,null,1,null,null,null,1,1,72,72,72,null,null,1,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/value_objects/user_imports/row_error.rb":{"lines":[null,null,1,null,null,null,1,1,2,2,2,2,null,null,1,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/value_objects/users/listing.rb":{"lines":[null,null,1,null,null,1,1,11,11,11,null,null,1,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/app/value_objects/users/overview.rb":{"lines":[null,null,1,null,null,1,1,21,21,21,null,null,1,null,1,null,19,null,null,null,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/config/environment.rb":{"lines":[null,1,null,null,1]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/config/environments/test.rb":{"lines":[null,null,null,null,null,1,null,null,null,1,null,null,null,null,null,1,null,null,1,null,null,1,1,null,null,1,null,null,1,null,null,1,null,null,null,null,1,null,null,1,null,null,1,null,null,null,null,null,null,null,null,1,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/config/initializers/content_security_policy.rb":{"lines":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/config/initializers/filter_parameter_logging.rb":{"lines":[null,null,null,null,null,1,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/config/initializers/inertia_rails.rb":{"lines":[null,null,1,1,1,1,1,1,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/config/initializers/inflections.rb":{"lines":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/config/initializers/sqlite_wal.rb":{"lines":[null,null,1,1,1,2,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,2,null,null,null]},"/home/chfs/Documentos/proj/rails/Fullstack-Developer/config/routes.rb":{"lines":[1,null,1,1,null,null,1,null,null,1,null,null,1,1,1,1,1,null,null,1,null,null,null,1,1,null,null,1,null,null,1,null]}},"timestamp":1788893755.2973628,"run_id":"5ef67c0c-bbef-45b7-8925-73f0c56d5889","worker_id":"22975"}} diff --git a/coverage/.resultset.json.lock b/coverage/.resultset.json.lock new file mode 100644 index 000000000..e69de29bb diff --git a/coverage/coverage.json b/coverage/coverage.json new file mode 100644 index 000000000..d394cf95c --- /dev/null +++ b/coverage/coverage.json @@ -0,0 +1,4514 @@ +{ + "$schema": "https://raw.githubusercontent.com/simplecov-ruby/simplecov/main/schemas/coverage-v1.3.schema.json", + "meta": { + "schema_version": "1.3", + "simplecov_version": "1.2.0", + "command_name": "Minitest", + "command_names": [ + "Minitest" + ], + "project_name": "Fullstack-developer", + "timestamp": "2026-09-08T15:55:55.300-03:00", + "root": "/home/chfs/Documentos/proj/rails/Fullstack-Developer", + "commit": "3b4de2cbce931933f6e4ec2163eb8436fa6b157c", + "primary_coverage": "line", + "line_coverage": true, + "branch_coverage": false, + "method_coverage": false + }, + "total": { + "lines": { + "covered": 746, + "missed": 1, + "omitted": 1061, + "total": 747, + "percent": 99.8661311914324, + "strength": 9.139223560910308 + } + }, + "coverage": { + "app/channels/application_cable/channel.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "module ApplicationCable", + " class Channel < ActionCable::Channel::Base", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + 1, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 2, + "missed_lines": 0, + "omitted_lines": 4, + "total_lines": 2 + }, + "app/channels/application_cable/connection.rb": { + "source": [ + "module ApplicationCable", + " class Connection < ActionCable::Connection::Base", + " identified_by :current_user", + "", + " def connect", + " set_current_user || reject_unauthorized_connection", + " end", + "", + " private", + " def set_current_user", + " if session = Session.find_by(id: cookies.signed[:session_id])", + " self.current_user = session.user", + " end", + " end", + " end", + "end" + ], + "lines": [ + 1, + 1, + 1, + null, + 1, + 3, + null, + null, + 1, + 1, + 3, + 1, + null, + null, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 9, + "missed_lines": 0, + "omitted_lines": 7, + "total_lines": 9 + }, + "app/channels/dashboard_channel/channel.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "module DashboardChannel", + " class Channel < ApplicationCable::Channel", + " def subscribed", + " return reject unless Users::Policy.admin_reader?(current_user)", + "", + " stream_from \"dashboard_stats\"", + " end", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + 1, + 1, + 2, + null, + 1, + null, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 5, + "missed_lines": 0, + "omitted_lines": 6, + "total_lines": 5 + }, + "app/channels/import_progress_channel/channel.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "module ImportProgressChannel", + " class Channel < ApplicationCable::Channel", + " def subscribed", + " import = UserImport.find_by(id: params[:import_id])", + " return reject unless import && Users::Policy.admin?(current_user)", + "", + " stream_for import", + " end", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + 1, + 1, + 3, + 3, + null, + 1, + null, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 6, + "missed_lines": 0, + "omitted_lines": 6, + "total_lines": 6 + }, + "app/controllers/admin/base_controller.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "module Admin", + " class BaseController < ApplicationController", + " include Authorization", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + 1, + 1, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 3, + "missed_lines": 0, + "omitted_lines": 4, + "total_lines": 3 + }, + "app/controllers/admin/dashboard_controller.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "module Admin", + " class DashboardController < BaseController", + " def index", + " overview = Dashboard::TotalsQuery.call(User.all)", + "", + " render inertia: \"Admin/Dashboard\", props: {", + " overview: overview.to_inertia,", + " urls: { users_url: admin_users_path, imports_url: admin_user_imports_path }", + " }", + " end", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + 1, + 1, + 4, + null, + 4, + null, + null, + null, + null, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 5, + "missed_lines": 0, + "omitted_lines": 9, + "total_lines": 5 + }, + "app/controllers/admin/user_imports_controller.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "module Admin", + " # Thin controller (fase 5): create only builds the UserImport + enqueues", + " # the job; parser/validation/aggregation all live in Parser/Model/Processor.", + " # Reads go through ListingQuery + UserImportPresenter (DIP).", + " class UserImportsController < BaseController", + " def index", + " listing = UserImports::ListingQuery.call(page: params[:page])", + "", + " render inertia: \"Admin/UserImports/Index\", props: {", + " imports: list_items(listing.imports),", + " pagination: listing.pagination,", + " urls: admin_urls.merge(new_import_url: new_admin_user_import_path)", + " }", + " end", + "", + " def new", + " render inertia: \"Admin/UserImports/New\", props: {", + " imports: list_items(recent_imports),", + " urls: admin_urls.merge(submit_url: admin_user_imports_path)", + " }", + " end", + "", + " def create", + " user_import = UserImport.new(user: Current.user, status: :pending)", + " user_import.file = params[:file]", + "", + " if user_import.save", + " UserImports::Job.perform_later(user_import)", + " redirect_to admin_user_import_path(user_import),", + " notice: \"#{user_import.file.filename} queued for processing.\"", + " else", + " render inertia: \"Admin/UserImports/New\", props: {", + " imports: list_items(recent_imports),", + " errors: user_import.errors.to_hash,", + " urls: admin_urls.merge(submit_url: admin_user_imports_path)", + " }", + " end", + " end", + "", + " def show", + " user_import = UserImport.find(params[:id])", + "", + " render inertia: \"Admin/UserImports/Show\", props: {", + " import: UserImportPresenter.new(user_import).to_inertia,", + " urls: admin_urls.merge(imports_url: admin_user_imports_path)", + " }", + " end", + "", + " private", + "", + " def admin_urls", + " {", + " dashboard_url: admin_root_path,", + " users_url: admin_users_path,", + " imports_url: admin_user_imports_path", + " }", + " end", + "", + " def recent_imports", + " UserImport.order(id: :desc).limit(5)", + " end", + "", + " def list_items(imports)", + " imports.map do |record|", + " UserImportPresenter.new(record).to_inertia.merge(", + " show_url: admin_user_import_path(record)", + " )", + " end", + " end", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + null, + null, + null, + 1, + 1, + 1, + null, + 1, + null, + null, + null, + null, + null, + null, + 1, + 1, + null, + null, + null, + null, + null, + 1, + 2, + 2, + null, + 2, + 1, + 1, + null, + null, + 1, + null, + null, + null, + null, + null, + null, + null, + 1, + 1, + null, + 1, + null, + null, + null, + null, + null, + 1, + null, + 1, + null, + 4, + null, + null, + null, + null, + null, + 1, + 2, + null, + null, + 1, + 3, + 6, + null, + null, + null, + null, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 25, + "missed_lines": 0, + "omitted_lines": 48, + "total_lines": 25 + }, + "app/controllers/admin/users_controller.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "module Admin", + " # Thin controller: writes go through Admin::UserForm, reads through", + " # Users::ListingQuery / UserPresenter, role rules live in Users::Policy.", + " class UsersController < BaseController", + " def index", + " listing = Users::ListingQuery.call(", + " scope: User.all,", + " search: params[:search],", + " page: params[:page]", + " )", + "", + " render inertia: \"Admin/Users/Index\", props: {", + " users: listing.users.map { |user| user_item(user) },", + " pagination: listing.pagination,", + " filters: { search: params[:search].to_s },", + " urls: admin_urls.merge(new_url: new_admin_user_path)", + " }", + " end", + "", + " def new", + " render inertia: \"Admin/Users/New\", props: {", + " urls: admin_urls.merge(submit_url: admin_users_path, cancel_url: admin_users_path)", + " }", + " end", + "", + " def create", + " form = Admin::UserForm.new(user_params)", + "", + " if form.save", + " Dashboard::Publisher.call", + " redirect_to admin_users_path, notice: \"User created successfully.\"", + " else", + " render inertia: \"Admin/Users/New\", props: {", + " urls: admin_urls.merge(submit_url: admin_users_path, cancel_url: admin_users_path),", + " form: echoed_attributes,", + " errors: form.errors.to_hash", + " }", + " end", + " end", + "", + " def edit", + " @user = User.find(params[:id])", + " render inertia: \"Admin/Users/Edit\", props: {", + " urls: admin_urls.merge(submit_url: admin_user_path(@user), cancel_url: admin_users_path),", + " user: UserPresenter.new(@user).to_inertia,", + " form: edit_attributes", + " }", + " end", + "", + " def update", + " @user = User.find(params[:id])", + " form = Admin::UserForm.new(user_params, user: @user)", + "", + " if form.update", + " Dashboard::Publisher.call", + " redirect_to admin_users_path, notice: \"User updated successfully.\"", + " else", + " render inertia: \"Admin/Users/Edit\", props: {", + " urls: admin_urls.merge(submit_url: admin_user_path(@user), cancel_url: admin_users_path),", + " user: UserPresenter.new(@user).to_inertia,", + " form: echoed_attributes,", + " errors: form.errors.to_hash", + " }", + " end", + " end", + "", + " def destroy", + " target = User.find(params[:id])", + "", + " if policy.destructible?(Current.user, target)", + " Users::AccountCloser.call(target)", + " Dashboard::Publisher.call", + " redirect_to admin_users_path, notice: \"User deleted successfully.\"", + " else", + " redirect_to admin_users_path, alert: \"You cannot delete this user.\"", + " end", + " end", + "", + " def toggle_role", + " target = User.find(params[:id])", + "", + " if policy.demotable?(Current.user, target) && Users::RoleToggler.call(target)", + " Dashboard::Publisher.call", + " redirect_to admin_users_path, notice: \"Role switched to #{target.role}.\"", + " else", + " redirect_to admin_users_path, alert: \"You cannot change this user's role.\"", + " end", + " end", + "", + " private", + "", + " def policy", + " Users::Policy.new(Current.user)", + " end", + "", + " def admin_urls", + " {", + " dashboard_url: admin_root_path,", + " users_url: admin_users_path,", + " imports_url: admin_user_imports_path", + " }", + " end", + "", + " def user_params", + " params.permit(:full_name, :email_address, :avatar, :avatar_url, :role, :password, :password_confirmation)", + " end", + "", + " def user_item(user)", + " UserPresenter.new(user).to_inertia.merge(", + " edit_url: edit_admin_user_path(user),", + " toggle_url: toggle_role_admin_user_path(user),", + " delete_url: admin_user_path(user)", + " )", + " end", + "", + " def echoed_attributes", + " params.permit(:full_name, :email_address, :avatar_url, :role).to_h", + " end", + "", + " def edit_attributes", + " {", + " full_name: @user.full_name,", + " email_address: @user.email_address,", + " avatar_url: @user.avatar_url,", + " role: @user.role", + " }", + " end", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + null, + null, + 1, + 1, + 3, + null, + null, + null, + null, + null, + 3, + 6, + null, + null, + null, + null, + null, + null, + 1, + 1, + null, + null, + null, + null, + 1, + 4, + null, + 4, + 1, + 1, + null, + 3, + null, + null, + null, + null, + null, + null, + null, + 1, + 1, + 1, + null, + null, + null, + null, + null, + null, + 1, + 3, + 3, + null, + 3, + 2, + 2, + null, + 1, + null, + null, + null, + null, + null, + null, + null, + null, + 1, + 2, + null, + 2, + 1, + 1, + 1, + null, + 1, + null, + null, + null, + 1, + 3, + null, + 3, + 2, + 2, + null, + 1, + null, + null, + null, + 1, + null, + 1, + 5, + null, + null, + 1, + null, + 9, + null, + null, + null, + null, + null, + 1, + 7, + null, + null, + 1, + 6, + null, + null, + null, + null, + null, + null, + 1, + 4, + null, + null, + 1, + null, + 1, + null, + null, + null, + null, + null, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 50, + "missed_lines": 0, + "omitted_lines": 81, + "total_lines": 50 + }, + "app/controllers/application_controller.rb": { + "source": [ + "class ApplicationController < ActionController::Base", + " include Authentication", + " before_action :set_inertia_shared_auth", + " # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.", + " allow_browser versions: :modern", + "", + " private", + "", + " def set_inertia_shared_auth", + " inertia_share auth: { user: -> { auth_user_json } }", + " end", + "", + " def auth_user_json", + " return nil unless Current.user", + "", + " UserPresenter.new(Current.user).to_inertia", + " end", + "end" + ], + "lines": [ + 1, + 1, + 1, + null, + 1, + null, + 1, + null, + 1, + 161, + null, + null, + 1, + 38, + null, + 22, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 10, + "missed_lines": 0, + "omitted_lines": 8, + "total_lines": 10 + }, + "app/controllers/concerns/authentication.rb": { + "source": [ + "module Authentication", + " extend ActiveSupport::Concern", + "", + " included do", + " before_action :require_authentication", + " helper_method :authenticated?", + " end", + "", + " class_methods do", + " def allow_unauthenticated_access(**options)", + " skip_before_action :require_authentication, **options", + " end", + " end", + "", + " private", + " def authenticated?", + " resume_session", + " end", + "", + " def require_authentication", + " resume_session || request_authentication", + " end", + "", + " def resume_session", + " Current.session ||= find_session_by_cookie", + " end", + "", + " def find_session_by_cookie", + " Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]", + " end", + "", + " def request_authentication", + " session[:return_to_after_authenticating] = request.url", + " redirect_to new_session_path", + " end", + "", + " def after_authentication_url", + " session.delete(:return_to_after_authenticating) || redirect_target_for(Current.user)", + " end", + "", + " def redirect_target_for(user)", + " Users::Policy.admin?(user) ? admin_root_path : profile_path", + " end", + "", + " def start_new_session_for(user)", + " user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|", + " Current.session = session", + " cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }", + " end", + " end", + "", + " def terminate_session", + " Current.session.destroy", + " cookies.delete(:session_id)", + " end", + "end" + ], + "lines": [ + 1, + 1, + null, + 1, + 1, + 1, + null, + null, + 1, + 1, + 3, + null, + null, + null, + 1, + 1, + 0, + null, + null, + 1, + 51, + null, + null, + 1, + 51, + null, + null, + 1, + 51, + null, + null, + 1, + 7, + 7, + null, + null, + 1, + 47, + null, + null, + 1, + 45, + null, + null, + 1, + 46, + 46, + 46, + null, + null, + null, + 1, + 5, + 5, + null, + null + ], + "lines_covered_percent": 96.7741935483871, + "covered_lines": 30, + "missed_lines": 1, + "omitted_lines": 25, + "total_lines": 31 + }, + "app/controllers/concerns/authorization.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "module Authorization", + " extend ActiveSupport::Concern", + "", + " included do", + " before_action :require_admin", + " helper_method :current_administrator?", + " end", + "", + " private", + "", + " def current_administrator?", + " Users::Policy.admin?(Current.user)", + " end", + "", + " def require_admin", + " redirect_to root_path unless current_administrator?", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + 1, + null, + 1, + 1, + 1, + null, + null, + 1, + null, + 1, + 30, + null, + null, + 1, + 30, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 10, + "missed_lines": 0, + "omitted_lines": 10, + "total_lines": 10 + }, + "app/controllers/passwords_controller.rb": { + "source": [ + "class PasswordsController < ApplicationController", + " allow_unauthenticated_access", + " before_action :set_user_by_token, only: %i[ edit update ]", + "", + " def new", + " render inertia: \"Auth/ForgotPassword\", props: {", + " urls: { login_url: new_session_path }", + " }", + " end", + "", + " def create", + " if user = User.find_by(email_address: params[:email_address])", + " PasswordsMailer.reset(user).deliver_later", + " end", + "", + " redirect_to new_session_path, notice: \"Password reset instructions sent (if user with that email address exists).\"", + " end", + "", + " def edit", + " render inertia: \"Auth/ResetPassword\", props: {", + " token: params[:token],", + " urls: { login_url: new_session_path }", + " }", + " end", + "", + " def update", + " form = Users::PasswordResetForm.new(params.permit(:password, :password_confirmation), user: @user)", + "", + " if form.update", + " redirect_to new_session_path, notice: \"Password has been reset.\"", + " else", + " render inertia: \"Auth/ResetPassword\", props: {", + " token: params[:token],", + " errors: form.errors.to_hash,", + " urls: { login_url: new_session_path }", + " }", + " end", + " end", + "", + " private", + " def set_user_by_token", + " @user = User.find_by_token_for!(:password_reset, params[:token])", + " rescue ActiveSupport::MessageVerifier::InvalidSignature", + " redirect_to new_password_path, alert: \"Password reset link is invalid or has expired.\"", + " end", + "end" + ], + "lines": [ + 1, + 1, + 1, + null, + 1, + 1, + null, + null, + null, + null, + 1, + 2, + 1, + null, + null, + 2, + null, + null, + 1, + 1, + null, + null, + null, + null, + null, + 1, + 2, + null, + 2, + 1, + null, + 1, + null, + null, + null, + null, + null, + null, + null, + 1, + 1, + 4, + null, + 1, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 20, + "missed_lines": 0, + "omitted_lines": 26, + "total_lines": 20 + }, + "app/controllers/profiles_controller.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "class ProfilesController < ApplicationController", + " # Authenticated by the Authentication concern. Identity always comes from", + " # Current.user — the singular route never carries an id (anti-IDOR by design).", + "", + " def show", + " render inertia: \"Profile/Show\", props: {", + " user: UserPresenter.new(Current.user).to_inertia,", + " urls: {", + " profile_url: profile_path,", + " admin_url: admin_root_path,", + " edit_url: edit_profile_path,", + " delete_url: profile_path", + " }", + " }", + " end", + "", + " def edit", + " render inertia: \"Profile/Edit\", props: {", + " urls: { profile_url: profile_path, admin_url: admin_root_path, submit_url: profile_path, cancel_url: profile_path },", + " form: profile_attributes", + " }", + " end", + "", + " def update", + " form = Users::ProfileForm.new(profile_params, user: Current.user)", + "", + " if form.update", + " redirect_to profile_path, notice: \"Profile updated successfully.\"", + " else", + " render inertia: \"Profile/Edit\", props: {", + " urls: { profile_url: profile_path, admin_url: admin_root_path, submit_url: profile_path, cancel_url: profile_path },", + " form: echoed_attributes,", + " errors: form.errors.to_hash", + " }", + " end", + " end", + "", + " def destroy", + " Users::AccountCloser.call(Current.user)", + " terminate_session", + " redirect_to new_session_path, notice: \"Your account has been closed. Goodbye!\"", + " end", + "", + " private", + "", + " def profile_params", + " params.permit(:full_name, :email_address, :avatar, :avatar_url, :password, :password_confirmation)", + " end", + "", + " def profile_attributes", + " {", + " full_name: Current.user.full_name,", + " email_address: Current.user.email_address,", + " avatar_url: Current.user.avatar_url", + " }", + " end", + "", + " def echoed_attributes", + " params.permit(:full_name, :email_address, :avatar_url).to_h", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + null, + null, + null, + 1, + 3, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + 1, + 1, + null, + null, + null, + null, + null, + 1, + 4, + null, + 4, + 3, + null, + 1, + null, + null, + null, + null, + null, + null, + null, + 1, + 1, + 1, + 1, + null, + null, + 1, + null, + 1, + 4, + null, + null, + 1, + null, + 1, + null, + null, + null, + null, + null, + 1, + 1, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 21, + "missed_lines": 0, + "omitted_lines": 42, + "total_lines": 21 + }, + "app/controllers/registrations_controller.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "class RegistrationsController < ApplicationController", + " allow_unauthenticated_access only: %i[new create]", + " rate_limit to: 10, within: 3.minutes, only: :create,", + " with: -> { redirect_to new_registration_path, alert: \"Try again later.\" }", + "", + " def new", + " render inertia: \"Auth/Register\", props: {", + " urls: {", + " register_url: registration_path,", + " login_url: new_session_path", + " }", + " }", + " end", + "", + " def create", + " form = Users::RegistrationForm.new(registration_params)", + "", + " if form.save", + " start_new_session_for(form.user)", + " redirect_to after_authentication_url, notice: \"Signed up successfully.\"", + " else", + " render inertia: \"Auth/Register\", props: {", + " urls: {", + " register_url: registration_path,", + " login_url: new_session_path", + " },", + " form: echoed_attributes,", + " errors: form.errors.to_hash", + " }", + " end", + " end", + "", + " private", + "", + " def registration_params", + " params.permit(:full_name, :email_address, :avatar, :avatar_url, :password, :password_confirmation)", + " end", + "", + " def echoed_attributes", + " params.permit(:full_name, :email_address, :avatar_url).to_h", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + 1, + 1, + 1, + null, + 1, + 1, + null, + null, + null, + null, + null, + null, + null, + 1, + 14, + null, + 14, + 3, + 3, + null, + 11, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + 1, + null, + 1, + 14, + null, + null, + 1, + 11, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 17, + "missed_lines": 0, + "omitted_lines": 27, + "total_lines": 17 + }, + "app/controllers/root_controller.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "class RootController < ApplicationController", + " def index", + " redirect_to after_authentication_url", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + 1, + 1, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 3, + "missed_lines": 0, + "omitted_lines": 4, + "total_lines": 3 + }, + "app/controllers/sessions_controller.rb": { + "source": [ + "class SessionsController < ApplicationController", + " allow_unauthenticated_access only: %i[ new create ]", + " rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_url, alert: \"Try again later.\" }", + "", + " def new", + " render inertia: \"Auth/Login\", props: {", + " urls: {", + " login_url: session_path,", + " register_url: new_registration_path,", + " forgot_password_url: new_password_path", + " }", + " }", + " end", + "", + " def create", + " if user = User.authenticate_by(params.permit(:email_address, :password))", + " start_new_session_for user", + " redirect_to after_authentication_url, notice: \"Signed in successfully.\"", + " else", + " redirect_to new_session_path, alert: \"Try another email address or password.\"", + " end", + " end", + "", + " def destroy", + " terminate_session", + " redirect_to new_session_path", + " end", + "end" + ], + "lines": [ + 1, + 1, + 2, + null, + 1, + 1, + null, + null, + null, + null, + null, + null, + null, + null, + 1, + 54, + 43, + 43, + null, + 11, + null, + null, + null, + 1, + 4, + 4, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 13, + "missed_lines": 0, + "omitted_lines": 15, + "total_lines": 13 + }, + "app/forms/admin/user_form.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "module Admin", + " # Admin write use case: manages any user, including their role. Inherits the", + " # common user validations from Users::Form (DRY). A password is required only", + " # when creating a user; on update it is optional (use-case rule).", + " class UserForm < Users::Form", + " attribute :password, :string", + " attribute :password_confirmation, :string", + " attribute :role, :string", + "", + " validates :role, inclusion: { in: User.roles.keys }", + " validates :password, presence: true, length: { in: 8..72 }, if: :new_record?", + " validates :password, length: { in: 8..72 }, if: :password_chosen?", + " validates :password_confirmation, presence: true, if: :password_chosen?", + " validate :password_confirmation_match, if: :password_chosen?", + "", + " private", + "", + " def user_attributes", + " attributes = super.merge(role: role)", + " return attributes unless password.present?", + "", + " attributes.merge(password: password, password_confirmation: password_confirmation)", + " end", + "", + " def new_record?", + " user.new_record?", + " end", + "", + " def password_chosen?", + " password.present?", + " end", + "", + " def password_confirmation_match", + " errors.add(:password_confirmation, :confirmation) unless password == password_confirmation", + " end", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + null, + null, + null, + 1, + 1, + 1, + 1, + null, + 1, + 1, + 1, + 1, + 1, + null, + 1, + null, + 1, + 11, + 11, + null, + 6, + null, + null, + 1, + 34, + null, + null, + 1, + 51, + null, + null, + 1, + 9, + null, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 21, + "missed_lines": 0, + "omitted_lines": 18, + "total_lines": 21 + }, + "app/forms/users/form.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "module Users", + " # Base form object: single source of user write validations (DRY).", + " # Subclasses add use-case rules: RegistrationForm (role :user + password),", + " # Admin::UserForm (role + optional password) and ProfileForm (own profile).", + " class Form", + " include ActiveModel::Model", + " include ActiveModel::Attributes", + "", + " attr_reader :user", + " attr_accessor :avatar", + "", + " attribute :full_name, :string", + " attribute :email_address, :string", + " attribute :avatar_url, :string", + " # Import idempotency (Fase 5): when enabled, an already-imported email is", + " # a silent no-op instead of a duplicate error. The policy lives here, in", + " # the Form — never as a loose `if` in the Processor.", + " attribute :ignore_duplicate_emails, :boolean, default: false", + " # Import use case (Fase 5): spreadsheets may lack avatar columns, so the", + " # avatar rule is relaxed there (placeholder avatar in the presenter).", + " # Every interactive write keeps the avatar requirement.", + " attribute :require_avatar, :boolean, default: true", + "", + " validates :full_name, presence: true, length: { maximum: 100 }", + " validate :avatar_source", + " validate :avatar_url_format, if: -> { avatar_url.present? }", + "", + " def initialize(attributes = {}, user: nil, role: nil)", + " @user = user || build_user(role)", + " super(attributes)", + " end", + "", + " def save", + " persist", + " end", + "", + " def update", + " persist", + " end", + "", + " private", + "", + " def persist", + " return true if skip_existing_email?", + " return false unless valid?", + "", + " apply_attributes", + " return true if user.save", + "", + " errors.merge!(user.errors)", + " user.avatar.purge if avatar.present?", + " false", + " end", + "", + " # Re-processing the same spreadsheet must not fail: an existing email is", + " # counted as processed (a no-op) instead of raising \"already taken\".", + " def skip_existing_email?", + " return false unless ignore_duplicate_emails", + "", + " existing = User.find_by(email_address: email_address)", + " return false unless existing", + "", + " @user = existing", + " true", + " end", + "", + " def apply_attributes", + " user.assign_attributes(user_attributes)", + " apply_avatar", + " end", + "", + " def user_attributes", + " {", + " full_name: full_name,", + " email_address: email_address", + " }", + " end", + "", + " def build_user(role)", + " User.new.tap { |record| record.role = role if role }", + " end", + "", + " def apply_avatar", + " if avatar.present?", + " user.avatar.attach(avatar)", + " user.avatar_url = nil", + " elsif avatar_url.present?", + " user.avatar.purge if user.avatar.attached?", + " user.avatar_url = avatar_url", + " end", + " end", + "", + " def avatar_source", + " return unless require_avatar", + " return if avatar.present?", + " return if avatar_url.present?", + " return if user.persisted? && user.avatar_source.present?", + "", + " errors.add(:base, :avatar_required)", + " end", + "", + " def avatar_url_format", + " return if avatar_url.match?(%r{\\Ahttps?://}) && avatar_url.length <= 2048", + "", + " errors.add(:avatar_url, :invalid_http_url)", + " end", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + null, + null, + null, + 1, + 1, + 1, + null, + 1, + 1, + null, + 1, + 1, + 1, + null, + null, + null, + 1, + null, + null, + null, + 1, + null, + 1, + 1, + 125, + null, + 1, + 126, + 126, + null, + null, + 1, + 100, + null, + null, + 1, + 26, + null, + null, + 1, + null, + 1, + 126, + 124, + null, + 106, + 106, + null, + 15, + 15, + 15, + null, + null, + null, + null, + 1, + 126, + null, + 70, + 70, + null, + 2, + 2, + null, + null, + 1, + 106, + 106, + null, + null, + 1, + null, + 103, + null, + null, + null, + null, + 1, + 200, + null, + null, + 1, + 106, + 1, + 1, + 105, + 33, + 33, + null, + null, + null, + 1, + 124, + 46, + 45, + 2, + null, + 2, + null, + null, + 1, + 46, + null, + 1, + null, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 59, + "missed_lines": 0, + "omitted_lines": 51, + "total_lines": 59 + }, + "app/forms/users/password_reset_form.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "module Users", + " # Forgot-password write use case: changes ONLY the password of an existing", + " # user whose reset token was already verified by the controller. Extends the", + " # common user validation contract so no User write bypasses the Form", + " # hierarchy (DIP/DRY); identity fields are prefilled from the target record", + " # and never rewritten. The avatar rule is out of scope for this intent.", + " class PasswordResetForm < Form", + " attribute :password, :string", + " attribute :password_confirmation, :string", + " attribute :require_avatar, :boolean, default: false", + "", + " validates :password, presence: true, length: { in: 8..72 }", + " validates :password_confirmation, presence: true", + " validate :password_confirmation_match", + "", + " def initialize(attributes = {}, user:)", + " identity = {", + " full_name: user.full_name,", + " email_address: user.email_address", + " }.merge(attributes)", + "", + " super(identity, user: user)", + " end", + "", + " private", + "", + " def user_attributes", + " {", + " password: password,", + " password_confirmation: password_confirmation", + " }", + " end", + "", + " def password_confirmation_match", + " errors.add(:password_confirmation, :confirmation) unless password == password_confirmation", + " end", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + null, + null, + null, + null, + null, + 1, + 1, + 1, + 1, + null, + 1, + 1, + 1, + null, + 1, + null, + 6, + null, + null, + null, + 6, + null, + null, + 1, + null, + 1, + null, + 3, + null, + null, + null, + null, + 1, + 6, + null, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 16, + "missed_lines": 0, + "omitted_lines": 24, + "total_lines": 16 + }, + "app/forms/users/profile_form.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "module Users", + " # Own-profile write use case: full_name, email_address, avatar and an", + " # OPTIONAL password. Never manages a role or other users (ISP).", + " class ProfileForm < Form", + " attribute :password, :string", + " attribute :password_confirmation, :string", + "", + " validates :password, length: { in: 8..72 }, if: :password_chosen?", + " validates :password_confirmation, presence: true, if: :password_chosen?", + " validate :password_confirmation_match, if: :password_chosen?", + "", + " private", + "", + " def user_attributes", + " return super unless password.present?", + "", + " super.merge(password: password, password_confirmation: password_confirmation)", + " end", + "", + " def password_chosen?", + " password.present?", + " end", + "", + " def password_confirmation_match", + " errors.add(:password_confirmation, :confirmation) unless password == password_confirmation", + " end", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + null, + null, + 1, + 1, + 1, + null, + 1, + 1, + 1, + null, + 1, + null, + 1, + 8, + null, + 2, + null, + null, + 1, + 33, + null, + null, + 1, + 4, + null, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 15, + "missed_lines": 0, + "omitted_lines": 15, + "total_lines": 15 + }, + "app/forms/users/registration_form.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "module Users", + " # Registration use case: a visitor always becomes a regular :user.", + " # The role rule is imposed here, never in the controller.", + " class RegistrationForm < Form", + " attribute :password, :string", + " attribute :password_confirmation, :string", + "", + " validates :password, presence: true, length: { in: 8..72 }", + " validates :password_confirmation, presence: true", + " validate :password_confirmation_match", + "", + " def initialize(attributes = {}, user: nil)", + " super(attributes, user: user, role: :user)", + " end", + "", + " private", + "", + " def user_attributes", + " super.merge(", + " password: password,", + " password_confirmation: password_confirmation", + " )", + " end", + "", + " def password_confirmation_match", + " return if password.blank? || password_confirmation.blank?", + "", + " errors.add(:password_confirmation, :confirmation) unless password == password_confirmation", + " end", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + null, + null, + 1, + 1, + 1, + null, + 1, + 1, + 1, + null, + 1, + 92, + null, + null, + 1, + null, + 1, + 84, + null, + null, + null, + null, + null, + 1, + 90, + null, + 89, + null, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 15, + "missed_lines": 0, + "omitted_lines": 18, + "total_lines": 15 + }, + "app/helpers/application_helper.rb": { + "source": [ + "module ApplicationHelper", + "end" + ], + "lines": [ + 1, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 1, + "missed_lines": 0, + "omitted_lines": 1, + "total_lines": 1 + }, + "app/jobs/application_job.rb": { + "source": [ + "class ApplicationJob < ActiveJob::Base", + " # Automatically retry jobs that encountered a deadlock", + " # retry_on ActiveRecord::Deadlocked", + "", + " # Most jobs are safe to ignore if the underlying records are no longer available", + " # discard_on ActiveJob::DeserializationError", + "end" + ], + "lines": [ + 1, + null, + null, + null, + null, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 1, + "missed_lines": 0, + "omitted_lines": 6, + "total_lines": 1 + }, + "app/jobs/user_imports/job.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "module UserImports", + " # Thin job: only sets the processing state and delegates to the service.", + " # No business rule lives here (SRP).", + " class Job < ApplicationJob", + " queue_as :imports", + "", + " def perform(user_import)", + " user_import.update!(status: :processing) unless user_import.processing?", + " UserImports::Processor.new.call(user_import)", + " end", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + null, + null, + 1, + 1, + null, + 1, + 1, + 1, + null, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 6, + "missed_lines": 0, + "omitted_lines": 8, + "total_lines": 6 + }, + "app/mailers/application_mailer.rb": { + "source": [ + "class ApplicationMailer < ActionMailer::Base", + " # Fallback keeps tests/console runnable; production enforces MAIL_FROM in", + " # config/environments/production.rb (fail-fast).", + " default from: ENV.fetch(\"MAIL_FROM\", \"no-reply@localhost\")", + " layout \"mailer\"", + "end" + ], + "lines": [ + 1, + null, + null, + 1, + 1, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 3, + "missed_lines": 0, + "omitted_lines": 3, + "total_lines": 3 + }, + "app/mailers/passwords_mailer.rb": { + "source": [ + "class PasswordsMailer < ApplicationMailer", + " def reset(user)", + " @user = user", + " mail subject: \"Reset your password\", to: user.email_address", + " end", + "end" + ], + "lines": [ + 1, + 1, + 1, + 1, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 4, + "missed_lines": 0, + "omitted_lines": 2, + "total_lines": 4 + }, + "app/models/application_record.rb": { + "source": [ + "class ApplicationRecord < ActiveRecord::Base", + " primary_abstract_class", + "end" + ], + "lines": [ + 1, + 1, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 2, + "missed_lines": 0, + "omitted_lines": 1, + "total_lines": 2 + }, + "app/models/current.rb": { + "source": [ + "class Current < ActiveSupport::CurrentAttributes", + " attribute :session", + " delegate :user, to: :session, allow_nil: true", + "end" + ], + "lines": [ + 1, + 1, + 1, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 3, + "missed_lines": 0, + "omitted_lines": 1, + "total_lines": 3 + }, + "app/models/session.rb": { + "source": [ + "class Session < ApplicationRecord", + " belongs_to :user", + "end" + ], + "lines": [ + 1, + 1, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 2, + "missed_lines": 0, + "omitted_lines": 1, + "total_lines": 2 + }, + "app/models/user.rb": { + "source": [ + "class User < ApplicationRecord", + " has_secure_password", + " has_many :sessions, dependent: :destroy", + " has_many :user_imports, dependent: :nullify", + " has_one_attached :avatar", + "", + " enum :role, { user: 0, admin: 1 }", + "", + " generates_token_for :password_reset, expires_in: 15.minutes do", + " password_salt.last(10)", + " end", + "", + " normalizes :email_address, with: ->(e) { e.strip.downcase }", + " validates :email_address, presence: true, uniqueness: true", + "", + " def avatar_source", + " return avatar if avatar.attached?", + " avatar_url", + " end", + "end" + ], + "lines": [ + 1, + 1, + 1, + 1, + 1, + null, + 1, + null, + 1, + 11, + null, + null, + 364, + 1, + null, + 1, + 3, + 1, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 13, + "missed_lines": 0, + "omitted_lines": 7, + "total_lines": 13 + }, + "app/models/user_import.rb": { + "source": [ + "class UserImport < ApplicationRecord", + " belongs_to :user", + " has_one_attached :file", + "", + " enum :status, { pending: 0, processing: 1, completed: 2, failed: 3 }", + "", + " validate :file_presence, on: :create", + " validate :supported_file_format", + "", + " # Single intent method (progress is not spread across the app):", + " # 0..1 completed share, never dividing by zero.", + " def progress", + " return 0.0 if total_rows.zero?", + "", + " processed_rows.fdiv(total_rows)", + " end", + "", + " private", + "", + " def file_presence", + " errors.add(:file, :blank) unless file.attached?", + " end", + "", + " # Accepted formats are defined by the parser registry — the model does not", + " # carry its own contract of formats (DRY single source).", + " def supported_file_format", + " return unless file.attached?", + "", + " extension = file.blob.filename.extension_with_delimiter.downcase", + " return if UserImports::Parser::REGISTRY.key?(extension)", + "", + " errors.add(:file, :unsupported_format)", + " end", + "end" + ], + "lines": [ + 1, + 1, + 1, + null, + 1, + null, + 1, + 1, + null, + null, + null, + 1, + 18, + null, + 16, + null, + null, + 1, + null, + 1, + 15, + null, + null, + null, + null, + 1, + 29, + null, + 26, + 26, + null, + 2, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 17, + "missed_lines": 0, + "omitted_lines": 17, + "total_lines": 17 + }, + "app/parsers/user_imports/parser.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "module UserImports", + " # The only strategy structure allowed in this project (arquitetura §2.1,", + " # regra 1): an interface with #parse and a registry of extension -> impl.", + " # Formats are selected by DATA (a constant hash), not by a Factory object.", + " class Parser", + " REGISTRY = {", + " \".csv\" => Roo,", + " \".xlsx\" => Roo,", + " \".xls\" => Roo", + " }.freeze", + "", + " # Selects the implementation for a file name — the single place that", + " # knows which format maps to which strategy (OCP: new format = new entry).", + " def self.for(file_name)", + " implementation = REGISTRY[File.extname(file_name.to_s).downcase]", + " raise ArgumentError, \"unsupported spreadsheet format: #{file_name}\" unless implementation", + "", + " implementation", + " end", + "", + " # Contract: yields UserImports::Result { headers:, rows:, total_rows: }.", + " def parse(_uploaded_file)", + " raise NotImplementedError, \"#{self.class} must implement #parse\"", + " end", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + null, + null, + null, + 1, + null, + 1, + null, + null, + null, + null, + null, + null, + 1, + 11, + 11, + null, + 9, + null, + null, + null, + 1, + 1, + null, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 9, + "missed_lines": 0, + "omitted_lines": 19, + "total_lines": 9 + }, + "app/parsers/user_imports/parser/roo.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "module UserImports", + " class Parser", + " # Roo backs every extension in the registry (csv/xlsx/xls). It reads the", + " # first sheet, normalizes headers (case/underscore) onto the canonical", + " # form attributes, skips the header row and blank lines, and exposes the", + " # remaining rows lazily (Enumerator#lazy over the sheet).", + " # Substitutable implementation of the UserImports::Parser interface (LSP).", + " class Roo", + " # Canonical attribute key after header normalization.", + " HEADER_MAP = {", + " \"full_name\" => :full_name,", + " \"fullname\" => :full_name,", + " \"full name\" => :full_name,", + " \"email_address\" => :email_address,", + " \"emailaddress\" => :email_address,", + " \"email\" => :email_address,", + " \"e-mail\" => :email_address,", + " \"e_mail\" => :email_address,", + " \"avatar_url\" => :avatar_url,", + " \"avatar url\" => :avatar_url,", + " \"avatar\" => :avatar_url", + " }.freeze", + "", + " CSV_OPTIONS = { col_sep: \",\", encoding: \"BOM|UTF-8\" }.freeze", + "", + " # uploaded_file is either an ActiveStorage attachment (responds to", + " # #download) or a path String/Pathname — normalized the same way.", + " def parse(uploaded_file)", + " with_source(uploaded_file) do |path, extension|", + " spreadsheet = ::Roo::Spreadsheet.open(path, extension: extension, csv_options: CSV_OPTIONS)", + " rows_from(spreadsheet)", + " end", + " end", + "", + " private", + "", + " def with_source(uploaded_file)", + " extension = extension_for(uploaded_file)", + " if uploaded_file.respond_to?(:download)", + " Tempfile.create([ \"user_import\", extension ]) do |file|", + " file.binmode", + " file.write(uploaded_file.download)", + " file.rewind", + " yield file.path, extension.delete(\".\").to_sym", + " end", + " else", + " yield uploaded_file.to_s, extension.delete(\".\").to_sym", + " end", + " end", + "", + " def extension_for(uploaded_file)", + " name =", + " if uploaded_file.respond_to?(:filename)", + " uploaded_file.filename.to_s", + " else", + " uploaded_file.to_s", + " end", + " File.extname(name).downcase", + " end", + "", + " def rows_from(spreadsheet)", + " sheet = spreadsheet.sheet(0)", + " map = header_map(sheet.row(1))", + " data_rows = data_rows_of(sheet)", + "", + " rows = data_rows.lazy.map do |row_number, cells|", + " Row.new(row_number: row_number, attributes: attributes_for(cells, map))", + " end", + "", + " Result.new(headers: map.keys, rows: rows, total_rows: data_rows.size)", + " end", + "", + " def header_map(header_row)", + " header_row.to_a.each_with_index.each_with_object({}) do |(cell, index), map|", + " key = HEADER_MAP[normalize_header(cell)]", + " map[key] = index if key", + " end", + " end", + "", + " # Lazily consumed list of [original_row_number, cells] for non-blank", + " # data rows (line 1 is the header). Range is bounded by sheet.last_row.", + " def data_rows_of(sheet)", + " (2..sheet.last_row).filter_map do |row_number|", + " cells = sheet.row(row_number)", + " next if blank_row?(cells)", + "", + " [ row_number, cells ]", + " end", + " end", + "", + " def attributes_for(cells, map)", + " map.each_with_object({}) do |(key, index), attributes|", + " attributes[key] = normalize_value(cells[index])", + " end", + " end", + "", + " def blank_row?(cells)", + " cells.nil? || cells.all? { |cell| cell.nil? || cell.to_s.strip.empty? }", + " end", + "", + " def normalize_header(cell)", + " return \"\" if cell.nil?", + "", + " cell.to_s.strip.downcase.gsub(/[\\s\\-]+/, \"_\")", + " end", + "", + " def normalize_value(cell)", + " return nil if cell.nil?", + "", + " cell.to_s.strip", + " end", + " end", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + 1, + null, + null, + null, + null, + null, + 1, + null, + 1, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + 1, + null, + null, + null, + 1, + 4, + 4, + 4, + null, + null, + null, + 1, + null, + 1, + 4, + 4, + 1, + 1, + 1, + 1, + 1, + null, + null, + 3, + null, + null, + null, + 1, + null, + 4, + 1, + null, + 3, + null, + 4, + null, + null, + 1, + 4, + 4, + 4, + null, + 4, + 5, + null, + null, + 4, + null, + null, + 1, + 4, + 10, + 10, + null, + null, + null, + null, + null, + 1, + 4, + 8, + 8, + null, + 7, + null, + null, + null, + 1, + 5, + 12, + null, + null, + null, + 1, + 18, + null, + null, + 1, + 10, + null, + 10, + null, + null, + 1, + 12, + null, + 11, + null, + null, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 51, + "missed_lines": 0, + "omitted_lines": 65, + "total_lines": 51 + }, + "app/policies/users/policy.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "module Users", + " # Policy object: single source of role invariants (DRY). Pure domain rules,", + " # no HTTP. New role-based rules are added here, never in controllers.", + " class Policy", + " def self.admin?(user)", + " new(user).admin?", + " end", + "", + " def self.admin_reader?(user)", + " new(user).admin?", + " end", + "", + " def initialize(user)", + " @user = user", + " end", + "", + " def admin?", + " @user&.admin?", + " end", + "", + " def destructible?(actor, target)", + " return false if actor == target", + " return false if last_admin?(target)", + "", + " true", + " end", + "", + " def demotable?(actor, target)", + " return false if actor == target", + " return false if last_admin?(target)", + "", + " true", + " end", + "", + " private", + "", + " attr_reader :user", + "", + " def last_admin?(target)", + " target.admin? && role_count_for(:admin) == 1", + " end", + "", + " def role_count_for(role)", + " User.where(role: role).count", + " end", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + null, + null, + 1, + 1, + 77, + null, + null, + 1, + 2, + null, + null, + 1, + 90, + null, + null, + 1, + 79, + null, + null, + 1, + 6, + 4, + null, + 3, + null, + null, + 1, + 7, + 5, + null, + 4, + null, + null, + 1, + null, + 1, + null, + 1, + 9, + null, + null, + 1, + 5, + null, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 24, + "missed_lines": 0, + "omitted_lines": 25, + "total_lines": 24 + }, + "app/presenters/user_import_presenter.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "# Pure presentation of a UserImport for Inertia (SRP — no business rule):", + "# date formatting, status label and rounded percentage come from the model's", + "# single progress intent. Contract mirrored by AdminImport in the TS types.", + "class UserImportPresenter", + " def initialize(user_import)", + " @user_import = user_import", + " end", + "", + " def to_inertia", + " {", + " id: id,", + " file_name: file_name,", + " status: status,", + " status_human: status_human,", + " total_rows: total_rows,", + " processed_rows: processed_rows,", + " failed_rows: failed_rows,", + " progress: percent,", + " finished_at: finished_at,", + " error_message: error_message,", + " created_at: created_at", + " }", + " end", + "", + " private", + "", + " attr_reader :user_import", + "", + " delegate :id, :status, :total_rows, :processed_rows, :failed_rows, :error_message, to: :user_import", + "", + " def file_name", + " user_import.file.attached? ? user_import.file.filename.to_s : nil", + " end", + "", + " def status_human", + " status&.capitalize", + " end", + "", + " def percent", + " (user_import.progress * 100).round", + " end", + "", + " def finished_at", + " user_import.finished_at&.iso8601", + " end", + "", + " def created_at", + " user_import.created_at&.iso8601", + " end", + "end" + ], + "lines": [ + null, + null, + null, + null, + null, + 1, + 1, + 9, + null, + null, + 1, + null, + 9, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + 1, + null, + 1, + null, + 1, + null, + 1, + 9, + null, + null, + 1, + 9, + null, + null, + 1, + 9, + null, + null, + 1, + 9, + null, + null, + 1, + 9, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 18, + "missed_lines": 0, + "omitted_lines": 34, + "total_lines": 18 + }, + "app/presenters/user_presenter.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "class UserPresenter", + " AVATAR_PLACEHOLDER = \"/images/avatar-placeholder.png\"", + "", + " def initialize(user)", + " @user = user", + " end", + "", + " def to_inertia", + " {", + " id: id,", + " full_name: full_name,", + " email_address: email_address,", + " role: role,", + " avatar_source: avatar_source", + " }", + " end", + "", + " private", + "", + " attr_reader :user", + "", + " delegate :id, :full_name, :email_address, :role, to: :user", + "", + " def avatar_source", + " return user.avatar if user.avatar.attached?", + " return user.avatar_url if user.avatar_url.present?", + "", + " AVATAR_PLACEHOLDER", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + 1, + null, + 1, + 38, + null, + null, + 1, + null, + 38, + null, + null, + null, + null, + null, + null, + null, + 1, + null, + 1, + null, + 1, + null, + 1, + 38, + 38, + null, + 1, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 13, + "missed_lines": 0, + "omitted_lines": 19, + "total_lines": 13 + }, + "app/queries/dashboard/totals_query.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "module Dashboard", + " # Single source of dashboard totals, reused by the initial render and by the", + " # real-time broadcast (Phase 6) — DRY.", + " class TotalsQuery", + " def self.call(scope)", + " new(scope).call", + " end", + "", + " def initialize(scope)", + " @scope = scope", + " end", + "", + " def call", + " Users::Overview.new(", + " total_users: scope.count,", + " by_role: scope.group(:role).count", + " )", + " end", + "", + " private", + "", + " attr_reader :scope", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + null, + null, + 1, + 1, + 20, + null, + null, + 1, + 20, + null, + null, + 1, + 20, + null, + null, + null, + null, + null, + 1, + null, + 1, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 10, + "missed_lines": 0, + "omitted_lines": 16, + "total_lines": 10 + }, + "app/queries/user_imports/listing_query.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "module UserImports", + " # Reads the admin import history: newest first, paginated. Pagy is isolated", + " # here — the controller never sees it (DIP), mirroring Users::ListingQuery.", + " class ListingQuery", + " PER_PAGE = 10", + " ORDER = Arel.sql(\"id DESC\").freeze", + "", + " def self.call(scope: UserImport.all, page: nil)", + " new(scope: scope, page: page).call", + " end", + "", + " def initialize(scope: UserImport.all, page: nil)", + " @scope = scope", + " @page = page", + " end", + "", + " def call", + " relation = scope.order(ORDER)", + " count = relation.count", + " pagy = Pagy.new(count: count, page: safe_page(count), limit: PER_PAGE)", + "", + " UserImports::Listing.new(", + " imports: relation.offset(pagy.offset).limit(pagy.in),", + " pagination: {", + " page: pagy.page,", + " pages: pagy.last,", + " count: pagy.count,", + " items: pagy.limit,", + " prev: pagy.prev,", + " next: pagy.next", + " }", + " )", + " end", + "", + " private", + "", + " attr_reader :scope, :page", + "", + " def safe_page(count)", + " requested = Integer(page, exception: false) || 1", + " last = [ 1, (count.to_f / PER_PAGE).ceil ].max", + " requested.clamp(1, last)", + " end", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + null, + null, + 1, + 1, + 1, + null, + 1, + 1, + null, + null, + 1, + 1, + 1, + null, + null, + 1, + 1, + 1, + 1, + null, + 1, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + 1, + null, + 1, + null, + 1, + 1, + 1, + 1, + null, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 20, + "missed_lines": 0, + "omitted_lines": 27, + "total_lines": 20 + }, + "app/queries/users/listing_query.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "module Users", + " # Reads the admin user list: search, stable ordering, avatar preloading and", + " # pagination. Pagy is isolated here — the controller never sees it (DIP).", + " class ListingQuery", + " ORDER = Arel.sql(\"LOWER(full_name) ASC, id ASC\").freeze", + " PER_PAGE = 10", + "", + " def self.call(scope: User.all, search: nil, page: nil)", + " new(scope: scope, search: search, page: page).call", + " end", + "", + " def initialize(scope: User.all, search: nil, page: nil)", + " @scope = scope", + " @search = search", + " @page = page", + " end", + "", + " def call", + " filtered = search_filter(scope.with_attached_avatar.order(ORDER))", + " pagy = Pagy.new(count: filtered.count, page: safe_page(filtered.count), limit: PER_PAGE)", + "", + " Users::Listing.new(", + " users: filtered.offset(pagy.offset).limit(pagy.in),", + " pagination: {", + " page: pagy.page,", + " pages: pagy.last,", + " count: pagy.count,", + " items: pagy.limit,", + " prev: pagy.prev,", + " next: pagy.next", + " }", + " )", + " end", + "", + " private", + "", + " attr_reader :scope, :search, :page", + "", + " def search_filter(relation)", + " return relation if search.blank?", + "", + " term = \"%#{ActiveRecord::Base.sanitize_sql_like(search.to_s).downcase}%\"", + " relation.where(\"LOWER(full_name) LIKE :term OR LOWER(email_address) LIKE :term\", term: term)", + " end", + "", + " def safe_page(count)", + " requested = Integer(page, exception: false) || 1", + " last = [ 1, (count.to_f / PER_PAGE).ceil ].max", + " requested.clamp(1, last)", + " end", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + null, + null, + 1, + 1, + 1, + null, + 1, + 11, + null, + null, + 1, + 11, + 11, + 11, + null, + null, + 1, + 11, + 11, + null, + 11, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + 1, + null, + 1, + null, + 1, + 11, + null, + 4, + 4, + null, + null, + 1, + 11, + 11, + 11, + null, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 24, + "missed_lines": 0, + "omitted_lines": 30, + "total_lines": 24 + }, + "app/services/dashboard/publisher.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "module Dashboard", + " # Publishes the current dashboard totals to the \"dashboard_stats\" stream so", + " # the admin panel updates without a reload. Reuses Dashboard::TotalsQuery +", + " # Users::Overview#to_inertia — identical shape to the initial render (DRY).", + " class Publisher", + " STREAM = \"dashboard_stats\"", + "", + " def self.call", + " new.call", + " end", + "", + " def call", + " overview = Dashboard::TotalsQuery.call(User.all)", + " ActionCable.server.broadcast(STREAM, overview.to_inertia)", + " end", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + null, + null, + null, + 1, + 1, + null, + 1, + 8, + null, + null, + 1, + 12, + 12, + null, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 8, + "missed_lines": 0, + "omitted_lines": 11, + "total_lines": 8 + }, + "app/services/user_imports/processor.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "module UserImports", + " # Orchestrates the async import (SRP + DIP): each collaborator is injected", + " # in the constructor with a real default; tests swap in fakes. The Processor", + " # composes — it does not own parser/form/broadcast logic.", + " class Processor", + " BATCH_SIZE = 25", + " # Attribute-level errors that map cleanly onto a spreadsheet cell.", + " ATTRIBUTES = %i[full_name email_address avatar_url].freeze", + "", + " def initialize(", + " parser_class: UserImports::Parser::Roo,", + " form_class: Users::RegistrationForm,", + " broadcaster: UserImports::ProgressBroadcaster.new,", + " dashboard_publisher: Dashboard::Publisher.new", + " )", + " @parser_class = parser_class", + " @form_class = form_class", + " @broadcaster = broadcaster", + " @dashboard_publisher = dashboard_publisher", + " end", + "", + " # - parses once (total_rows persisted upfront)", + " # - persists each row through a shared Form Object (RegistrationForm +", + " # generated invite password) inside per-batch transactions", + " # - counts processed/failed, aggregates row errors into a summary", + " # - broadcasts progress after every batch and always writes a final state", + " # (the last broadcast carries the completion/failure status — Phase 6)", + " def call(user_import)", + " result = parser_class.new.parse(user_import.file)", + " user_import.update!(", + " status: :processing,", + " total_rows: result.total_rows,", + " processed_rows: 0,", + " failed_rows: 0", + " )", + "", + " row_errors = []", + " result.rows.each_slice(BATCH_SIZE) do |batch|", + " process_batch(user_import, batch, row_errors)", + " broadcaster.call(user_import)", + " end", + "", + " finish(user_import, row_errors)", + " rescue StandardError => error", + " fail_import(user_import, error)", + " end", + "", + " private", + "", + " attr_reader :parser_class, :form_class, :broadcaster, :dashboard_publisher", + "", + " def process_batch(user_import, batch, row_errors)", + " User.transaction do", + " batch.each { |row| process_row(user_import, row, row_errors) }", + " end", + " end", + "", + " def process_row(user_import, row, row_errors)", + " invite = generated_password", + " form = form_class.new(", + " row.attributes.merge(", + " password: invite,", + " password_confirmation: invite,", + " ignore_duplicate_emails: true,", + " require_avatar: false", + " )", + " )", + "", + " if form.save", + " user_import.increment!(:processed_rows)", + " else", + " user_import.increment!(:failed_rows)", + " row_errors.concat(row_errors(form, row))", + " end", + " end", + "", + " def row_errors(form, row)", + " names = form.errors.attribute_names & ATTRIBUTES", + " names = form.errors.attribute_names if names.empty?", + " names.flat_map do |attribute|", + " form.errors.full_messages_for(attribute).map do |message|", + " RowError.new(row_number: row.row_number, attribute: attribute, message: message)", + " end", + " end", + " end", + "", + " # Any failed row makes the outcome :failed with an aggregated summary", + " # (count + row numbers only — never leaked row data).", + " def finish(user_import, row_errors)", + " user_import.update!(", + " status: row_errors.any? ? :failed : :completed,", + " error_message: row_errors.any? ? summary(row_errors) : nil,", + " finished_at: Time.current", + " )", + " broadcaster.call(user_import)", + " dashboard_publisher.call", + " end", + "", + " # Fatal errors (parse/IO/DB) also land in a final :failed state — no", + " # re-raise, so a broken file is not retried forever by the queue.", + " def fail_import(user_import, error)", + " warn \"===IMPORT ERROR=== #{error.class}: #{error.message}\" if ENV[\"DEBUG_IMPORT_ERROR\"] == \"1\"", + " user_import.update!(", + " status: :failed,", + " error_message: \"Could not process the file (#{error.class}).\",", + " finished_at: Time.current", + " )", + " broadcaster.call(user_import)", + " end", + "", + " def summary(row_errors)", + " rows = row_errors.map(&:row_number).uniq", + " \"#{row_errors.size} error(s) in #{rows.size} row(s): #{rows.join(\", \")}.\"", + " end", + "", + " def generated_password", + " SecureRandom.urlsafe_base64(12)", + " end", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + null, + null, + null, + 1, + 1, + null, + 1, + null, + 1, + null, + null, + null, + null, + null, + 6, + 6, + 6, + 6, + null, + null, + null, + null, + null, + null, + null, + null, + 1, + 6, + 5, + null, + null, + null, + null, + null, + null, + 5, + 5, + 7, + 7, + null, + null, + 5, + null, + 1, + null, + null, + 1, + null, + 1, + null, + 1, + 7, + 76, + null, + null, + null, + 1, + 69, + 69, + null, + null, + null, + null, + null, + null, + null, + null, + 69, + 67, + null, + 2, + 2, + null, + null, + null, + 1, + 2, + 2, + 2, + 2, + 2, + null, + null, + null, + null, + null, + null, + 1, + 5, + 5, + 5, + null, + null, + 5, + 5, + null, + null, + null, + null, + 1, + 1, + 1, + null, + null, + null, + null, + 1, + null, + null, + 1, + 1, + 1, + null, + null, + 1, + 69, + null, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 51, + "missed_lines": 0, + "omitted_lines": 71, + "total_lines": 51 + }, + "app/services/user_imports/progress_broadcaster.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "module UserImports", + " # The single place that broadcasts import progress (DRY — Fase 6 will feed", + " # the Inertia front end with this same payload). Injected into the", + " # Processor as the real default; tests replace it with a fake (DIP).", + " class ProgressBroadcaster", + " def call(user_import)", + " ActionCable::Channel::Base.broadcast_to(user_import, payload(user_import))", + " end", + "", + " private", + "", + " def payload(user_import)", + " {", + " id: user_import.id,", + " status: user_import.status,", + " total: user_import.total_rows,", + " processed: user_import.processed_rows,", + " failed: user_import.failed_rows,", + " percent: user_import.progress", + " }", + " end", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + null, + null, + null, + 1, + 1, + 7, + null, + null, + 1, + null, + 1, + null, + 7, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 7, + "missed_lines": 0, + "omitted_lines": 18, + "total_lines": 7 + }, + "app/services/users/account_closer.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "module Users", + " # Closes an account: destroys the user. Sessions fall with", + " # dependent: :destroy; user_imports are kept via dependent: :nullify so the", + " # import history survives. Session/cookie cleanup stays in the controller.", + " class AccountCloser", + " def self.call(user)", + " new.call(user)", + " end", + "", + " def call(user)", + " user.destroy!", + " end", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + null, + null, + null, + 1, + 1, + 5, + null, + null, + 1, + 5, + null, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 6, + "missed_lines": 0, + "omitted_lines": 10, + "total_lines": 6 + }, + "app/services/users/role_toggler.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "module Users", + " # Toggles a user's role (\"user\" <-> \"admin\"). An admin write use case, so it", + " # persists through Admin::UserForm — controllers never touch ActiveRecord for", + " # a User write (DIP). The authorization question (demotable?) stays in the", + " # controller/policy; this service only applies the mutation through the", + " # shared validation contract (DRY single source).", + " class RoleToggler", + " def self.call(target)", + " new.call(target)", + " end", + "", + " def call(target)", + " form = Admin::UserForm.new(", + " {", + " full_name: target.full_name,", + " email_address: target.email_address,", + " role: toggled_role(target),", + " require_avatar: false", + " },", + " user: target", + " )", + " form.update", + " end", + "", + " private", + "", + " def toggled_role(target)", + " target.role == \"admin\" ? \"user\" : \"admin\"", + " end", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + null, + null, + null, + null, + null, + 1, + 1, + 2, + null, + null, + 1, + 2, + null, + null, + null, + null, + null, + null, + null, + null, + 2, + null, + null, + 1, + null, + 1, + 2, + null, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 10, + "missed_lines": 0, + "omitted_lines": 23, + "total_lines": 10 + }, + "app/value_objects/user_imports/listing.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "module UserImports", + " # Immutable result of UserImports::ListingQuery — keeps pagy out of the", + " # controller (DIP), mirroring Users::Listing.", + " class Listing", + " def initialize(imports:, pagination:)", + " @imports = imports.to_a.freeze", + " @pagination = pagination.freeze", + " freeze", + " end", + "", + " attr_reader :imports, :pagination", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + null, + null, + 1, + 1, + 1, + 1, + 1, + null, + null, + 1, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 7, + "missed_lines": 0, + "omitted_lines": 8, + "total_lines": 7 + }, + "app/value_objects/user_imports/result.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "module UserImports", + " # Parser boundary contract: normalized headers plus a lazy, re-readable", + " # row stream and its total count. The parser does not touch the domain.", + " class Result", + " def initialize(headers:, rows:, total_rows:)", + " @headers = headers.freeze", + " @rows = rows", + " @total_rows = total_rows", + " freeze", + " end", + "", + " attr_reader :headers, :rows, :total_rows", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + null, + null, + 1, + 1, + 8, + 8, + 8, + 8, + null, + null, + 1, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 8, + "missed_lines": 0, + "omitted_lines": 8, + "total_lines": 8 + }, + "app/value_objects/user_imports/row.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "module UserImports", + " # One normalized spreadsheet row crossing the parser boundary.", + " # The parser never validates domain rules (SRP): a Row only transports", + " # data, tagged with its original spreadsheet line number.", + " class Row", + " def initialize(row_number:, attributes:)", + " @row_number = row_number", + " @attributes = attributes.freeze", + " freeze", + " end", + "", + " attr_reader :row_number, :attributes", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + null, + null, + null, + 1, + 1, + 72, + 72, + 72, + null, + null, + 1, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 7, + "missed_lines": 0, + "omitted_lines": 9, + "total_lines": 7 + }, + "app/value_objects/user_imports/row_error.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "module UserImports", + " # A typed validation error for a single attribute of a single row.", + " # Only {row_number, attribute, message} — never the whole row data", + " # (privacy + typing).", + " class RowError", + " def initialize(row_number:, attribute:, message:)", + " @row_number = row_number", + " @attribute = attribute", + " @message = message", + " freeze", + " end", + "", + " attr_reader :row_number, :attribute, :message", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + null, + null, + null, + 1, + 1, + 2, + 2, + 2, + 2, + null, + null, + 1, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 8, + "missed_lines": 0, + "omitted_lines": 9, + "total_lines": 8 + }, + "app/value_objects/users/listing.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "module Users", + " # Immutable result of Users::ListingQuery: the page of users plus its", + " # pagination metadata. Keeps the query's contract explicit (no loose hash).", + " class Listing", + " def initialize(users:, pagination:)", + " @users = users.to_a.freeze", + " @pagination = pagination.freeze", + " freeze", + " end", + "", + " attr_reader :users, :pagination", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + null, + null, + 1, + 1, + 11, + 11, + 11, + null, + null, + 1, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 7, + "missed_lines": 0, + "omitted_lines": 8, + "total_lines": 7 + }, + "app/value_objects/users/overview.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "module Users", + " # Immutable frontier data for the admin dashboard. #to_inertia is the", + " # serialization contract shared with the real-time broadcast (Phase 6).", + " class Overview", + " def initialize(total_users:, by_role:)", + " @total_users = total_users", + " @by_role = by_role.freeze", + " freeze", + " end", + "", + " attr_reader :total_users, :by_role", + "", + " def to_inertia", + " {", + " total_users: total_users,", + " by_role: by_role", + " }", + " end", + " end", + "end" + ], + "lines": [ + null, + null, + 1, + null, + null, + 1, + 1, + 21, + 21, + 21, + null, + null, + 1, + null, + 1, + null, + 19, + null, + null, + null, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 9, + "missed_lines": 0, + "omitted_lines": 13, + "total_lines": 9 + }, + "config/environment.rb": { + "source": [ + "# Load the Rails application.", + "require_relative \"application\"", + "", + "# Initialize the Rails application.", + "Rails.application.initialize!" + ], + "lines": [ + null, + 1, + null, + null, + 1 + ], + "lines_covered_percent": 100.0, + "covered_lines": 2, + "missed_lines": 0, + "omitted_lines": 3, + "total_lines": 2 + }, + "config/environments/test.rb": { + "source": [ + "# The test environment is used exclusively to run your application's", + "# test suite. You never need to work with it otherwise. Remember that", + "# your test database is \"scratch space\" for the test suite and is wiped", + "# and recreated between test runs. Don't rely on the data there!", + "", + "Rails.application.configure do", + " # Settings specified here will take precedence over those in config/application.rb.", + "", + " # While tests run files are not watched, reloading is not necessary.", + " config.enable_reloading = false", + "", + " # Eager loading loads your entire application. When running a single test locally,", + " # this is usually not necessary, and can slow down your test suite. However, it's", + " # recommended that you enable it in continuous integration systems to ensure eager", + " # loading is working properly before deploying your code.", + " config.eager_load = ENV[\"CI\"].present?", + "", + " # Configure public file server for tests with cache-control for performance.", + " config.public_file_server.headers = { \"cache-control\" => \"public, max-age=3600\" }", + "", + " # Show full error reports.", + " config.consider_all_requests_local = true", + " config.cache_store = :memory_store", + "", + " # Render exception templates for rescuable exceptions and raise for other exceptions.", + " config.action_dispatch.show_exceptions = :rescuable", + "", + " # Disable request forgery protection in test environment.", + " config.action_controller.allow_forgery_protection = false", + "", + " # Store uploaded files on the local file system in a temporary directory.", + " config.active_storage.service = :test", + "", + " # Tell Action Mailer not to deliver emails to the real world.", + " # The :test delivery method accumulates sent emails in the", + " # ActionMailer::Base.deliveries array.", + " config.action_mailer.delivery_method = :test", + "", + " # Set host to be used by links generated in mailer templates.", + " config.action_mailer.default_url_options = { host: \"example.com\" }", + "", + " # Print deprecation notices to the stderr.", + " config.active_support.deprecation = :stderr", + "", + " # Raises error for missing translations.", + " # config.i18n.raise_on_missing_translations = true", + "", + " # Annotate rendered view with file names.", + " # config.action_view.annotate_rendered_view_with_filenames = true", + "", + " # Raise error when a before_action's only/except options reference missing actions.", + " config.action_controller.raise_on_missing_callback_actions = true", + "end" + ], + "lines": [ + null, + null, + null, + null, + null, + 1, + null, + null, + null, + 1, + null, + null, + null, + null, + null, + 1, + null, + null, + 1, + null, + null, + 1, + 1, + null, + null, + 1, + null, + null, + 1, + null, + null, + 1, + null, + null, + null, + null, + 1, + null, + null, + 1, + null, + null, + 1, + null, + null, + null, + null, + null, + null, + null, + null, + 1, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 13, + "missed_lines": 0, + "omitted_lines": 40, + "total_lines": 13 + }, + "config/initializers/content_security_policy.rb": { + "source": [ + "# Be sure to restart your server when you modify this file.", + "", + "# Define an application-wide content security policy.", + "# See the Securing Rails Applications Guide for more information:", + "# https://guides.rubyonrails.org/security.html#content-security-policy-header", + "", + "# Rails.application.configure do", + "# config.content_security_policy do |policy|", + "# policy.default_src :self, :https", + "# policy.font_src :self, :https, :data", + "# policy.img_src :self, :https, :data", + "# policy.object_src :none", + "# policy.script_src :self, :https", + "# Allow @vite/client to hot reload javascript changes in development", + "# policy.script_src *policy.script_src, :unsafe_eval, \"http://#{ ViteRuby.config.host_with_port }\" if Rails.env.development?", + "", + "# You may need to enable this in production as well depending on your setup.", + "# policy.script_src *policy.script_src, :blob if Rails.env.test?", + "", + "# policy.style_src :self, :https", + "# Allow @vite/client to hot reload style changes in development", + "# policy.style_src *policy.style_src, :unsafe_inline if Rails.env.development?", + "", + "# # Specify URI for violation reports", + "# # policy.report_uri \"/csp-violation-report-endpoint\"", + "# end", + "#", + "# # Generate session nonces for permitted importmap, inline scripts, and inline styles.", + "# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }", + "# config.content_security_policy_nonce_directives = %w(script-src style-src)", + "#", + "# # Report violations without enforcing the policy.", + "# # config.content_security_policy_report_only = true", + "# end" + ], + "lines": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 0, + "missed_lines": 0, + "omitted_lines": 34, + "total_lines": 0 + }, + "config/initializers/filter_parameter_logging.rb": { + "source": [ + "# Be sure to restart your server when you modify this file.", + "", + "# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file.", + "# Use this to limit dissemination of sensitive information.", + "# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.", + "Rails.application.config.filter_parameters += [", + " :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc", + "]" + ], + "lines": [ + null, + null, + null, + null, + null, + 1, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 1, + "missed_lines": 0, + "omitted_lines": 7, + "total_lines": 1 + }, + "config/initializers/inertia_rails.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "InertiaRails.configure do |config|", + " config.version = ViteRuby.digest", + " config.encrypt_history = true", + " config.always_include_errors_hash = true", + " config.use_script_element_for_initial_page = true", + " config.use_data_inertia_head_attribute = true", + "end" + ], + "lines": [ + null, + null, + 1, + 1, + 1, + 1, + 1, + 1, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 6, + "missed_lines": 0, + "omitted_lines": 3, + "total_lines": 6 + }, + "config/initializers/inflections.rb": { + "source": [ + "# Be sure to restart your server when you modify this file.", + "", + "# Add new inflection rules using the following format. Inflections", + "# are locale specific, and you may define rules for as many different", + "# locales as you wish. All of these examples are active by default:", + "# ActiveSupport::Inflector.inflections(:en) do |inflect|", + "# inflect.plural /^(ox)$/i, \"\\\\1en\"", + "# inflect.singular /^(ox)en/i, \"\\\\1\"", + "# inflect.irregular \"person\", \"people\"", + "# inflect.uncountable %w( fish sheep )", + "# end", + "", + "# These inflection rules are supported but not enabled by default:", + "# ActiveSupport::Inflector.inflections(:en) do |inflect|", + "# inflect.acronym \"RESTful\"", + "# end" + ], + "lines": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 0, + "missed_lines": 0, + "omitted_lines": 16, + "total_lines": 0 + }, + "config/initializers/sqlite_wal.rb": { + "source": [ + "# frozen_string_literal: true", + "", + "ActiveSupport.on_load(:active_record_sqlite3adapter) do", + " prepend(Module.new {", + " def configure_connection", + " super", + " # Switch SQLite to Write-Ahead Logging.", + " #", + " # Instead of overwriting the main .sqlite3 file on every transaction", + " # (the default rollback-journal mode, which blocks readers while writing),", + " # SQLite appends changes to a sidecar -wal file. This keeps the main", + " # database readable by many concurrent processes (e.g. the parallelized", + " # test suite and Workers) while a single writer is active, and only", + " # checkpoints back into the main file periodically.", + " #", + " # raw_connection is the underlying SQLite3::Database exposed by the", + " # adapter, used here because Active Record does not expose this pragma.", + " # journal_mode is per-database and sticky, but running it on every", + " # connection (dev/test/prod, including the cache/queue/cable dbs) makes", + " # sure a connection never starts in the slower rollback mode.", + " #", + " # Paired with busy_timeout from config/database.yml (timeout: 5000),", + " # which prevents SQLITE_BUSY errors when processes contend for the", + " # single writer slot that WAL allows.", + " raw_connection.execute(\"PRAGMA journal_mode=WAL\")", + " end", + " })", + "end" + ], + "lines": [ + null, + null, + 1, + 1, + 1, + 2, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + 2, + null, + null, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 5, + "missed_lines": 0, + "omitted_lines": 23, + "total_lines": 5 + }, + "config/routes.rb": { + "source": [ + "Rails.application.routes.draw do", + " # Authentication", + " resource :session, only: %i[new create destroy]", + " resources :passwords, param: :token, only: %i[new create edit update]", + "", + " # Registration (unauthenticated)", + " resource :registration, only: %i[new create], path: \"register\"", + "", + " # Profile (authenticated)", + " resource :profile, only: %i[show edit update destroy]", + "", + " # Admin (Phase 3 CRUD)", + " namespace :admin do", + " root \"dashboard#index\"", + " resources :users, only: %i[index new create edit update destroy] do", + " member do", + " patch :toggle_role", + " end", + " end", + " resources :user_imports, only: %i[index new create show]", + " end", + "", + " # Redirect to localhost from 127.0.0.1 to use same IP address with Vite server", + " constraints(host: \"127.0.0.1\") do", + " get \"(*path)\", to: redirect { |params, req| \"#{req.protocol}localhost:#{req.port}/#{params[:path]}\" }", + " end", + "", + " root \"root#index\"", + "", + " # Health check", + " get \"up\" => \"rails/health#show\", as: :rails_health_check", + "end" + ], + "lines": [ + 1, + null, + 1, + 1, + null, + null, + 1, + null, + null, + 1, + null, + null, + 1, + 1, + 1, + 1, + 1, + null, + null, + 1, + null, + null, + null, + 1, + 1, + null, + null, + 1, + null, + null, + 1, + null + ], + "lines_covered_percent": 100.0, + "covered_lines": 15, + "missed_lines": 0, + "omitted_lines": 17, + "total_lines": 15 + } + }, + "groups": { + "Controllers": { + "lines": { + "covered": 207, + "missed": 1, + "omitted": 299, + "total": 208, + "percent": 99.51923076923077, + "strength": 5.75 + }, + "files": [ + "app/controllers/admin/base_controller.rb", + "app/controllers/admin/dashboard_controller.rb", + "app/controllers/admin/user_imports_controller.rb", + "app/controllers/admin/users_controller.rb", + "app/controllers/application_controller.rb", + "app/controllers/concerns/authentication.rb", + "app/controllers/concerns/authorization.rb", + "app/controllers/passwords_controller.rb", + "app/controllers/profiles_controller.rb", + "app/controllers/registrations_controller.rb", + "app/controllers/root_controller.rb", + "app/controllers/sessions_controller.rb" + ] + }, + "Channels": { + "lines": { + "covered": 22, + "missed": 0, + "omitted": 23, + "total": 22, + "percent": 100.0, + "strength": 1.4090909090909092 + }, + "files": [ + "app/channels/application_cable/channel.rb", + "app/channels/application_cable/connection.rb", + "app/channels/dashboard_channel/channel.rb", + "app/channels/import_progress_channel/channel.rb" + ] + }, + "Forms": { + "lines": { + "covered": 126, + "missed": 0, + "omitted": 126, + "total": 126, + "percent": 100.0, + "strength": 23.484126984126984 + }, + "files": [ + "app/forms/admin/user_form.rb", + "app/forms/users/form.rb", + "app/forms/users/password_reset_form.rb", + "app/forms/users/profile_form.rb", + "app/forms/users/registration_form.rb" + ] + }, + "Helpers": { + "lines": { + "covered": 1, + "missed": 0, + "omitted": 1, + "total": 1, + "percent": 100.0, + "strength": 1.0 + }, + "files": [ + "app/helpers/application_helper.rb" + ] + }, + "Jobs": { + "lines": { + "covered": 7, + "missed": 0, + "omitted": 14, + "total": 7, + "percent": 100.0, + "strength": 1.0 + }, + "files": [ + "app/jobs/application_job.rb", + "app/jobs/user_imports/job.rb" + ] + }, + "Mailers": { + "lines": { + "covered": 7, + "missed": 0, + "omitted": 5, + "total": 7, + "percent": 100.0, + "strength": 1.0 + }, + "files": [ + "app/mailers/application_mailer.rb", + "app/mailers/passwords_mailer.rb" + ] + }, + "Models": { + "lines": { + "covered": 37, + "missed": 0, + "omitted": 27, + "total": 37, + "percent": 100.0, + "strength": 14.513513513513514 + }, + "files": [ + "app/models/application_record.rb", + "app/models/current.rb", + "app/models/session.rb", + "app/models/user.rb", + "app/models/user_import.rb" + ] + }, + "Parsers": { + "lines": { + "covered": 60, + "missed": 0, + "omitted": 84, + "total": 60, + "percent": 100.0, + "strength": 4.116666666666666 + }, + "files": [ + "app/parsers/user_imports/parser.rb", + "app/parsers/user_imports/parser/roo.rb" + ] + }, + "Policies": { + "lines": { + "covered": 24, + "missed": 0, + "omitted": 25, + "total": 24, + "percent": 100.0, + "strength": 12.625 + }, + "files": [ + "app/policies/users/policy.rb" + ] + }, + "Presenters": { + "lines": { + "covered": 31, + "missed": 0, + "omitted": 53, + "total": 31, + "percent": 100.0, + "strength": 7.580645161290323 + }, + "files": [ + "app/presenters/user_import_presenter.rb", + "app/presenters/user_presenter.rb" + ] + }, + "Queries": { + "lines": { + "covered": 54, + "missed": 0, + "omitted": 73, + "total": 54, + "percent": 100.0, + "strength": 4.203703703703703 + }, + "files": [ + "app/queries/dashboard/totals_query.rb", + "app/queries/user_imports/listing_query.rb", + "app/queries/users/listing_query.rb" + ] + }, + "Services": { + "lines": { + "covered": 82, + "missed": 0, + "omitted": 133, + "total": 82, + "percent": 100.0, + "strength": 7.7317073170731705 + }, + "files": [ + "app/services/dashboard/publisher.rb", + "app/services/user_imports/processor.rb", + "app/services/user_imports/progress_broadcaster.rb", + "app/services/users/account_closer.rb", + "app/services/users/role_toggler.rb" + ] + }, + "Value Objects": { + "lines": { + "covered": 46, + "missed": 0, + "omitted": 55, + "total": 46, + "percent": 100.0, + "strength": 8.673913043478262 + }, + "files": [ + "app/value_objects/user_imports/listing.rb", + "app/value_objects/user_imports/result.rb", + "app/value_objects/user_imports/row.rb", + "app/value_objects/user_imports/row_error.rb", + "app/value_objects/users/listing.rb", + "app/value_objects/users/overview.rb" + ] + }, + "Ungrouped": { + "lines": { + "covered": 42, + "missed": 0, + "omitted": 143, + "total": 42, + "percent": 100.0, + "strength": 1.0476190476190477 + }, + "files": [ + "config/environment.rb", + "config/environments/test.rb", + "config/initializers/content_security_policy.rb", + "config/initializers/filter_parameter_logging.rb", + "config/initializers/inertia_rails.rb", + "config/initializers/inflections.rb", + "config/initializers/sqlite_wal.rb", + "config/routes.rb" + ] + } + }, + "errors": {} +} \ No newline at end of file diff --git a/coverage/index.html b/coverage/index.html new file mode 100644 index 000000000..02901ab2b --- /dev/null +++ b/coverage/index.html @@ -0,0 +1,93 @@ + + + + + + Code Coverage + + + + + + + +
+
+
    +
    + + +
    +
    + +
    + + + + +
    + + +
    +
    +
    +
    + + +
    + +
    +
    + +
    + + + + + diff --git a/db/cable_schema.rb b/db/cable_schema.rb new file mode 100644 index 000000000..23666604a --- /dev/null +++ b/db/cable_schema.rb @@ -0,0 +1,11 @@ +ActiveRecord::Schema[7.1].define(version: 1) do + create_table "solid_cable_messages", force: :cascade do |t| + t.binary "channel", limit: 1024, null: false + t.binary "payload", limit: 536870912, null: false + t.datetime "created_at", null: false + t.integer "channel_hash", limit: 8, null: false + t.index ["channel"], name: "index_solid_cable_messages_on_channel" + t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash" + t.index ["created_at"], name: "index_solid_cable_messages_on_created_at" + end +end diff --git a/db/cache_schema.rb b/db/cache_schema.rb new file mode 100644 index 000000000..81a410d18 --- /dev/null +++ b/db/cache_schema.rb @@ -0,0 +1,12 @@ +ActiveRecord::Schema[7.2].define(version: 1) do + create_table "solid_cache_entries", force: :cascade do |t| + t.binary "key", limit: 1024, null: false + t.binary "value", limit: 536870912, null: false + t.datetime "created_at", null: false + t.integer "key_hash", limit: 8, null: false + t.integer "byte_size", limit: 4, null: false + t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size" + t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size" + t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true + end +end diff --git a/db/migrate/20260906180314_create_users.rb b/db/migrate/20260906180314_create_users.rb new file mode 100644 index 000000000..40a289e53 --- /dev/null +++ b/db/migrate/20260906180314_create_users.rb @@ -0,0 +1,14 @@ +class CreateUsers < ActiveRecord::Migration[8.0] + def change + create_table :users do |t| + t.string :email_address, null: false + t.string :password_digest, null: false + t.string :full_name, null: false + t.integer :role, null: false, default: 0 + t.string :avatar_url + + t.timestamps + end + add_index :users, :email_address, unique: true + end +end diff --git a/db/migrate/20260906180324_create_sessions.rb b/db/migrate/20260906180324_create_sessions.rb new file mode 100644 index 000000000..8102f13ae --- /dev/null +++ b/db/migrate/20260906180324_create_sessions.rb @@ -0,0 +1,11 @@ +class CreateSessions < ActiveRecord::Migration[8.0] + def change + create_table :sessions do |t| + t.references :user, null: false, foreign_key: true + t.string :ip_address + t.string :user_agent + + t.timestamps + end + end +end diff --git a/db/migrate/20260906180325_create_active_storage_tables.active_storage.rb b/db/migrate/20260906180325_create_active_storage_tables.active_storage.rb new file mode 100644 index 000000000..6bd8bd082 --- /dev/null +++ b/db/migrate/20260906180325_create_active_storage_tables.active_storage.rb @@ -0,0 +1,57 @@ +# This migration comes from active_storage (originally 20170806125915) +class CreateActiveStorageTables < ActiveRecord::Migration[7.0] + def change + # Use Active Record's configured type for primary and foreign keys + primary_key_type, foreign_key_type = primary_and_foreign_key_types + + create_table :active_storage_blobs, id: primary_key_type do |t| + t.string :key, null: false + t.string :filename, null: false + t.string :content_type + t.text :metadata + t.string :service_name, null: false + t.bigint :byte_size, null: false + t.string :checksum + + if connection.supports_datetime_with_precision? + t.datetime :created_at, precision: 6, null: false + else + t.datetime :created_at, null: false + end + + t.index [ :key ], unique: true + end + + create_table :active_storage_attachments, id: primary_key_type do |t| + t.string :name, null: false + t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type + t.references :blob, null: false, type: foreign_key_type + + if connection.supports_datetime_with_precision? + t.datetime :created_at, precision: 6, null: false + else + t.datetime :created_at, null: false + end + + t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + + create_table :active_storage_variant_records, id: primary_key_type do |t| + t.belongs_to :blob, null: false, index: false, type: foreign_key_type + t.string :variation_digest, null: false + + t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + end + + private + def primary_and_foreign_key_types + config = Rails.configuration.generators + setting = config.options[config.orm][:primary_key_type] + primary_key_type = setting || :primary_key + foreign_key_type = setting || :bigint + [ primary_key_type, foreign_key_type ] + end +end diff --git a/db/migrate/20260906180327_create_user_imports.rb b/db/migrate/20260906180327_create_user_imports.rb new file mode 100644 index 000000000..8ab0f620f --- /dev/null +++ b/db/migrate/20260906180327_create_user_imports.rb @@ -0,0 +1,16 @@ +class CreateUserImports < ActiveRecord::Migration[8.0] + def change + create_table :user_imports do |t| + t.references :user, null: false, foreign_key: true + t.integer :status, null: false, default: 0 + t.integer :total_rows, null: false, default: 0 + t.integer :processed_rows, null: false, default: 0 + t.integer :failed_rows, null: false, default: 0 + t.datetime :finished_at + t.text :error_message + + t.timestamps + end + add_index :user_imports, :status + end +end diff --git a/db/migrate/20260906203000_make_user_imports_user_id_optional.rb b/db/migrate/20260906203000_make_user_imports_user_id_optional.rb new file mode 100644 index 000000000..94d0fe7e2 --- /dev/null +++ b/db/migrate/20260906203000_make_user_imports_user_id_optional.rb @@ -0,0 +1,5 @@ +class MakeUserImportsUserIdOptional < ActiveRecord::Migration[8.0] + def change + change_column_null :user_imports, :user_id, true + end +end diff --git a/db/queue_schema.rb b/db/queue_schema.rb new file mode 100644 index 000000000..f9a71dabb --- /dev/null +++ b/db/queue_schema.rb @@ -0,0 +1,160 @@ +ActiveRecord::Schema[7.1].define(version: 1) do + create_table "solid_queue_blocked_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.string "concurrency_key", null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release" + t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance" + t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true + end + + create_table "solid_queue_claimed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.bigint "process_id" + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true + t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" + end + + create_table "solid_queue_failed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.text "error" + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true + end + + create_table "solid_queue_jobs", force: :cascade do |t| + t.string "queue_name", null: false + t.string "class_name", null: false + t.text "arguments" + t.integer "priority", default: 0, null: false + t.string "active_job_id" + t.datetime "scheduled_at" + t.datetime "finished_at" + t.string "concurrency_key" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.bigint "batch_id" + t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id" + t.index [ "batch_id" ], name: "index_solid_queue_jobs_on_batch_id" + t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name" + t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at" + t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering" + t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting" + end + + create_table "solid_queue_pauses", force: :cascade do |t| + t.string "queue_name", null: false + t.datetime "created_at", null: false + t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true + end + + create_table "solid_queue_processes", force: :cascade do |t| + t.string "kind", null: false + t.datetime "last_heartbeat_at", null: false + t.bigint "supervisor_id" + t.integer "pid", null: false + t.string "hostname" + t.text "metadata" + t.datetime "created_at", null: false + t.string "name", null: false + t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at" + t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true + t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id" + end + + create_table "solid_queue_ready_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true + t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all" + t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue" + end + + create_table "solid_queue_recurring_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "task_key", null: false + t.datetime "run_at", null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true + t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true + end + + create_table "solid_queue_recurring_tasks", force: :cascade do |t| + t.string "key", null: false + t.string "schedule", null: false + t.string "command", limit: 2048 + t.string "class_name" + t.text "arguments" + t.string "queue_name" + t.integer "priority", default: 0 + t.boolean "static", default: true, null: false + t.text "description" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true + t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static" + end + + create_table "solid_queue_scheduled_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "scheduled_at", null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true + t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all" + end + + create_table "solid_queue_semaphores", force: :cascade do |t| + t.string "key", null: false + t.integer "value", default: 1, null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at" + t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value" + t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true + end + + create_table "solid_queue_batches", force: :cascade do |t| + t.string "active_job_batch_id" + t.string "description" + t.text "on_finish" + t.text "on_success" + t.text "on_failure" + t.text "metadata" + t.integer "total_jobs", default: 0, null: false + t.integer "completed_jobs", default: 0, null: false + t.integer "failed_jobs", default: 0, null: false + t.datetime "enqueued_at" + t.datetime "finished_at" + t.datetime "failed_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "active_job_batch_id" ], name: "index_solid_queue_batches_on_active_job_batch_id", unique: true + t.index [ "finished_at" ], name: "index_solid_queue_batches_on_finished_at" + end + + create_table "solid_queue_batch_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.bigint "batch_id", null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_batch_executions_on_job_id", unique: true + t.index [ "batch_id" ], name: "index_solid_queue_batch_executions_on_batch_id" + end + + add_foreign_key "solid_queue_batch_executions", "solid_queue_batches", column: "batch_id", on_delete: :cascade + add_foreign_key "solid_queue_batch_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 000000000..0f46c9123 --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,80 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.0].define(version: 2026_09_06_203000) do + create_table "active_storage_attachments", force: :cascade do |t| + t.string "name", null: false + t.string "record_type", null: false + t.bigint "record_id", null: false + t.bigint "blob_id", null: false + t.datetime "created_at", null: false + t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" + t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true + end + + create_table "active_storage_blobs", force: :cascade do |t| + t.string "key", null: false + t.string "filename", null: false + t.string "content_type" + t.text "metadata" + t.string "service_name", null: false + t.bigint "byte_size", null: false + t.string "checksum" + t.datetime "created_at", null: false + t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true + end + + create_table "active_storage_variant_records", force: :cascade do |t| + t.bigint "blob_id", null: false + t.string "variation_digest", null: false + t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true + end + + create_table "sessions", force: :cascade do |t| + t.integer "user_id", null: false + t.string "ip_address" + t.string "user_agent" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_sessions_on_user_id" + end + + create_table "user_imports", force: :cascade do |t| + t.integer "user_id" + t.integer "status", default: 0, null: false + t.integer "total_rows", default: 0, null: false + t.integer "processed_rows", default: 0, null: false + t.integer "failed_rows", default: 0, null: false + t.datetime "finished_at" + t.text "error_message" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["status"], name: "index_user_imports_on_status" + t.index ["user_id"], name: "index_user_imports_on_user_id" + end + + create_table "users", force: :cascade do |t| + t.string "email_address", null: false + t.string "password_digest", null: false + t.string "full_name", null: false + t.integer "role", default: 0, null: false + t.string "avatar_url" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["email_address"], name: "index_users_on_email_address", unique: true + end + + add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" + add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "sessions", "users" + add_foreign_key "user_imports", "users" +end diff --git a/db/seeds.rb b/db/seeds.rb new file mode 100644 index 000000000..c8918e29d --- /dev/null +++ b/db/seeds.rb @@ -0,0 +1,107 @@ +# ------------------------------------------------------------------------------ +# Seed: validates the model structure (IAM users + user imports). +# Idempotent: safe to run multiple times via `bin/rails db:seed`. +# +# Production guard: the container entrypoint runs `db:prepare` on boot, so +# seeds would run on a fresh production volume. Never create reference +# credentials with a default password in production. Seeds run in production +# only when explicitly allowed AND with real admin credentials supplied via env. +# ------------------------------------------------------------------------------ + +if Rails.env.production? + unless ENV.fetch("SEED_ALLOW_IN_PRODUCTION", "false") == "true" + puts "Skipping seeds in production. Set SEED_ALLOW_IN_PRODUCTION=true and provide SEED_ADMIN_EMAIL/SEED_ADMIN_PASSWORD to run." + return + end +end + +def seed_admin_email + Rails.env.production? ? ENV.fetch("SEED_ADMIN_EMAIL") : ENV.fetch("SEED_ADMIN_EMAIL", "admin@iam.local") +end + +def seed_admin_password + Rails.env.production? ? ENV.fetch("SEED_ADMIN_PASSWORD") : ENV.fetch("SEED_ADMIN_PASSWORD", "Password123!") +end + +def seed_admin + User.find_or_create_by!(email_address: seed_admin_email) do |u| + u.password = seed_admin_password + u.full_name = "Ivy Admin" + u.role = :admin + u.avatar_url = "https://consolidesuamarca-voice.s3.us-east-2.amazonaws.com/assets/base/07e/c0c/7e0/exemplos-referencias-logomarcas-famosas-shell.jpg" + end +end + +def seed_regular_user + User.find_or_create_by!(email_address: "user@iam.local") do |u| + u.password = "Password123!" + u.full_name = "Ursula User" + u.role = :user + u.avatar_url = "https://i.ytimg.com/vi/ux47qw1Mhck/sddefault.jpg" + end +end + +def seed_session(user, ip: "127.0.0.1", agent: "seed/1.0") + user.sessions.create!(ip_address: ip, user_agent: agent) +end + +def seed_user_import(user, status: :completed, total: 5, processed: 5, failed: 0) + import = user.user_imports.build( + status: status, + total_rows: total, + processed_rows: processed, + failed_rows: failed, + finished_at: Time.current + ) + import.file.attach( + io: StringIO.new("full_name,email_address,avatar_url\nSeed User,seed@iam.local,https://example.com/avatars/seed.png\n"), + filename: "seed.csv", + content_type: "text/csv" + ) + import.save! + import +end + +def validate_models! + checks = [] + + admin = User.find_by!(email_address: "admin@iam.local") + user = User.find_by!(email_address: "user@iam.local") + + checks << [ "User#admin? true", admin.admin? ] + checks << [ "User#user? true", user.user? ] + checks << [ "Admin avatar_source is remote URL", admin.avatar_source == admin.avatar_url ] + checks << [ "Admin has sessions (#{admin.sessions.count})", admin.sessions.exists? ] + checks << [ "Admin has user_imports (#{admin.user_imports.count})", admin.user_imports.exists? ] + + User.all.each do |record| + checks << [ "User login validates (#{record.email_address})", record.valid? ] + checks << [ "User avatar present via URL", record.avatar_source.present? ] + end + + failed = checks.reject { |_desc, ok| ok } + passed = checks.count - failed.size + + puts "\nSeed validation summary: #{passed}/#{checks.count} checks passed" + unless failed.empty? + failed.each { |desc, _| warn " FAILED: #{desc}" } + raise "Seed model validation failed" + end +end + +# --- Run --------------------------------------------------------------------- +admin = seed_admin +user = seed_regular_user + +seed_session(admin) +seed_session(user) +seed_user_import(admin, status: :completed, total: 5, processed: 5) +seed_user_import(admin, status: :failed, total: 2, processed: 1, failed: 1) + +validate_models! + +puts "Seeds seeded successfully:" +puts " Admin: #{admin.email_address} (#{admin.role})" +puts " User: #{user.email_address} (#{user.role})" +puts " Total users: #{User.count}" +puts " User imports: #{UserImport.count}" diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 000000000..ddcfecadc --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,65 @@ +import js from '@eslint/js' +import babelParser from '@babel/eslint-parser' +import prettier from 'eslint-config-prettier' +import reactHooks from 'eslint-plugin-react-hooks' +import globals from 'globals' + +import { noAny } from './eslint/no-any.mjs' +import { noLiteralClassName } from './eslint/no-literal-class-name.mjs' + +const reactHooksRules = reactHooks.configs['recommended-latest'] ?? + reactHooks.configs.recommended ?? { rules: {} } + +export default [ + { + ignores: ['node_modules/**', 'app/frontend/build/**', 'public/**', 'coverage/**', 'vendor/**'], + }, + js.configs.recommended, + { + files: ['**/*.{js,mjs,cjs}'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + globals: globals.node, + }, + }, + { + files: ['**/*.{ts,tsx}'], + languageOptions: { + parser: babelParser, + parserOptions: { + requireConfigFile: false, + babelOptions: { + plugins: ['@babel/plugin-syntax-typescript', '@babel/plugin-syntax-jsx'], + }, + }, + globals: { + ...globals.browser, + ...globals.es2022, + }, + }, + plugins: { + 'react-hooks': reactHooks, + 'no-any': { rules: { 'no-any': noAny } }, + 'no-literal-class-name': { rules: { 'no-literal-class-name': noLiteralClassName } }, + }, + rules: { + ...reactHooksRules.rules, + // Types are checked by `tsc` (tsconfig) — the JS rule would only add + // noise for `import type` constructs parsed via Babel. + 'no-unused-vars': 'off', + 'no-undef': 'off', + 'no-any/no-any': 'error', + 'no-literal-class-name/no-literal-class-name': 'error', + }, + }, + { + files: ['**/*.{test,spec}.{ts,tsx}'], + rules: { + // Tests exercise the consumer override API (Button/TextInput className + // merging) — the app-body rule does not apply to them. + 'no-literal-class-name/no-literal-class-name': 'off', + }, + }, + prettier, +] diff --git a/eslint/no-any.mjs b/eslint/no-any.mjs new file mode 100644 index 000000000..017fa2f4a --- /dev/null +++ b/eslint/no-any.mjs @@ -0,0 +1,20 @@ +// Explicit `any` is only allowed at the styled() type boundary (with an +// eslint-disable + justification); everywhere else use unknown/specifics. +export const noAny = { + meta: { + type: 'problem', + schema: [], + docs: { description: 'Ban explicit any outside justified boundaries' }, + }, + create(context) { + return { + TSAnyKeyword(node) { + context.report({ + node, + message: + 'Avoid `any`; use `unknown`/the exact contract or justify an eslint-disable at the boundary.', + }) + }, + } + }, +} diff --git a/eslint/no-literal-class-name.mjs b/eslint/no-literal-class-name.mjs new file mode 100644 index 000000000..036f297ab --- /dev/null +++ b/eslint/no-literal-class-name.mjs @@ -0,0 +1,26 @@ +// Tailwind utility classes as JSX string literals belong in a co-located +// *.styles.ts file (frontend-styles.md §3). The rule only targets the literal +// form; `className={cn(...)}` composition inside atoms stays legal. +export const noLiteralClassName = { + meta: { + type: 'problem', + schema: [], + docs: { description: 'Tailwind utilities only via co-located *.styles.ts' }, + }, + create(context) { + return { + JSXAttribute(node) { + if (node.name?.type !== 'JSXIdentifier' || node.name.name !== 'className') return + + const { value } = node + if (value?.type === 'Literal' && typeof value.value === 'string') { + context.report({ + node, + message: + 'Tailwind utility classes belong in a co-located *.styles.ts file; use styled(...) or className={cn(...)}.', + }) + } + }, + } + }, +} diff --git a/lib/tasks/.keep b/lib/tasks/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/log/.keep b/log/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..c4b48252d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,5220 @@ +{ + "name": "Fullstack-Developer", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@inertiajs/react": "^3.7.0", + "@inertiajs/vite": "^3.7.0", + "@rails/actioncable": "^7.2.302", + "@tailwindcss/forms": "^0.5.11", + "@tailwindcss/typography": "^0.5.20", + "@tailwindcss/vite": "^4.3.3", + "@vitejs/plugin-react": "^6.1.1", + "clsx": "^2.1.1", + "mobx": "^7.0.3", + "mobx-react-lite": "^5.0.3", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.3" + }, + "devDependencies": { + "@babel/core": "^8.0.1", + "@babel/eslint-parser": "^8.0.1", + "@babel/plugin-syntax-jsx": "^8.0.1", + "@babel/plugin-syntax-typescript": "^8.0.3", + "@eslint/js": "^10.0.1", + "@inertiajs/core": "^3.7.0", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", + "@types/node": "^26.4.1", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "eslint": "^10.10.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react-hooks": "^7.1.1", + "globals": "^17.12.0", + "jsdom": "^30.0.1", + "prettier": "^3.9.6", + "typescript": "^7.0.2", + "vite": "^8.2.2", + "vite-plugin-ruby": "^5.2.3", + "vitest": "^5.0.0" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-8.0.0.tgz", + "integrity": "sha512-DOjnob/cXOUgDOozCDeq/aK2p5y8dUIVdf6tNhEV1HQRd6I8aQ4f4fbtHRVEvb6lP3BGomrKHiS8ICAASSVQSw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/core": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-8.0.1.tgz", + "integrity": "sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/generator": "^8.0.0", + "@babel/helper-compilation-targets": "^8.0.0", + "@babel/helpers": "^8.0.0", + "@babel/parser": "^8.0.0", + "@babel/template": "^8.0.0", + "@babel/traverse": "^8.0.0", + "@babel/types": "^8.0.0", + "@types/gensync": "^1.0.5", + "convert-source-map": "^2.0.0", + "empathic": "^2.0.1", + "gensync": "^1.0.0-beta.2", + "import-meta-resolve": "^4.2.0", + "json5": "^2.2.3", + "obug": "^2.1.1", + "semver": "^7.7.3" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/@babel/code-frame": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz", + "integrity": "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^8.0.0", + "js-tokens": "^10.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/core/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/eslint-parser": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-8.0.1.tgz", + "integrity": "sha512-2javO8pAQv/ld6sS6OcxoLAlzZEZy+xm99bnoAfMhzKSumKhdF5wylpbZB7XTorWr3KLPtx5K95eduJPOy1mzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-scope": "^9.1.0", + "eslint-visitor-keys": "^5.0.0", + "semver": "^7.7.3" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0", + "eslint": "^9.0.0 || ^10.0.0" + } + }, + "node_modules/@babel/generator": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz", + "integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-8.0.0.tgz", + "integrity": "sha512-JwculLABZvyPvyLBpwU/E/IbH2uM3mnxNtIJpxnIfb24y1PrdVxK5Dqjle4DpgqpGRnwgC7G8IkzPdSXZrO1Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^8.0.0", + "@babel/helper-validator-option": "^8.0.0", + "browserslist": "^4.24.0", + "lru-cache": "^11.0.0", + "semver": "^7.7.3" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-8.0.0.tgz", + "integrity": "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports/node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports/node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports/node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports/node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/helper-module-imports/node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports/node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-8.0.1.tgz", + "integrity": "sha512-3PKFgjTyPlhFhorfP+SjKQxLViIL++zWjFOO4hGriYU+Bsm983DxEM1JmDRJVWXV0O9npu+xXRqz7Pbd3mh70g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", + "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-8.0.0.tgz", + "integrity": "sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helpers": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-8.0.0.tgz", + "integrity": "sha512-wfbi91pM3py96oIiJEz7qIpyXDytgr9zQC1HEWwlGNVRAEmItuU/0a41ZUKu1sJGyhhOIpc4t5vk4PYzt8wpsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/parser": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz", + "integrity": "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-8.0.1.tgz", + "integrity": "sha512-n0jtCOxEovhU7METqSQjcZO9pX53nu9uNIjMS+hEt+Nt9jA7oOZoBIgbCxhhASmF6T6rPDGge5UAvh6Z4eFz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-8.0.3.tgz", + "integrity": "sha512-jmTPwps7oSQSZaV1SxkQ3C12UWyufGysGc5OzDpZzvPAIX4mO7dJT3hoqkWVrSImvkcMiknir1iLN1SNV/CZzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz", + "integrity": "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/template/node_modules/@babel/code-frame": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz", + "integrity": "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^8.0.0", + "js-tokens": "^10.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/template/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/template/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/traverse": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-8.0.4.tgz", + "integrity": "sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/generator": "^8.0.0", + "@babel/helper-globals": "^8.0.0", + "@babel/parser": "^8.0.4", + "@babel/template": "^8.0.0", + "@babel/types": "^8.0.4", + "obug": "^2.1.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/code-frame": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz", + "integrity": "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^8.0.0", + "js-tokens": "^10.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/traverse/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/types": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", + "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.4" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/types/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.2.tgz", + "integrity": "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.12.tgz", + "integrity": "sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@inertiajs/core": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-3.7.0.tgz", + "integrity": "sha512-JzysXTPsOpKcnR7ohwpgKE+UWBrKwW7z23DydWHSxjfQ02tJAKmNnZJAxnX7zH3zUSTqbtPge6QC8XhvfjNXmw==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "es-toolkit": "^1.33.0", + "laravel-precognition": "^2.0.0" + }, + "peerDependencies": { + "axios": "^1.15.2" + }, + "peerDependenciesMeta": { + "axios": { + "optional": true + } + } + }, + "node_modules/@inertiajs/react": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@inertiajs/react/-/react-3.7.0.tgz", + "integrity": "sha512-rc/TsVT7ihDk+cMeCuU3BjdUfTq6HJwXqGy7oPaysLQCeyfhIaP2QgExo5R1QSCFIwl9un0MeZnzTOG+i38jLw==", + "license": "MIT", + "dependencies": { + "@inertiajs/core": "3.7.0", + "es-toolkit": "^1.33.0", + "laravel-precognition": "^2.0.0" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/@inertiajs/vite": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@inertiajs/vite/-/vite-3.7.0.tgz", + "integrity": "sha512-eoOqvIEgmsktC/b5NuZjsj3VvCJ7GsbvHFN2IgT/7DXjLVdXGygWeyYHmS57AGSbb6yOyebeBR6cYkk6YyHOmg==", + "license": "MIT", + "dependencies": { + "@inertiajs/core": "3.7.0", + "tinyglobby": "^0.2.15" + }, + "peerDependencies": { + "vite": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", + "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rails/actioncable": { + "version": "7.2.302", + "resolved": "https://registry.npmjs.org/@rails/actioncable/-/actioncable-7.2.302.tgz", + "integrity": "sha512-9JOPzUb7RCqIEWeoE78mPFd71fzyJ25LjeMzD45zQ75f41ca7BsgQVqiyrIuxDZ1yyZ4PdyxCMZF0KJlJ9qX0g==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", + "integrity": "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.7.tgz", + "integrity": "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.7.tgz", + "integrity": "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.7.tgz", + "integrity": "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.7.tgz", + "integrity": "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.7.tgz", + "integrity": "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.7.tgz", + "integrity": "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.7.tgz", + "integrity": "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.7.tgz", + "integrity": "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.7.tgz", + "integrity": "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.7.tgz", + "integrity": "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.7.tgz", + "integrity": "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.7.tgz", + "integrity": "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.7.tgz", + "integrity": "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.7.tgz", + "integrity": "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@tailwindcss/forms": { + "version": "0.5.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz", + "integrity": "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==", + "license": "MIT", + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.20", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.20.tgz", + "integrity": "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.7", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz", + "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/gensync": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/gensync/-/gensync-1.0.5.tgz", + "integrity": "sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsesc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.4.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz", + "integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz", + "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.0", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz", + "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", + "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.422", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz", + "integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==", + "dev": true, + "license": "ISC" + }, + "node_modules/empathic": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.1.tgz", + "integrity": "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.10.0.tgz", + "integrity": "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", + "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/laravel-precognition": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/laravel-precognition/-/laravel-precognition-2.0.0.tgz", + "integrity": "sha512-dmA4HGc9m+TsVNsJs9/XQBI8u6j7coilN+qKkBuhuXQzH3HypwS/c5dFQ4UqUGjBbcxIM7zdk91kM/SRZwIvWQ==", + "license": "MIT", + "dependencies": { + "es-toolkit": "^1.32.0" + }, + "peerDependencies": { + "axios": "^1.4.0" + }, + "peerDependenciesMeta": { + "axios": { + "optional": true + } + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "license": "MIT", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mobx": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/mobx/-/mobx-7.0.3.tgz", + "integrity": "sha512-KopyyzqZ8xT1fqxFNl8ItKKMpGifRe2ySaNAHs80/cx0mL1bpJtJ46S+FSyzXKRjW0TODWj5zwq/Y9hUdJUy4w==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mobx" + } + }, + "node_modules/mobx-react-lite": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/mobx-react-lite/-/mobx-react-lite-5.0.3.tgz", + "integrity": "sha512-8qT/d/8Cg8ljt+heKhoFHNoqgedyeSGzdTixT0lgbFMMFlWfHUcXHZ8UD63eQi3ELjkxaH1VU07lXWhRwM0cVA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mobx" + }, + "peerDependencies": { + "mobx": "^7.0.0", + "react": "^18 || ^19" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", + "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.148.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.7", + "@rolldown/binding-android-arm64": "1.2.7", + "@rolldown/binding-darwin-arm64": "1.2.7", + "@rolldown/binding-darwin-x64": "1.2.7", + "@rolldown/binding-freebsd-x64": "1.2.7", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.7", + "@rolldown/binding-linux-arm64-gnu": "1.2.7", + "@rolldown/binding-linux-arm64-musl": "1.2.7", + "@rolldown/binding-linux-ppc64-gnu": "1.2.7", + "@rolldown/binding-linux-s390x-gnu": "1.2.7", + "@rolldown/binding-linux-x64-gnu": "1.2.7", + "@rolldown/binding-linux-x64-musl": "1.2.7", + "@rolldown/binding-openharmony-arm64": "1.2.7", + "@rolldown/binding-win32-arm64-msvc": "1.2.7", + "@rolldown/binding-win32-x64-msvc": "1.2.7" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tldts": { + "version": "7.4.11", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.11.tgz", + "integrity": "sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.11" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.11", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.11.tgz", + "integrity": "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-ruby": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/vite-plugin-ruby/-/vite-plugin-ruby-5.2.3.tgz", + "integrity": "sha512-WwUa91eE1A5veI2UiU2WVtTKmmvha8zoCccSi3rT978W43QT+SXzAHYuJ8cirPq58E1ct4okmOCWpsyEbUl6Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "obug": "^2.0", + "tinyglobby": "^0.2.12" + }, + "peerDependencies": { + "vite": ">=5.0.0" + } + }, + "node_modules/vitest": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz", + "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.0", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.0", + "@vitest/browser-preview": "5.0.0", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.0", + "@vitest/coverage-v8": "5.0.0", + "@vitest/ui": "5.0.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz", + "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..5fd8eb47b --- /dev/null +++ b/package.json @@ -0,0 +1,52 @@ +{ + "private": true, + "type": "module", + "devDependencies": { + "@babel/core": "^8.0.1", + "@babel/eslint-parser": "^8.0.1", + "@babel/plugin-syntax-jsx": "^8.0.1", + "@babel/plugin-syntax-typescript": "^8.0.3", + "@eslint/js": "^10.0.1", + "@inertiajs/core": "^3.7.0", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", + "@types/node": "^26.4.1", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "eslint": "^10.10.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react-hooks": "^7.1.1", + "globals": "^17.12.0", + "jsdom": "^30.0.1", + "prettier": "^3.9.6", + "typescript": "^7.0.2", + "vite": "^8.2.2", + "vite-plugin-ruby": "^5.2.3", + "vitest": "^5.0.0" + }, + "scripts": { + "build": "vite build", + "check": "tsc -p tsconfig.app.json && tsc -p tsconfig.node.json", + "lint": "eslint .", + "format": "prettier --write .", + "format:check": "prettier --check .", + "test:frontend": "vitest run" + }, + "dependencies": { + "@inertiajs/react": "^3.7.0", + "@inertiajs/vite": "^3.7.0", + "@rails/actioncable": "^7.2.302", + "@tailwindcss/forms": "^0.5.11", + "@tailwindcss/typography": "^0.5.20", + "@tailwindcss/vite": "^4.3.3", + "@vitejs/plugin-react": "^6.1.1", + "clsx": "^2.1.1", + "mobx": "^7.0.3", + "mobx-react-lite": "^5.0.3", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.3" + } +} diff --git a/public/400.html b/public/400.html new file mode 100644 index 000000000..282dbc8cc --- /dev/null +++ b/public/400.html @@ -0,0 +1,114 @@ + + + + + + + The server cannot process the request due to a client error (400 Bad Request) + + + + + + + + + + + + + +
    +
    + +
    +
    +

    The server cannot process the request due to a client error. Please check the request and try again. If you’re the application owner check the logs for more information.

    +
    +
    + + + + diff --git a/public/404.html b/public/404.html new file mode 100644 index 000000000..c0670bc87 --- /dev/null +++ b/public/404.html @@ -0,0 +1,114 @@ + + + + + + + The page you were looking for doesn’t exist (404 Not found) + + + + + + + + + + + + + +
    +
    + +
    +
    +

    The page you were looking for doesn’t exist. You may have mistyped the address or the page may have moved. If you’re the application owner check the logs for more information.

    +
    +
    + + + + diff --git a/public/406-unsupported-browser.html b/public/406-unsupported-browser.html new file mode 100644 index 000000000..9532a9ccd --- /dev/null +++ b/public/406-unsupported-browser.html @@ -0,0 +1,114 @@ + + + + + + + Your browser is not supported (406 Not Acceptable) + + + + + + + + + + + + + +
    +
    + +
    +
    +

    Your browser is not supported.
    Please upgrade your browser to continue.

    +
    +
    + + + + diff --git a/public/422.html b/public/422.html new file mode 100644 index 000000000..8bcf06014 --- /dev/null +++ b/public/422.html @@ -0,0 +1,114 @@ + + + + + + + The change you wanted was rejected (422 Unprocessable Entity) + + + + + + + + + + + + + +
    +
    + +
    +
    +

    The change you wanted was rejected. Maybe you tried to change something you didn’t have access to. If you’re the application owner check the logs for more information.

    +
    +
    + + + + diff --git a/public/500.html b/public/500.html new file mode 100644 index 000000000..d77718c3a --- /dev/null +++ b/public/500.html @@ -0,0 +1,114 @@ + + + + + + + We’re sorry, but something went wrong (500 Internal Server Error) + + + + + + + + + + + + + +
    +
    + +
    +
    +

    We’re sorry, but something went wrong.
    If you’re the application owner check the logs for more information.

    +
    +
    + + + + diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c4c9dbfbbd2f7c1421ffd5727188146213abbcef GIT binary patch literal 4166 zcmd6qU;WFw?|v@m)Sk^&NvB8tcujdV-r1b=i(NJxn&7{KTb zX$3(M+3TP2o^#KAo{#tIjl&t~(8D-k004kqPglzn0HFG(Q~(I*AKsD#M*g7!XK0T7 zN6P7j>HcT8rZgKl$v!xr806dyN19Bd4C0x_R*I-a?#zsTvb_89cyhuC&T**i|Rc zq5b8M;+{8KvoJ~uj9`u~d_f6`V&3+&ZX9x5pc8s)d175;@pjm(?dapmBcm0&vl9+W zx1ZD2o^nuyUHWj|^A8r>lUorO`wFF;>9XL-Jy!P}UXC{(z!FO%SH~8k`#|9;Q|eue zqWL0^Bp(fg_+Pkm!fDKRSY;+^@BF?AJE zCUWpXPst~hi_~u)SzYBDZroR+Z4xeHIlm_3Yc_9nZ(o_gg!jDgVa=E}Y8uDgem9`b zf=mfJ_@(BXSkW53B)F2s!&?_R4ptb1fYXlF++@vPhd=marQgEGRZS@B4g1Mu?euknL= z67P~tZ?*>-Hmi7GwlisNHHJDku-dSm7g@!=a}9cSL6Pa^w^2?&?$Oi8ibrr>w)xqx zOH_EMU@m05)9kuNR>>4@H%|){U$^yvVQ(YgOlh;5oU_-vivG-p4=LrN-k7D?*?u1u zsWly%tfAzKd6Fb=`eU2un_uaTXmcT#tlOL+aRS=kZZf}A7qT8lvcTx~7j` z*b>=z)mwg7%B2_!D0!1IZ?Nq{^Y$uI4Qx*6T!E2Col&2{k?ImCO=dD~A&9f9diXy^$x{6CwkBimn|1E09 zAMSezYtiL?O6hS37KpvDM?22&d{l)7h-!F)C-d3j8Z`c@($?mfd{R82)H>Qe`h{~G z!I}(2j(|49{LR?w4Jspl_i!(4T{31|dqCOpI52r5NhxYV+cDAu(xp*4iqZ2e-$YP= zoFOPmm|u*7C?S{Fp43y+V;>~@FFR76bCl@pTtyB93vNWy5yf;HKr8^0d7&GVIslYm zo3Tgt@M!`8B6IW&lK{Xk>%zp41G%`(DR&^u z5^pwD4>E6-w<8Kl2DzJ%a@~QDE$(e87lNhy?-Qgep!$b?5f7+&EM7$e>|WrX+=zCb z=!f5P>MxFyy;mIRxjc(H*}mceXw5a*IpC0PEYJ8Y3{JdoIW)@t97{wcUB@u+$FCCO z;s2Qe(d~oJC^`m$7DE-dsha`glrtu&v&93IZadvl_yjp!c89>zo;Krk+d&DEG4?x$ zufC1n+c1XD7dolX1q|7}uelR$`pT0Z)1jun<39$Sn2V5g&|(j~Z!wOddfYiZo7)A< z!dK`aBHOOk+-E_xbWCA3VR-+o$i5eO9`rMI#p_0xQ}rjEpGW;U!&&PKnivOcG(|m9 z!C8?WC6nCXw25WVa*eew)zQ=h45k8jSIPbq&?VE{oG%?4>9rwEeB4&qe#?-y_es4c|7ufw%+H5EY#oCgv!Lzv291#-oNlX~X+Jl5(riC~r z=0M|wMOP)Tt8@hNg&%V@Z9@J|Q#K*hE>sr6@oguas9&6^-=~$*2Gs%h#GF@h)i=Im z^iKk~ipWJg1VrvKS;_2lgs3n1zvNvxb27nGM=NXE!D4C!U`f*K2B@^^&ij9y}DTLB*FI zEnBL6y{jc?JqXWbkIZd7I16hA>(f9T!iwbIxJj~bKPfrO;>%*5nk&Lf?G@c2wvGrY&41$W{7HM9+b@&XY@>NZM5s|EK_Dp zQX60CBuantx>|d#DsaZ*8MW(we|#KTYZ=vNa#d*DJQe6hr~J6{_rI#?wi@s|&O}FR zG$kfPxheXh1?IZ{bDT-CWB4FTvO-k5scW^mi8?iY5Q`f8JcnnCxiy@m@D-%lO;y0pTLhh6i6l@x52j=#^$5_U^os}OFg zzdHbo(QI`%9#o*r8GCW~T3UdV`szO#~)^&X_(VW>o~umY9-ns9-V4lf~j z`QBD~pJ4a#b`*6bJ^3RS5y?RAgF7K5$ll97Y8#WZduZ`j?IEY~H(s^doZg>7-tk*t z4_QE1%%bb^p~4F5SB$t2i1>DBG1cIo;2(xTaj*Y~hlM{tSDHojL-QPg%Mo%6^7FrpB*{ z4G0@T{-77Por4DCMF zB_5Y~Phv%EQ64W8^GS6h?x6xh;w2{z3$rhC;m+;uD&pR74j+i22P5DS-tE8ABvH(U~indEbBUTAAAXfHZg5QpB@TgV9eI<)JrAkOI z8!TSOgfAJiWAXeM&vR4Glh;VxH}WG&V$bVb`a`g}GSpwggti*&)taV1@Ak|{WrV|5 zmNYx)Ans=S{c52qv@+jmGQ&vd6>6yX6IKq9O$3r&0xUTdZ!m1!irzn`SY+F23Rl6# zFRxws&gV-kM1NX(3(gnKpGi0Q)Dxi~#?nyzOR9!en;Ij>YJZVFAL*=R%7y%Mz9hU% zs>+ZB?qRmZ)nISx7wxY)y#cd$iaC~{k0avD>BjyF1q^mNQ1QcwsxiTySe<6C&cC6P zE`vwO9^k-d`9hZ!+r@Jnr+MF*2;2l8WjZ}DrwDUHzSF{WoG zucbSWguA!3KgB3MU%HH`R;XqVv0CcaGq?+;v_A5A2kpmk5V%qZE3yzQ7R5XWhq=eR zyUezH=@V)y>L9T-M-?tW(PQYTRBKZSVb_!$^H-Pn%ea;!vS_?M<~Tm>_rWIW43sPW z=!lY&fWc1g7+r?R)0p8(%zp&vl+FK4HRkns%BW+Up&wK8!lQ2~bja|9bD12WrKn#M zK)Yl9*8$SI7MAwSK$%)dMd>o+1UD<2&aQMhyjS5R{-vV+M;Q4bzl~Z~=4HFj_#2V9 zB)Gfzx3ncy@uzx?yzi}6>d%-?WE}h7v*w)Jr_gBl!2P&F3DX>j_1#--yjpL%<;JMR z*b70Gr)MMIBWDo~#<5F^Q0$VKI;SBIRneuR7)yVsN~A9I@gZTXe)E?iVII+X5h0~H zx^c(fP&4>!*q>fb6dAOC?MI>Cz3kld#J*;uik+Ps49cwm1B4 zZc1|ZxYyTv;{Z!?qS=D)sgRKx^1AYf%;y_V&VgZglfU>d+Ufk5&LV$sKv}Hoj+s; xK3FZRYdhbXT_@RW*ff3@`D1#ps#~H)p+y&j#(J|vk^lW{fF9OJt5(B-_&*Xgn9~3N literal 0 HcmV?d00001 diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 000000000..04b34bf83 --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 000000000..c19f78ab6 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file diff --git a/script/.keep b/script/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/storage/.keep b/storage/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb new file mode 100644 index 000000000..cee29fd21 --- /dev/null +++ b/test/application_system_test_case.rb @@ -0,0 +1,5 @@ +require "test_helper" + +class ApplicationSystemTestCase < ActionDispatch::SystemTestCase + driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ] +end diff --git a/test/channels/application_cable/connection_test.rb b/test/channels/application_cable/connection_test.rb new file mode 100644 index 000000000..f6b31fa0e --- /dev/null +++ b/test/channels/application_cable/connection_test.rb @@ -0,0 +1,25 @@ +require "test_helper" + +class ApplicationCableConnectionTest < ActionCable::Connection::TestCase + tests ApplicationCable::Connection + + test "connects when the signed session cookie is present" do + user = users(:one) + app_session = user.sessions.create!(user_agent: "cable", ip_address: "127.0.0.1") + cookies.signed[:session_id] = app_session.id + + connect + + assert_equal user, connection.current_user + end + + test "rejects connections without a session cookie" do + assert_reject_connection { connect } + end + + test "rejects connections with an unknown session id" do + cookies.signed[:session_id] = 999_999 + + assert_reject_connection { connect } + end +end diff --git a/test/channels/dashboard_channel_test.rb b/test/channels/dashboard_channel_test.rb new file mode 100644 index 000000000..2a1a0be08 --- /dev/null +++ b/test/channels/dashboard_channel_test.rb @@ -0,0 +1,21 @@ +require "test_helper" + +class DashboardChannelTest < ActionCable::Channel::TestCase + tests DashboardChannel::Channel + test "subscribes an admin reader to the dashboard_stats stream" do + stub_connection current_user: users(:one) + + subscribe + + assert subscription.confirmed? + assert_has_stream "dashboard_stats" + end + + test "rejects non-admin subscriptions" do + stub_connection current_user: users(:two) + + subscribe + + assert subscription.rejected? + end +end diff --git a/test/channels/import_progress_channel_test.rb b/test/channels/import_progress_channel_test.rb new file mode 100644 index 000000000..730f1a1dc --- /dev/null +++ b/test/channels/import_progress_channel_test.rb @@ -0,0 +1,35 @@ +require "test_helper" + +class ImportProgressChannelTest < ActionCable::Channel::TestCase + tests ImportProgressChannel::Channel + test "subscribes an admin to the import stream" do + import = UserImport.create!(user: users(:one)) do |record| + record.file.attach(io: StringIO.new("Full name,Email address\n"), filename: "users.csv", content_type: "text/csv") + end + stub_connection current_user: users(:one) + + subscribe import_id: import.id + + assert subscription.confirmed? + assert_has_stream_for import + end + + test "rejects unknown imports" do + stub_connection current_user: users(:one) + + subscribe import_id: 999_999 + + assert subscription.rejected? + end + + test "rejects non-admin subscriptions" do + import = UserImport.create!(user: users(:one)) do |record| + record.file.attach(io: StringIO.new("Full name,Email address\n"), filename: "users.csv", content_type: "text/csv") + end + stub_connection current_user: users(:two) + + subscribe import_id: import.id + + assert subscription.rejected? + end +end diff --git a/test/contracts/presenter_contract_test.rb b/test/contracts/presenter_contract_test.rb new file mode 100644 index 000000000..83cdfcae5 --- /dev/null +++ b/test/contracts/presenter_contract_test.rb @@ -0,0 +1,64 @@ +require "test_helper" + +# Proves DRY between the Ruby presenters/serializers and the frontend TS +# contracts (app/frontend/types/index.ts): a presenter that drifts from its +# TS interface fails the suite, keeping both sides in lockstep (fase-10). +class PresenterContractTest < ActiveSupport::TestCase + TYPES_SOURCE = Rails.root.join("app/frontend/types/index.ts").read.freeze + + def ts_fields(interface) + block = TYPES_SOURCE.match(/export type #{interface} = (?:\w+ & )?\{(.*?)\n\}/m) + raise "TS interface #{interface} not found" unless block + + block[1].lines.map { |line| line[/^\s*(\w+)\??:/, 1] }.compact + end + + def intersection?(interface) + TYPES_SOURCE.match?(/export type #{interface} = \w+ & \{/) + end + + def assert_matches_interface(serialized, interface) + ruby = serialized.keys.map(&:to_s) + ts = ts_fields(interface) + + if intersection?(interface) + missing = ts - ruby + assert_empty missing, "#{interface} expects missing keys #{missing.inspect}" + else + assert_empty ruby - ts, "#{interface} doesn't know #{(ruby - ts).inspect}" + assert_empty ts - ruby, "#{interface} expects missing keys #{(ts - ruby).inspect}" + end + end + + test "UserPresenter matches the User TS interface" do + assert_matches_interface UserPresenter.new(users(:one)).to_inertia, "User" + end + + test "Users::Overview matches the UsersOverview TS interface" do + overview = Users::Overview.new(total_users: 2, by_role: { "user" => 1, "admin" => 1 }) + assert_matches_interface overview.to_inertia, "UsersOverview" + end + + test "UserImportPresenter matches the AdminImport TS interface" do + assert_matches_interface UserImportPresenter.new(user_imports(:one)).to_inertia, "AdminImport" + end + + test "admin user items match the AdminUser TS interface" do + item = UserPresenter.new(users(:one)).to_inertia.merge( + edit_url: "/admin/users/1/edit", + toggle_url: "/admin/users/1/toggle", + delete_url: "/admin/users/1" + ) + assert_matches_interface item, "AdminUser" + end + + test "admin import list items match the AdminImportListItem TS interface" do + item = UserImportPresenter.new(user_imports(:one)).to_inertia.merge(show_url: "/admin/user_imports/1") + assert_matches_interface item, "AdminImportListItem" + end + + test "listing pagination matches the Pagination TS interface" do + pagination = { page: 1, pages: 2, count: 20, items: 10, prev: nil, next: nil } + assert_matches_interface pagination, "Pagination" + end +end diff --git a/test/controllers/.keep b/test/controllers/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/test/fixtures/files/.keep b/test/fixtures/files/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/test/fixtures/files/avatar.png b/test/fixtures/files/avatar.png new file mode 100644 index 0000000000000000000000000000000000000000..f37764b1f7606623616dcdc169cc858273ea2d94 GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZYBRYe;|OLfu)tPp=D){ QB2a?C)78&qol`;+0Lr!y6951J literal 0 HcmV?d00001 diff --git a/test/fixtures/files/users.csv b/test/fixtures/files/users.csv new file mode 100644 index 000000000..81b0c3197 --- /dev/null +++ b/test/fixtures/files/users.csv @@ -0,0 +1,3 @@ +Full name,Email address +Alice Import,alice.import@iam.test +Bob Import,bob.import@iam.test \ No newline at end of file diff --git a/test/fixtures/user_imports.yml b/test/fixtures/user_imports.yml new file mode 100644 index 000000000..fda2ba3d5 --- /dev/null +++ b/test/fixtures/user_imports.yml @@ -0,0 +1,19 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + user: one + status: 1 + total_rows: 1 + processed_rows: 1 + failed_rows: 1 + finished_at: 2026-09-06 15:03:28 + error_message: MyText + +two: + user: two + status: 1 + total_rows: 1 + processed_rows: 1 + failed_rows: 1 + finished_at: 2026-09-06 15:03:28 + error_message: MyText diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml new file mode 100644 index 000000000..b8e7132bf --- /dev/null +++ b/test/fixtures/users.yml @@ -0,0 +1,15 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + email_address: admin@iam.test + password_digest: <%= BCrypt::Password.create("Password123!") %> + full_name: Ivy Admin + role: 1 + avatar_url: https://example.com/avatars/admin.png + +two: + email_address: user@iam.test + password_digest: <%= BCrypt::Password.create("Password123!") %> + full_name: Ursula User + role: 0 + avatar_url: https://example.com/avatars/user.png \ No newline at end of file diff --git a/test/forms/admin/user_form_test.rb b/test/forms/admin/user_form_test.rb new file mode 100644 index 000000000..214b5b618 --- /dev/null +++ b/test/forms/admin/user_form_test.rb @@ -0,0 +1,108 @@ +require "test_helper" + +class Admin::UserFormTest < ActiveSupport::TestCase + test "creates an admin user" do + form = form_for(role: "admin", full_name: "Bob Admin", email_address: "bob@iam.test") + + assert form.save + assert form.user.persisted? + assert_equal "admin", form.user.role + assert form.user.authenticate("Password123!") + end + + test "creates a regular user" do + form = form_for(full_name: "Carol User", email_address: "carol@iam.test") + + assert form.save + assert_equal "user", form.user.role + end + + test "create requires a password" do + form = form_for(full_name: "No Pass", email_address: "nopass@iam.test", password: "", password_confirmation: "") + + assert_not form.save + assert form.errors[:password].present? + assert_not form.user.persisted? + end + + test "create rejects an unknown role" do + form = form_for(full_name: "Rogue", email_address: "rogue@iam.test", role: "superadmin") + + assert_not form.save + assert form.errors[:role].present? + end + + test "update does not require a password and can change the role" do + user = users(:two) + form = Admin::UserForm.new( + { + full_name: "Ursula Updated", + email_address: user.email_address, + role: "admin", + avatar_url: user.avatar_url + }, + user: user + ) + + assert form.update + assert_equal "Ursula Updated", user.reload.full_name + assert_equal "admin", user.role + end + + test "update accepts an optional new password" do + user = users(:two) + form = Admin::UserForm.new( + attributes_for(user).merge(password: "NewPassword123!", password_confirmation: "NewPassword123!"), + user: user + ) + + assert form.update + assert user.reload.authenticate("NewPassword123!") + end + + test "update keeps the avatar when the form does not touch it" do + user = users(:one) + form = Admin::UserForm.new(attributes_for(user), user: user) + + assert form.update + assert_equal "https://example.com/avatars/admin.png", user.reload.avatar_url + end + + test "update rejects a mismatched password confirmation" do + user = users(:two) + form = Admin::UserForm.new( + attributes_for(user).merge(password: "NewPassword123!", password_confirmation: "Different123!"), + user: user + ) + + assert_not form.update + assert form.errors[:password_confirmation].present? + assert_not user.authenticate("NewPassword123!") + end + + private + + def form_for(**overrides) + Admin::UserForm.new(valid_registration_attributes.merge(overrides)) + end + + def valid_registration_attributes + { + full_name: "Valid Admin", + email_address: "valid-admin@iam.test", + role: "user", + password: "Password123!", + password_confirmation: "Password123!", + avatar_url: "https://example.com/avatars/valid-admin.png" + } + end + + def attributes_for(user) + { + full_name: user.full_name, + email_address: user.email_address, + role: user.role, + avatar_url: user.avatar_url + } + end +end diff --git a/test/forms/users/password_reset_form_test.rb b/test/forms/users/password_reset_form_test.rb new file mode 100644 index 000000000..3cd51baef --- /dev/null +++ b/test/forms/users/password_reset_form_test.rb @@ -0,0 +1,49 @@ +require "test_helper" + +class PasswordResetFormTest < ActiveSupport::TestCase + test "resets only the password without touching identity" do + user = users(:two) + form = form_for(user, password: "NewPass123!", password_confirmation: "NewPass123!") + + assert form.update + assert user.reload.authenticate("NewPass123!") + assert_equal "Ursula User", user.full_name + assert_equal "user@iam.test", user.email_address + assert_empty form.errors + end + + test "rejects a blank password" do + user = users(:two) + form = form_for(user, password: "", password_confirmation: "") + + assert_not form.update + assert_includes form.errors[:password], "can't be blank" + end + + test "rejects a mismatched confirmation" do + user = users(:two) + form = form_for(user, password: "NewPass123!", password_confirmation: "Different123!") + + assert_not form.update + assert_includes form.errors[:password_confirmation], "doesn't match Password confirmation" + end + + test "does not require an avatar (imported users without one can reset)" do + user = User.create!( + full_name: "Sem Avatar", + email_address: "sem.avatar@iam.test", + password: "OldPass123!", + password_confirmation: "OldPass123!" + ) + form = form_for(user, password: "NewPass123!", password_confirmation: "NewPass123!") + + assert form.update + assert user.reload.authenticate("NewPass123!") + end + + private + + def form_for(user, **overrides) + Users::PasswordResetForm.new(overrides, user: user) + end +end diff --git a/test/forms/users/profile_form_test.rb b/test/forms/users/profile_form_test.rb new file mode 100644 index 000000000..ec519541f --- /dev/null +++ b/test/forms/users/profile_form_test.rb @@ -0,0 +1,86 @@ +require "test_helper" + +class Users::ProfileFormTest < ActiveSupport::TestCase + test "updates the own full name and email" do + user = users(:two) + form = Users::ProfileForm.new( + { full_name: "Renamed User", email_address: "renamed@iam.test", avatar_url: user.avatar_url }, + user: user + ) + + assert form.update + assert_equal "Renamed User", user.reload.full_name + assert_equal "renamed@iam.test", user.reload.email_address + end + + test "does not require a password" do + user = users(:two) + form = Users::ProfileForm.new(attributes_for(user), user: user) + + assert form.update + assert_empty form.errors[:password] + end + + test "updates the password when provided and confirmed" do + user = users(:two) + form = Users::ProfileForm.new( + attributes_for(user).merge(password: "NewPassword123!", password_confirmation: "NewPassword123!"), + user: user + ) + + assert form.update + assert user.reload.authenticate("NewPassword123!") + end + + test "rejects a password without a matching confirmation" do + user = users(:two) + form = Users::ProfileForm.new( + attributes_for(user).merge(password: "NewPassword123!", password_confirmation: "Different123!"), + user: user + ) + + assert_not form.update + assert form.errors[:password_confirmation].present? + assert_not user.authenticate("NewPassword123!") + end + + test "rejects a short password" do + user = users(:two) + form = Users::ProfileForm.new( + attributes_for(user).merge(password: "short", password_confirmation: "short"), + user: user + ) + + assert_not form.update + assert form.errors[:password].present? + end + + test "rejects a duplicate email address merging the model error" do + user = users(:two) + form = Users::ProfileForm.new( + { full_name: user.full_name, email_address: users(:one).email_address, avatar_url: user.avatar_url }, + user: user + ) + + assert_not form.update + assert_includes form.errors[:email_address], "has already been taken" + end + + test "keeps the avatar when the form does not touch it" do + user = users(:one) + form = Users::ProfileForm.new(attributes_for(user), user: user) + + assert form.update + assert_equal "https://example.com/avatars/admin.png", user.reload.avatar_url + end + + private + + def attributes_for(user) + { + full_name: user.full_name, + email_address: user.email_address, + avatar_url: user.avatar_url + } + end +end diff --git a/test/forms/users/registration_form_test.rb b/test/forms/users/registration_form_test.rb new file mode 100644 index 000000000..d434d965f --- /dev/null +++ b/test/forms/users/registration_form_test.rb @@ -0,0 +1,112 @@ +require "test_helper" + +class RegistrationFormTest < ActiveSupport::TestCase + test "persists a user with the :user role regardless of caller input" do + form = form_for( + full_name: "New Registrar", + email_address: "REGISTRAR@iam.test " + ) + + assert form.save + user = form.user + assert_predicate user, :user? + assert_equal "registrar@iam.test", user.email_address + assert_empty form.errors[:base] + end + + test "inherits the avatar source rule from the base form (DRY)" do + form = form_for(full_name: "No Avatar") + form.avatar_url = nil + + assert_no_difference "User.count" do + assert_not form.save + end + assert_equal [ "Provide an avatar by upload or a remote URL" ], form.errors[:base] + end + + test "requires a password and a matching confirmation" do + form = form_for(full_name: "Weak Pass", password: "", password_confirmation: "") + + assert_no_difference "User.count" do + assert_not form.save + end + assert_includes form.errors[:password], "can't be blank" + + form = form_for(full_name: "Mismatch", password: "Password123!", password_confirmation: "Different123!") + + assert_not form.save + assert_includes form.errors[:password_confirmation], "doesn't match Password confirmation" + end + + test "rejects a duplicate email address merging the model error" do + form = form_for(full_name: "Duplicate", email_address: users(:one).email_address) + + assert_no_difference "User.count" do + assert_not form.save + end + assert_includes form.errors[:email_address], "has already been taken" + end + + test "import rows can skip duplicate emails without failing" do + existing = users(:two) + form = form_for( + full_name: existing.full_name, + email_address: existing.email_address, + ignore_duplicate_emails: true, + require_avatar: false + ) + + assert form.save + assert_empty form.errors + assert_equal existing.id, form.user.id + assert_equal 1, User.where(email_address: existing.email_address).count + end + + test "duplicates still fail without the import option (default behavior)" do + existing = users(:two) + form = form_for( + full_name: "Intrusa", + email_address: existing.email_address, + require_avatar: false + ) + + assert_not form.save + assert_includes form.errors[:email_address], "has already been taken" + end + + test "import rows do not require an avatar" do + form = form_for( + full_name: "Sem Avatar", + email_address: "sem.avatar@iam.test", + require_avatar: false + ) + + assert form.save + assert_empty form.errors[:base] + end + + test "rejects a non-http avatar URL" do + form = form_for(avatar_url: "javascript:alert(1)") + + assert_no_difference "User.count" do + assert_not form.save + end + assert_includes form.errors[:avatar_url], "must be a valid http(s) URL" + end + + private + + def form_for(**overrides) + Users::RegistrationForm.new(valid_registration_attributes.merge(overrides)) + end + + def valid_registration_attributes + { + full_name: "Valid User", + email_address: "valid@iam.test", + password: "Password123!", + password_confirmation: "Password123!", + avatar_url: "https://example.com/avatars/valid.png" + } + end +end diff --git a/test/helpers/.keep b/test/helpers/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/test/integration/.keep b/test/integration/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/test/integration/admin_user_imports_flow_test.rb b/test/integration/admin_user_imports_flow_test.rb new file mode 100644 index 000000000..20925f399 --- /dev/null +++ b/test/integration/admin_user_imports_flow_test.rb @@ -0,0 +1,68 @@ +require "test_helper" + +class AdminUserImportsFlowTest < ActionDispatch::IntegrationTest + setup do + post session_path, params: { email_address: users(:one).email_address, password: "Password123!" } + end + + test "index lists the import history through the presenter" do + get admin_user_imports_path + + assert_inertia_response + assert_inertia_component "Admin/UserImports/Index" + assert_inertia_props { |props| props[:pagination][:count] == UserImport.count } + end + + test "new page shows the upload form plus recent history" do + get new_admin_user_import_path + + assert_inertia_component "Admin/UserImports/New" + assert_inertia_props { |props| props[:urls][:submit_url] == admin_user_imports_path } + end + + test "uploading a csv enqueues the job and processes rows to completion" do + assert_enqueued_with(job: UserImports::Job) do + post admin_user_imports_path, params: { file: fixture_file_upload("users.csv", "text/csv") } + end + + user_import = UserImport.last + assert_redirected_to admin_user_import_path(user_import) + assert_predicate user_import, :pending? + assert_not_nil user_import.file.attached? + + perform_enqueued_jobs + + user_import.reload + assert_predicate user_import, :completed? + assert_equal 2, user_import.total_rows + assert_equal 2, user_import.processed_rows + assert_equal 0, user_import.failed_rows + assert_equal 2, User.where(email_address: %w[alice.import@iam.test bob.import@iam.test]).count + end + + test "show renders details used by the progress bar" do + user_import = UserImport.last + + get admin_user_import_path(user_import) + + assert_inertia_component "Admin/UserImports/Show" + assert_inertia_props do |props| + props[:import][:id] == user_import.id && props[:import].key?(:progress) + end + end + + test "unsupported files re-render new with a model error" do + post admin_user_imports_path, params: { file: fixture_file_upload("avatar.png", "image/png") } + + assert_inertia_component "Admin/UserImports/New" + assert_inertia_props { |props| props.dig(:errors, :file).present? } + end + + test "non-admin users cannot reach the imports area" do + delete session_path + post session_path, params: { email_address: users(:two).email_address, password: "Password123!" } + + get admin_user_imports_path + assert_redirected_to root_path + end +end diff --git a/test/integration/admin_users_flow_test.rb b/test/integration/admin_users_flow_test.rb new file mode 100644 index 000000000..b67d15c73 --- /dev/null +++ b/test/integration/admin_users_flow_test.rb @@ -0,0 +1,207 @@ +require "test_helper" + +class AdminUsersFlowTest < ActionDispatch::IntegrationTest + setup do + post session_path, params: { email_address: users(:one).email_address, password: "Password123!" } + end + + test "dashboard shows the users overview" do + get admin_root_path + + assert_inertia_response + assert_inertia_component "Admin/Dashboard" + assert_inertia_props { |props| props[:overview][:total_users] == User.count } + end + + test "lists users with pagination props" do + get admin_users_path + + assert_inertia_response + assert_inertia_component "Admin/Users/Index" + assert_inertia_props do |props| + props[:users].length == 2 && props[:pagination][:count] == 2 + end + end + + test "filters users by name or email" do + get admin_users_path, params: { search: "ivy" } + + assert_inertia_props { |props| props[:users].length == 1 && props[:filters][:search] == "ivy" } + end + + test "creates a user and redirects to the list" do + assert_broadcasts "dashboard_stats", 1 do + assert_difference "User.count", 1 do + post admin_users_path, params: { + full_name: "Dana Admin", + email_address: "dana@iam.test", + role: "admin", + password: "Password123!", + password_confirmation: "Password123!", + avatar_url: "https://example.com/avatars/dana.png" + } + end + end + + assert_redirected_to admin_users_path + assert_equal "admin", User.find_by!(email_address: "dana@iam.test").role + + follow_redirect! + assert_inertia_component "Admin/Users/Index" + end + + test "create failure re-renders the new page with errors" do + assert_no_difference "User.count" do + post admin_users_path, params: { + full_name: "", + email_address: "bad@iam.test", + role: "user", + password: "Password123!", + password_confirmation: "Password123!", + avatar_url: "https://example.com/avatars/bad.png" + } + end + + assert_inertia_response + assert_inertia_component "Admin/Users/New" + assert_inertia_props { |props| props.dig(:errors, :full_name).present? } + end + + test "edits a user without touching the password" do + patch admin_user_path(users(:two)), params: { + full_name: "Ursula Updated", + email_address: users(:two).email_address, + role: "user", + avatar_url: users(:two).avatar_url + } + + assert_redirected_to admin_users_path + assert_equal "Ursula Updated", users(:two).reload.full_name + end + + test "edits a user with a new password" do + patch admin_user_path(users(:two)), params: { + full_name: users(:two).full_name, + email_address: users(:two).email_address, + role: "user", + avatar_url: users(:two).avatar_url, + password: "NewPassword123!", + password_confirmation: "NewPassword123!" + } + + assert users(:two).reload.authenticate("NewPassword123!") + end + + test "edit page pre-fills the current user data" do + get edit_admin_user_path(users(:two)) + + assert_inertia_props do |props| + props.dig(:form, :full_name) == users(:two).full_name && + props.dig(:form, :email_address) == users(:two).email_address && + props.dig(:form, :role) == "user" + end + end + + test "deletes a regular user" do + assert_broadcasts "dashboard_stats", 1 do + assert_difference "User.count", -1 do + delete admin_user_path(users(:two)) + end + end + + assert_redirected_to admin_users_path + assert_equal "User deleted successfully.", flash[:notice] + end + + test "an admin cannot delete themselves" do + assert_no_difference "User.count" do + delete admin_user_path(users(:one)) + end + + assert_redirected_to admin_users_path + assert_equal "You cannot delete this user.", flash[:alert] + end + + test "toggles a user into an admin and back" do + assert_broadcasts "dashboard_stats", 2 do + patch toggle_role_admin_user_path(users(:two)) + assert_equal "admin", users(:two).reload.role + patch toggle_role_admin_user_path(users(:two)) + end + + assert_equal "user", users(:two).reload.role + end + + test "an admin cannot demote themselves" do + patch toggle_role_admin_user_path(users(:one)) + + assert_equal "admin", users(:one).reload.role + assert_equal "You cannot change this user's role.", flash[:alert] + end + + test "a regular user is blocked from the admin users area" do + delete session_path + post session_path, params: { email_address: users(:two).email_address, password: "Password123!" } + + get admin_users_path + assert_redirected_to root_path + + post admin_users_path, params: { full_name: "Intruder", email_address: "x@iam.test", role: "user" } + assert_response :redirect + assert_redirected_to root_path + end + + test "renders the new user form" do + get new_admin_user_path + + assert_inertia_response + assert_inertia_component "Admin/Users/New" + assert_inertia_props { |props| props.dig(:urls, :submit_url) == admin_users_path } + end + + test "a failed create re-renders the new page with errors" do + assert_no_difference "User.count" do + post admin_users_path, params: { + full_name: "Broken", + email_address: users(:two).email_address, + role: "user", + password: "", + avatar_url: "https://example.com/a.png" + } + end + + assert_inertia_response + assert_inertia_component "Admin/Users/New" + assert_inertia_props { |props| props.dig(:errors, :password).present? } + end + + test "a duplicate email on create re-renders the new page with the taken error" do + assert_no_difference "User.count" do + post admin_users_path, params: { + full_name: "Dup", + email_address: users(:two).email_address, + role: "user", + password: "Password123!", + password_confirmation: "Password123!", + avatar_url: "https://example.com/a.png" + } + end + + assert_inertia_response + assert_inertia_component "Admin/Users/New" + assert_inertia_props { |props| props.dig(:errors, :email_address).to_s.include?("taken") } + end + + test "a failed update re-renders the edit page with errors" do + patch admin_user_path(users(:two)), params: { + full_name: "", + email_address: users(:two).email_address, + role: "user", + avatar_url: users(:two).avatar_url + } + + assert_inertia_response + assert_inertia_component "Admin/Users/Edit" + assert_inertia_props { |props| props.dig(:errors, :full_name).present? } + end +end diff --git a/test/integration/authentication_flow_test.rb b/test/integration/authentication_flow_test.rb new file mode 100644 index 000000000..803c4591f --- /dev/null +++ b/test/integration/authentication_flow_test.rb @@ -0,0 +1,122 @@ +require "test_helper" + +class AuthenticationFlowTest < ActionDispatch::IntegrationTest + test "visitor registers and is signed in as a regular user on /profile" do + assert_difference "User.count", 1 do + post registration_path, params: { + full_name: "New Visitor", + email_address: "visitor@iam.test", + password: "Password123!", + password_confirmation: "Password123!", + avatar_url: "https://example.com/avatars/visitor.png" + } + end + + assert_redirected_to profile_path + assert_equal "user", User.find_by!(email_address: "visitor@iam.test").role + assert_includes response.cookies.keys, "session_id" + + follow_redirect! + assert_inertia_response + assert_inertia_component "Profile/Show" + end + + test "visitor registers with an avatar upload instead of a URL" do + assert_difference "User.count", 1 do + post registration_path, params: { + full_name: "Upload Avatar", + email_address: "upload@iam.test", + avatar: fixture_file_upload("avatar.png", "image/png"), + avatar_url: "https://example.com/ignored.png", + password: "Password123!", + password_confirmation: "Password123!" + } + end + + user = User.find_by!(email_address: "upload@iam.test") + assert user.avatar.attached? + assert_nil user.avatar_url + assert_redirected_to profile_path + end + + test "registration requires an avatar source" do + assert_no_difference "User.count" do + post registration_path, params: { + full_name: "No Avatar", + email_address: "noavatar@iam.test", + password: "Password123!", + password_confirmation: "Password123!" + } + end + + assert_inertia_response + assert_inertia_component "Auth/Register" + assert_inertia_props({ errors: { base: [ "Provide an avatar by upload or a remote URL" ] } }) + end + + test "registration rejects a duplicate email address" do + assert_no_difference "User.count" do + post registration_path, params: { + full_name: "Dup", + email_address: users(:one).email_address, + password: "Password123!", + password_confirmation: "Password123!", + avatar_url: "https://example.com/avatars/x.png" + } + end + + assert_inertia_response + assert_inertia_component "Auth/Register" + assert_inertia_props { |props| props.dig(:errors, :email_address).to_s.include?("taken") } + end + + test "admin is redirected to /admin after login" do + post session_path, params: { email_address: users(:one).email_address, password: "Password123!" } + assert_redirected_to admin_root_path + + follow_redirect! + assert_inertia_response + assert_inertia_component "Admin/Dashboard" + end + + test "regular user is redirected to /profile after login" do + post session_path, params: { email_address: users(:two).email_address, password: "Password123!" } + assert_redirected_to profile_path + end + + test "non-admin is blocked from the admin area" do + post session_path, params: { email_address: users(:two).email_address, password: "Password123!" } + + get admin_root_path + assert_redirected_to root_path + + follow_redirect! + assert_redirected_to profile_path + end + + test "registration is rate limited after ten attempts" do + params = { + full_name: "Rate Limit", + email_address: "ratelimit@iam.test", + password: "Password123!", + password_confirmation: "Password123!", + avatar_url: "https://example.com/avatars/x.png" + } + + 10.times do + post registration_path, params: params + assert_not flash[:alert] == "Try again later." + end + + post registration_path, params: params + assert_redirected_to new_registration_path + assert_equal "Try again later.", flash[:alert] + end + + test "the registration page is reachable without a session" do + get new_registration_path + + assert_inertia_response + assert_inertia_component "Auth/Register" + end +end diff --git a/test/integration/inertia_share_test.rb b/test/integration/inertia_share_test.rb new file mode 100644 index 000000000..e7f9df8e6 --- /dev/null +++ b/test/integration/inertia_share_test.rb @@ -0,0 +1,38 @@ +require "test_helper" + +class InertiaShareTest < ActionDispatch::IntegrationTest + test "root response shares auth user through the presenter" do + sign_in_as users(:one) + follow_redirect! + + assert_inertia_props auth: { + user: { + id: users(:one).id, + full_name: "Ivy Admin", + email_address: "admin@iam.test", + role: "admin", + avatar_source: "https://example.com/avatars/admin.png" + } + } + end + + test "root response shares normalized flash after sign in" do + post session_path, params: { email_address: users(:one).email_address, password: "Password123!" } + follow_redirect! + + assert_inertia_response + assert_inertia_flash notice: "Signed in successfully." + end + + test "unauthenticated root redirects to session new" do + get root_path + + assert_redirected_to new_session_path + end + + private + + def sign_in_as(user) + post session_path, params: { email_address: user.email_address, password: "Password123!" } + end +end diff --git a/test/integration/passwords_flow_test.rb b/test/integration/passwords_flow_test.rb new file mode 100644 index 000000000..8bd401f1b --- /dev/null +++ b/test/integration/passwords_flow_test.rb @@ -0,0 +1,76 @@ +require "test_helper" + +class PasswordsFlowTest < ActionDispatch::IntegrationTest + test "forgot password page is reachable without a session" do + get new_password_path + + assert_inertia_response + assert_inertia_component "Auth/ForgotPassword" + assert_inertia_props({ urls: { login_url: new_session_path } }) + end + + test "requesting a reset for a known email enqueues the reset mailer" do + user = users(:two) + + assert_emails 1 do + post passwords_path, params: { email_address: user.email_address } + end + + assert_redirected_to new_session_path + assert_equal "Password reset instructions sent (if user with that email address exists).", flash[:notice] + end + + test "requesting a reset for an unknown email does not leak and still redirects" do + assert_no_emails do + post passwords_path, params: { email_address: "ghost@iam.test" } + end + + assert_redirected_to new_session_path + assert_equal "Password reset instructions sent (if user with that email address exists).", flash[:notice] + end + + test "resetting the password succeeds with a valid token" do + user = users(:two) + token = user.generate_token_for(:password_reset) + + get edit_password_path(token) + assert_inertia_response + assert_inertia_component "Auth/ResetPassword" + assert_inertia_props({ token: token }) + + patch password_path(token), params: { + password: "NewPassword123!", + password_confirmation: "NewPassword123!" + } + + assert_redirected_to new_session_path + assert_equal "Password has been reset.", flash[:notice] + + post session_path, params: { email_address: user.email_address, password: "NewPassword123!" } + assert_redirected_to profile_path + end + + test "an invalid token is rejected with an alert" do + token = users(:two).generate_token_for(:password_reset) + forged = token.chop + (token.end_with?("a") ? "b" : "a") + + get edit_password_path(forged) + + assert_redirected_to new_password_path + assert_equal "Password reset link is invalid or has expired.", flash[:alert] + end + + test "re-renders the reset form when password rules are violated" do + user = users(:two) + token = user.generate_token_for(:password_reset) + + patch password_path(token), params: { + password: "short", + password_confirmation: "short" + } + + assert_inertia_response + assert_inertia_component "Auth/ResetPassword" + assert_inertia_props { |props| props.dig(:errors, :password).present? } + end +end diff --git a/test/integration/profile_flow_test.rb b/test/integration/profile_flow_test.rb new file mode 100644 index 000000000..ff8e76e4f --- /dev/null +++ b/test/integration/profile_flow_test.rb @@ -0,0 +1,96 @@ +require "test_helper" + +class ProfileFlowTest < ActionDispatch::IntegrationTest + setup do + post session_path, params: { email_address: users(:two).email_address, password: "Password123!" } + end + + test "shows the own profile through the presenter" do + get profile_path + + assert_inertia_response + assert_inertia_component "Profile/Show" + assert_inertia_props { |props| props[:user][:email_address] == users(:two).email_address } + end + + test "edit page pre-fills the profile" do + get edit_profile_path + + assert_inertia_props do |props| + props.dig(:form, :full_name) == users(:two).full_name && + props.dig(:form, :email_address) == users(:two).email_address + end + end + + test "updates the profile" do + patch profile_path, params: { + full_name: "Ursula Renamed", + email_address: "renamed@iam.test", + avatar_url: users(:two).avatar_url + } + + assert_redirected_to profile_path + assert_equal "Ursula Renamed", users(:two).reload.full_name + assert_equal "renamed@iam.test", users(:two).reload.email_address + end + + test "updates the password without touching the profile fields" do + patch profile_path, params: { + full_name: users(:two).full_name, + email_address: users(:two).email_address, + avatar_url: users(:two).avatar_url, + password: "NewPassword123!", + password_confirmation: "NewPassword123!" + } + + assert users(:two).reload.authenticate("NewPassword123!") + assert_redirected_to profile_path + end + + test "update failure re-renders edit with errors keeping the form" do + patch profile_path, params: { + full_name: "", + email_address: users(:two).email_address, + avatar_url: users(:two).avatar_url + } + + assert_inertia_response + assert_inertia_component "Profile/Edit" + assert_inertia_props { |props| props.dig(:errors, :full_name).present? } + end + + test "anti-IDOR: a foreign id param is ignored" do + patch profile_path, params: { + id: users(:one).id, + full_name: "Hacked Name", + email_address: users(:two).email_address, + avatar_url: users(:two).avatar_url + } + + assert_equal "Hacked Name", users(:two).reload.full_name + assert_equal "Ivy Admin", users(:one).reload.full_name + end + + test "deletes the account, ends the session and returns to login" do + assert_difference "User.count", -1 do + delete profile_path + end + + assert_redirected_to new_session_path + refute cookies[:session_id].present? + assert_equal "Your account has been closed. Goodbye!", flash[:notice] + assert_no_difference "Session.count" do + get profile_path + end + end + + test "requires authentication for the profile area" do + delete session_path + + get profile_path + assert_redirected_to new_session_path + + get edit_profile_path + assert_redirected_to new_session_path + end +end diff --git a/test/integration/sessions_flow_test.rb b/test/integration/sessions_flow_test.rb new file mode 100644 index 000000000..e3e35d70b --- /dev/null +++ b/test/integration/sessions_flow_test.rb @@ -0,0 +1,59 @@ +require "test_helper" + +class SessionsFlowTest < ActionDispatch::IntegrationTest + test "invalid credentials keep the visitor on the login page with an alert" do + post session_path, params: { email_address: users(:two).email_address, password: "wrong-password" } + + assert_redirected_to new_session_path + assert_equal "Try another email address or password.", flash[:alert] + end + + test "signing out invalidates the session" do + log_in_as users(:two) + session = users(:two).sessions.order(:created_at).last + assert session + + delete session_path + + assert_redirected_to new_session_path + assert_nil Session.find_by(id: session.id) + end + + test "unauthenticated users are redirected to login and returned after signing in" do + get profile_path + assert_redirected_to new_session_path + + follow_redirect! + assert_inertia_component "Auth/Login" + + post session_path, params: { email_address: users(:two).email_address, password: "Password123!" } + assert_redirected_to profile_path + end + + test "unauthenticated root redirects through login" do + get root_path + assert_redirected_to new_session_path + end + + test "create is rate limited after ten attempts" do + user = users(:two) + + 10.times do + post session_path, params: { email_address: user.email_address, password: "wrong" } + assert_not flash[:alert] == "Try again later." + end + + post session_path, params: { email_address: user.email_address, password: "wrong" } + assert_redirected_to new_session_path + assert_equal "Try again later.", flash[:alert] + end + + test "return_to is consumed once after authentication" do + get profile_path + post session_path, params: { email_address: users(:two).email_address, password: "Password123!" } + assert_redirected_to profile_path + + post session_path, params: { email_address: users(:two).email_address, password: "Password123!" } + assert_redirected_to profile_path + end +end diff --git a/test/mailers/.keep b/test/mailers/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/test/models/.keep b/test/models/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/test/models/user_import_test.rb b/test/models/user_import_test.rb new file mode 100644 index 000000000..404022b39 --- /dev/null +++ b/test/models/user_import_test.rb @@ -0,0 +1,46 @@ +require "test_helper" + +class UserImportTest < ActiveSupport::TestCase + test "progress is a zero-safe 0..1 share" do + user_import = user_imports(:one) + user_import.update!(processed_rows: 1, total_rows: 2) + assert_in_delta 0.5, user_import.progress + + user_import.update!(total_rows: 0, processed_rows: 0) + assert_equal 0.0, user_import.progress + end + + test "requires a file on create" do + user_import = UserImport.new(user: users(:one), status: :pending) + + assert_not user_import.valid? + assert_includes user_import.errors[:file], "can't be blank" + end + + test "accepts supported spreadsheet formats" do + %w[users.csv users.xlsx users.xls].each do |name| + user_import = build_with_file(name) + + assert user_import.valid?, "#{name} should be accepted: #{user_import.errors[:file].inspect}" + end + end + + test "rejects unsupported file formats" do + user_import = build_with_file("users.pdf") + + assert_not user_import.valid? + assert_includes user_import.errors[:file], "must be a .csv, .xlsx or .xls file" + end + + private + + def build_with_file(filename) + UserImport.new(user: users(:one), status: :pending).tap do |record| + record.file.attach( + io: StringIO.new("Full name,Email address\n"), + filename: filename, + content_type: "text/csv" + ) + end + end +end diff --git a/test/models/user_presenter_test.rb b/test/models/user_presenter_test.rb new file mode 100644 index 000000000..89443da36 --- /dev/null +++ b/test/models/user_presenter_test.rb @@ -0,0 +1,33 @@ +require "test_helper" + +class UserPresenterTest < ActiveSupport::TestCase + test "#to_inertia presents the user contract for the frontend" do + presenter = UserPresenter.new(users(:one)) + + payload = presenter.to_inertia + + assert_equal users(:one).id, payload[:id] + assert_equal "Ivy Admin", payload[:full_name] + assert_equal "admin@iam.test", payload[:email_address] + assert_equal "admin", payload[:role] + end + + test "#to_inertia resolves the remote avatar url" do + payload = UserPresenter.new(users(:one)).to_inertia + + assert_equal "https://example.com/avatars/admin.png", payload[:avatar_source] + end + + test "#to_inertia falls back to a placeholder when no avatar is present" do + user = User.new( + email_address: "new@iam.test", + password: "Password123!", + full_name: "New User", + role: :user + ) + + payload = UserPresenter.new(user).to_inertia + + assert_equal UserPresenter::AVATAR_PLACEHOLDER, payload[:avatar_source] + end +end diff --git a/test/models/user_test.rb b/test/models/user_test.rb new file mode 100644 index 000000000..aa852be02 --- /dev/null +++ b/test/models/user_test.rb @@ -0,0 +1,36 @@ +require "test_helper" + +class UserTest < ActiveSupport::TestCase + test "normalizes the email address on save" do + user = users(:two) + + user.update!(email_address: " Mixed@IAM.TEST ") + + assert_equal "mixed@iam.test", user.reload.email_address + end + + test "avatar_source prefers an attached avatar over the URL" do + user = users(:two) + user.avatar.attach(io: StringIO.new("image"), filename: "avatar.png", content_type: "image/png") + + assert user.avatar_source.attached? + assert_equal "avatar.png", user.avatar_source.filename.to_s + end + + test "avatar_source falls back to the URL when nothing is attached" do + assert_equal users(:two).avatar_url, users(:two).avatar_source + end + + test "password reset token is invalidated when the password changes" do + user = users(:two) + token = user.generate_token_for(:password_reset) + + assert_equal user, User.find_by_token_for!(:password_reset, token) + + user.update!(password: "Changed123!", password_confirmation: "Changed123!") + + assert_raises(ActiveSupport::MessageVerifier::InvalidSignature) do + User.find_by_token_for!(:password_reset, token) + end + end +end diff --git a/test/parsers/user_imports/parser_test.rb b/test/parsers/user_imports/parser_test.rb new file mode 100644 index 000000000..ab2e06166 --- /dev/null +++ b/test/parsers/user_imports/parser_test.rb @@ -0,0 +1,79 @@ +require "test_helper" + +class UserImports::ParserTest < ActiveSupport::TestCase + test "registry selects the implementation by extension" do + assert_equal UserImports::Parser::Roo, UserImports::Parser.for("users.csv") + assert_equal UserImports::Parser::Roo, UserImports::Parser.for("users.xlsx") + assert_equal UserImports::Parser::Roo, UserImports::Parser.for("users.xls") + assert_raises(ArgumentError) { UserImports::Parser.for("users.pdf") } + end + + test "parse normalizes headers and attribute rows and skips blanks" do + with_csv(<<~CSV) do |path| + Full Name,EMAIL ADDRESS,Avatar URL + Alice Import ,alice@iam.test,https://example.com/a.png + + Bob Import,bob@iam.test, + CSV + result = UserImports::Parser.for("users.csv").new.parse(path) + + assert_equal %i[full_name email_address avatar_url], result.headers + assert_equal 2, result.total_rows + + rows = result.rows.to_a + assert_equal( + { full_name: "Alice Import", email_address: "alice@iam.test", avatar_url: "https://example.com/a.png" }, + rows[0].attributes + ) + assert_equal 2, rows[0].row_number + assert_equal( + { full_name: "Bob Import", email_address: "bob@iam.test", avatar_url: nil }, + rows[1].attributes + ) + assert_equal 4, rows[1].row_number + end + end + + test "unknown headers are ignored" do + with_csv("Full name,Email address,Spam Ding\nAda,ada@iam.test,nope\n") do |path| + result = UserImports::Parser.for("users.csv").new.parse(path) + + assert_equal %i[full_name email_address], result.headers + assert_equal({ full_name: "Ada", email_address: "ada@iam.test" }, result.rows.first.attributes) + end + end + + test "rows are lazily enumerated from the sheet" do + with_csv("Full name,Email address\nA,a@iam.test\nB,b@iam.test\n") do |path| + result = UserImports::Parser.for("users.csv").new.parse(path) + + assert_kind_of Enumerator::Lazy, result.rows + assert_equal 2, result.total_rows + end + end + + test "rejects unsupported file extensions" do + error = assert_raises(ArgumentError) { UserImports::Parser.for("users.ods") } + assert_includes error.message, "unsupported spreadsheet format" + end + + test "the base parser enforces the parse contract" do + assert_raises(NotImplementedError) { UserImports::Parser.new.parse("noop") } + end + + test "for selects the Roo strategy by extension" do + assert_equal UserImports::Parser::Roo, UserImports::Parser.for("users.csv") + assert_equal UserImports::Parser::Roo, UserImports::Parser.for("users.xlsx") + assert_equal UserImports::Parser::Roo, UserImports::Parser.for("USERS.XLS") + end + + private + + def with_csv(content) + Tempfile.create([ "users", ".csv" ]) do |file| + file.write(content) + file.flush + yield file.path + end + end +end diff --git a/test/policies/users/policy_test.rb b/test/policies/users/policy_test.rb new file mode 100644 index 000000000..4ad5f3c6d --- /dev/null +++ b/test/policies/users/policy_test.rb @@ -0,0 +1,39 @@ +require "test_helper" + +class Users::PolicyTest < ActiveSupport::TestCase + setup do + @policy = Users::Policy.new(users(:one)) + end + + test "an admin cannot delete themselves" do + refute @policy.destructible?(users(:one), users(:one)) + end + + test "an admin cannot demote themselves" do + refute @policy.demotable?(users(:one), users(:one)) + end + + test "a target which is the last admin cannot be deleted or demoted" do + refute @policy.destructible?(users(:two), users(:one)) + refute @policy.demotable?(users(:two), users(:one)) + end + + test "an admin can delete and demote a regular user" do + assert @policy.destructible?(users(:one), users(:two)) + assert @policy.demotable?(users(:one), users(:two)) + end + + test "an admin who is not the last one can be deleted and demoted" do + second_admin = User.create!( + full_name: "Bruce Backup", + email_address: "bruce@iam.test", + password: "Password123!", + role: :admin, + avatar_url: "https://example.com/avatars/bruce.png" + ) + policy = Users::Policy.new(second_admin) + + assert policy.destructible?(second_admin, users(:one)) + assert policy.demotable?(second_admin, users(:one)) + end +end diff --git a/test/queries/dashboard/totals_query_test.rb b/test/queries/dashboard/totals_query_test.rb new file mode 100644 index 000000000..6a396be29 --- /dev/null +++ b/test/queries/dashboard/totals_query_test.rb @@ -0,0 +1,29 @@ +require "test_helper" + +class Dashboard::TotalsQueryTest < ActiveSupport::TestCase + test "returns total users and counts grouped by role" do + overview = Dashboard::TotalsQuery.call(User.all) + + assert_equal User.count, overview.total_users + assert_equal User.user.count, overview.by_role["user"] + assert_equal User.admin.count, overview.by_role["admin"] + end + + test "to_inertia exposes the serialization contract" do + overview = Dashboard::TotalsQuery.call(User.all) + + assert_equal( + { total_users: User.count, by_role: User.group(:role).count }, + overview.to_inertia + ) + assert overview.frozen? + end + + test "works with a pre-scoped relation" do + scoped = User.where(role: :user) + overview = Dashboard::TotalsQuery.call(scoped) + + assert_equal User.user.count, overview.total_users + assert_equal(User.user.group(:role).count, overview.by_role) + end +end diff --git a/test/queries/users/listing_query_test.rb b/test/queries/users/listing_query_test.rb new file mode 100644 index 000000000..544dffb38 --- /dev/null +++ b/test/queries/users/listing_query_test.rb @@ -0,0 +1,60 @@ +require "test_helper" + +class Users::ListingQueryTest < ActiveSupport::TestCase + test "returns all users ordered by name then id with pagination metadata" do + listing = Users::ListingQuery.call + + assert_equal users(:one, :two).map(&:full_name).sort, listing.users.map(&:full_name) + assert_equal 2, listing.pagination[:count] + assert_equal 1, listing.pagination[:page] + assert_equal 1, listing.pagination[:pages] + assert_nil listing.pagination[:prev] + assert_nil listing.pagination[:next] + assert_equal 10, listing.pagination[:items] + end + + test "filters by full name case-insensitively" do + listing = Users::ListingQuery.call(search: "IVY") + + assert_equal [ "Ivy Admin" ], listing.users.map(&:full_name) + end + + test "filters by email address" do + listing = Users::ListingQuery.call(search: users(:two).email_address) + + assert_equal [ users(:two) ], listing.users.to_a + end + + test "escapes wildcard characters in the search term" do + listing = Users::ListingQuery.call(search: "%") + + assert_empty listing.users + end + + test "paginates once count exceeds the page size and clamps out-of-range pages" do + 12.times do |index| + User.create!( + full_name: "Bulk #{format("%02d", index + 10)}", + email_address: "bulk#{index}@iam.test", + password: "Password123!", + role: :user, + avatar_url: "https://example.com/avatars/bulk#{index}.png" + ) + end + + listing = Users::ListingQuery.call(page: 2) + + assert_equal 2, listing.pagination[:page] + assert_equal 14, listing.pagination[:count] + assert_equal 2, listing.pagination[:pages] + assert_equal 4, listing.users.length + + clamped = Users::ListingQuery.call(page: 99) + assert_equal 2, clamped.pagination[:page] + end + + test "blanks and non-numeric pages fall back to the first page" do + assert_equal 1, Users::ListingQuery.call(page: "abc").pagination[:page] + assert_equal 1, Users::ListingQuery.call(page: "").pagination[:page] + end +end diff --git a/test/services/dashboard/publisher_test.rb b/test/services/dashboard/publisher_test.rb new file mode 100644 index 000000000..7d82c9572 --- /dev/null +++ b/test/services/dashboard/publisher_test.rb @@ -0,0 +1,17 @@ +require "test_helper" + +class Dashboard::PublisherTest < ActiveSupport::TestCase + test "broadcasts the shared overview payload to the dashboard_stats stream" do + User.create!(full_name: "Carla", email_address: "carla@publisher.test", password: "Password123!", role: "admin") + + assert_broadcasts "dashboard_stats", 1 do + Dashboard::Publisher.call + end + end + + test "reuses TotalsQuery and Overview#to_inertia (same payload as the render)" do + payload = capture_broadcasts("dashboard_stats") { Dashboard::Publisher.call } + + assert_equal Dashboard::TotalsQuery.call(User.all).to_inertia, payload.first.symbolize_keys + end +end diff --git a/test/services/user_imports/processor_test.rb b/test/services/user_imports/processor_test.rb new file mode 100644 index 000000000..027cbe02b --- /dev/null +++ b/test/services/user_imports/processor_test.rb @@ -0,0 +1,128 @@ +require "test_helper" + +class UserImports::ProcessorTest < ActiveSupport::TestCase + # DIP in action: the Processor receives a fake parser class (no Roo, no + # files) and a fake broadcaster; only the default collaborators are real. + class RecordingBroadcaster + attr_reader :calls + + def initialize + @calls = [] + end + + def call(user_import) + @calls << user_import + end + end + + def build_fake_parser(result) + Class.new do + define_method(:parse) { |_file| result } + end + end + + def build_raising_parser(error_class, message) + Class.new do + define_method(:parse) { |_file| raise error_class, message } + end + end + + test "imports every valid row and finishes completed" do + result = result_for( + row(2, "Carla Nova", "carla@iam.test"), + row(3, "Danilo Nova", "danilo@iam.test") + ) + broadcaster = RecordingBroadcaster.new + user_import = build_import + + UserImports::Processor.new(parser_class: build_fake_parser(result), broadcaster: broadcaster).call(user_import) + + assert user_import.completed? + assert_equal 2, user_import.total_rows + assert_equal 2, user_import.processed_rows + assert_equal 0, user_import.failed_rows + assert_not_nil user_import.finished_at + assert_equal 2, broadcaster.calls.size + assert_equal 2, User.where(email_address: %w[carla@iam.test danilo@iam.test]).count + end + + test "counts invalid rows and ends failed with an aggregated summary" do + result = result_for( + row(2, "Carla Nova", "carla@iam.test"), + row(3, "Só Nome", ""), + row(4, "", "email@sem.nome") + ) + user_import = build_import + + UserImports::Processor.new(parser_class: build_fake_parser(result)).call(user_import) + + assert user_import.failed? + assert_equal 1, user_import.processed_rows + assert_equal 2, user_import.failed_rows + assert_match(/2 error\(s\) in 2 row\(s\): 3, 4\./, user_import.error_message) + assert_equal 1, User.where(email_address: "carla@iam.test").count + end + + test "duplicate emails are skipped as a no-op for reprocessing" do + result = result_for( + row(2, "Carla Nova", "reprocess@iam.test"), + row(3, "Carla Nova Outra", "reprocess@iam.test") + ) + user_import = build_import + + UserImports::Processor.new(parser_class: build_fake_parser(result)).call(user_import) + + assert user_import.completed? + assert_equal 2, user_import.processed_rows + assert_equal 0, user_import.failed_rows + assert_equal 1, User.where(email_address: "reprocess@iam.test").count + end + + test "fatal parser errors end failed without re-raising" do + user_import = build_import + + UserImports::Processor.new(parser_class: build_raising_parser(IOError, "corrupt file")).call(user_import) + + assert user_import.failed? + assert_includes user_import.error_message, "Could not process the file" + assert_not_nil user_import.finished_at + end + + test "broadcasts once per batch and refreshes the dashboard at the end" do + rows = 60.times.map { |index| row(index + 2, "Batch #{index}", "batch#{index}@iam.test") } + broadcaster = RecordingBroadcaster.new + dashboard_updates = [] + dashboard_publisher = Class.new { define_method(:call) { dashboard_updates << :called } }.new + user_import = build_import + + UserImports::Processor.new( + parser_class: build_fake_parser(result_for(*rows)), + broadcaster: broadcaster, + dashboard_publisher: dashboard_publisher + ).call(user_import) + + assert_equal 4, broadcaster.calls.size + assert_equal 60, user_import.processed_rows + assert user_import.completed? + assert_equal 1, dashboard_updates.size + end + + private + + def row(number, name, email) + UserImports::Row.new( + row_number: number, + attributes: { full_name: name, email_address: email } + ) + end + + def result_for(*rows) + UserImports::Result.new(headers: %i[full_name email_address], rows: rows, total_rows: rows.size) + end + + def build_import + UserImport.create!(user: users(:one), status: :pending) do |record| + record.file.attach(io: StringIO.new("Full name,Email address\n"), filename: "users.csv", content_type: "text/csv") + end + end +end diff --git a/test/services/users/account_closer_test.rb b/test/services/users/account_closer_test.rb new file mode 100644 index 000000000..d9dd25da1 --- /dev/null +++ b/test/services/users/account_closer_test.rb @@ -0,0 +1,34 @@ +require "test_helper" + +class Users::AccountCloserTest < ActiveSupport::TestCase + test "destroys the account" do + user = users(:two) + + assert_difference "User.count", -1 do + Users::AccountCloser.call(user) + end + assert_not User.exists?(user.id) + end + + test "keeps the user import history by nullifying the reference" do + user = users(:two) + import = user.user_imports.create!(status: :completed) do |record| + record.file.attach(io: StringIO.new("x\n"), filename: "users.csv", content_type: "text/csv") + end + + Users::AccountCloser.call(user) + + import.reload + assert import.persisted? + assert_nil import.user_id + end + + test "destroys the user sessions via dependent destroy" do + user = users(:two) + user.sessions.create!(ip_address: "10.0.0.1", user_agent: "test") + + assert_difference "Session.count", -1 do + Users::AccountCloser.call(user) + end + end +end diff --git a/test/system/.keep b/test/system/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 000000000..83c2b3d61 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,64 @@ +ENV["RAILS_ENV"] ||= "test" + +# Coverage merges need a single process (concurrent workers race the +# .resultset.json file). Default to one worker so the 80% gate is reliable; +# opt into speed with PARALLEL_WORKERS=2..N (the gate is then skipped). +ENV["PARALLEL_WORKERS"] ||= "1" + +require "simplecov" +SimpleCov.start do + track_files "app/**/*.rb" + add_filter "/test/" + add_filter "/vendor/" + + group "Controllers", "app/controllers" + group "Channels", "app/channels" + group "Forms", "app/forms" + group "Helpers", "app/helpers" + group "Jobs", "app/jobs" + group "Mailers", "app/mailers" + group "Models", "app/models" + group "Parsers", "app/parsers" + group "Policies", "app/policies" + group "Presenters", "app/presenters" + group "Queries", "app/queries" + group "Services", "app/services" + group "Value Objects", "app/value_objects" +end +SimpleCov.minimum_coverage 80 if ENV["PARALLEL_WORKERS"].to_i == 1 +require_relative "../config/environment" +require "rails/test_help" +require "inertia_rails/minitest" +require "action_cable/test_helper" + +module SignInHelper + def log_in_as(user, password: "Password123!") + post session_path, params: { + email_address: user.email_address, + password: + } + follow_redirect! if response.redirect? + end +end + +module ActiveSupport + class TestCase + include ActionCable::TestHelper + + # Run tests in parallel with specified workers + parallelize(workers: :number_of_processors) + + # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. + fixtures :all + + # Add more helper methods to be used by all tests here... + end +end + +class ActionDispatch::IntegrationTest + include SignInHelper + + # Rate limiter counters share Rails.cache across requests; reset them so a + # test never trips a limit raised by an earlier test in the same process. + teardown { Rails.cache.clear } +end diff --git a/tmp/.keep b/tmp/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/tmp/pids/.keep b/tmp/pids/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/tmp/storage/.keep b/tmp/storage/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 000000000..892291b50 --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + /* Aliases */ + "paths": { + "@/*": ["./app/frontend/*"], + "~/*": ["./app/frontend/*"] + } + }, + "include": ["app/frontend"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..ea9d0cd82 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.node.json" + } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 000000000..3afdd6e38 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "noEmit": true + }, + "include": ["vite.config.ts"] +} diff --git a/vendor/.keep b/vendor/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 000000000..91d4f097a --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,15 @@ +import react from '@vitejs/plugin-react' +import inertia from '@inertiajs/vite' +import tailwindcss from '@tailwindcss/vite' +import { fileURLToPath, URL } from 'node:url' +import { defineConfig } from 'vite' +import RubyPlugin from 'vite-plugin-ruby' + +export default defineConfig({ + plugins: [tailwindcss(), RubyPlugin(), inertia(), react()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./app/frontend', import.meta.url)), + }, + }, +}) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 000000000..bdcb646f9 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,18 @@ +import { configDefaults, defineConfig } from 'vitest/config' + +// Frontend component tests mirror the app's bundler aliases (@/ -> app/frontend). +export default defineConfig({ + test: { + environment: 'jsdom', + globals: true, + include: ['app/frontend/**/*.test.{ts,tsx}'], + exclude: [...configDefaults.exclude, 'node_modules', 'vendor'], + setupFiles: ['./app/frontend/test/setup.ts'], + }, + resolve: { + alias: { + '@': new URL('./app/frontend', import.meta.url).pathname, + '~': new URL('./app/frontend', import.meta.url).pathname, + }, + }, +}) From 01ecdefc3191dde2d8ea13809c2802fcc034572a Mon Sep 17 00:00:00 2001 From: "Carlos H.F.S" Date: Tue, 8 Sep 2026 16:28:51 -0300 Subject: [PATCH 2/2] chore: Last configuration. --- .gitignore | 8 - README_SETUP.md | 569 ++++++++++++++++++++++++++++++++++++++++++++++++ TECH_SPEC.md | 370 +++++++++++++++++++++++++++++++ 3 files changed, 939 insertions(+), 8 deletions(-) create mode 100644 README_SETUP.md create mode 100644 TECH_SPEC.md diff --git a/.gitignore b/.gitignore index c5f0c89d9..405f4d914 100644 --- a/.gitignore +++ b/.gitignore @@ -47,11 +47,3 @@ node_modules # SimpleCov coverage reports. /.resultset.json -# Local planning docs — kept out of version control. -/plan/ -/plan2/ -/IAMLikeRubyApp.md -/models-plan-001.md -/tech_levantamento.md -/cmd-sequence.txt - diff --git a/README_SETUP.md b/README_SETUP.md new file mode 100644 index 000000000..3c70ed9de --- /dev/null +++ b/README_SETUP.md @@ -0,0 +1,569 @@ +# Setup & Deployment Guide — mini Rails 8 IAM + InertiaJS + +Complete guide to run this application **locally** and **online** (Fly.io, Heroku, AWS, and +Azure Cloud). The whole guide is in English. + +The application is a Ruby 4.0.6 / Rails 8.0.5 monolith with a **React SPA integrated via +Inertia.js** (Vite), SQLite databases in **WAL mode**, and the **Solid trio** (Cache, Queue, +Cable) — which means **no Redis** is required anywhere. + +--- + +## Table of contents + +- [1. Stack snapshot](#1-stack-snapshot) +- [2. Environment variables reference](#2-environment-variables-reference) +- [3. Local development](#3-local-development) + - [3.1 Prerequisites](#31-prerequisites) + - [3.2 First-time setup](#32-first-time-setup) + - [3.3 Running the dev server](#33-running-the-dev-server) + - [3.4 Seeds / demo credentials](#34-seeds--demo-credentials) + - [3.5 Tests, coverage and linters](#35-tests-coverage-and-linters) + - [3.6 Docker (production image)](#36-docker-production-image) + - [3.7 Troubleshooting](#37-troubleshooting) +- [4. General production notes](#4-general-production-notes) +- [5. Deploying online](#5-deploying-online) + - [5.1 Kamal 2 (project's first-class deploy)](#51-kamal-2-projects-first-class-deploy) + - [5.2 Fly.io](#52-flyio) + - [5.3 Heroku](#53-heroku) + - [5.4 AWS](#54-aws) + - [5.5 Azure Cloud](#55-azure-cloud) +- [6. Postgres note: when to scale horizontally](#6-postgres-note-when-to-scale-horizontally) +- [7. AI usage disclosure](#7-ai-usage-disclosure) + +--- + +## 1. Stack snapshot + +| Component | Version / tech | Notes | +|---|---|---| +| Ruby | 4.0.6 | `.ruby-version` (`ruby-4.0.6`); RVM/rbenv on the host, `ruby:4.0.6-slim` in Docker | +| Rails | 8.0.5.1 | `Gemfile:5` | +| Frontend | React 19 + Tailwind CSS 4 + MobX | Built by Vite via `vite_rails`; entrypoints in `app/frontend/entrypoints` | +| Database | SQLite 3 (WAL mode) | One primary DB + cache/queue/cable DBs in `storage/` | +| Realtime / jobs | Solid Cable / Solid Queue / Solid Cache | No Redis | +| Auth | Rails 8 native auth (`bin/rails generate authentication`) + bcrypt | Sessions in cookies + CSRF | +| Deploy | Kamal 2 + Thruster | Multi-stage Dockerfile (`Dockerfile`) | +| Process manager (dev) | Foreman / Overmind / Hivemind | Used by `bin/dev` with `Procfile.dev` | + +Dev process layout (`Procfile.dev`): + +```procfile +vite: bin/vite dev # Vite dev server on port 3036 +web: bin/rails s # Puma on port 3000 +jobs: bin/jobs # Solid Queue worker +``` + +--- + +## 2. Environment variables reference + +All variables are documented in `.env.example`. In **development/test** the file `.env` is +loaded by `dotenv-rails`. In **production** the same values are injected by the cloud +provider / orchestrator — there is no `.env` inside the container. + +| Variable | Required | Purpose | +|---|---|---| +| `APP_HOST` | Yes (prod) | Public host; used for absolute URLs and Rails host authorization (`production.rb:99`) | +| `APP_PROTOCOL` | No | `http` or `https` (default `https` in prod) | +| `APP_PORT` | No | Optional port for mailer URLs (e.g., `3000`) | +| `MAIL_FROM` | Yes (prod) | Mailer sender; production **raises** if blank (`production.rb:68`) | +| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_AUTH`, `SMTP_STARTTLS_AUTO` | No | Optional SMTP for password-reset emails; delivery disabled without `SMTP_HOST` | +| `SEED_ADMIN_EMAIL`, `SEED_ADMIN_PASSWORD` | No | Admin credentials for seeds (dev/test default `admin@iam.local` / `Password123!`) | +| `SEED_ALLOW_IN_PRODUCTION` | No | `true` only if you really want to seed production (default `false`, see `db/seeds.rb`) | +| `RAILS_MASTER_KEY` | Yes (prod) | Decrypts `config/credentials.yml.enc`; matches `config/master.key` locally | +| `RAILS_MAX_THREADS` | No | Puma/Active Record pool threads (default 3) | +| `RAILS_LOG_LEVEL` | No | Log level for production (default `info`) | +| `RAILS_SERVE_STATIC_FILES` | No | Set `true` when there is no CDN in front of the container | +| `SOLID_QUEUE_IN_PUMA` | No | `true` runs the Solid Queue supervisor inside Puma (single-server deploy) | +| `JOB_CONCURRENCY` | No | Solid Queue worker processes (default 1, see `config/queue.yml`) | +| `WEB_CONCURRENCY` | No | Puma worker processes (default 1) | +| `VITE_RUBY_SKIP_ASSETS_PRECOMPILE_EXTENSION` | No | Used by the Docker build to control asset precompile | + +Kamal-only variables (read from `config/deploy.yml`, sourced from `.env` on the deploy host): + +| Variable | Purpose | +|---|---| +| `DOCKER_IMAGE` | Container image name, e.g. `your-user/mini_rails8_iam_with_inertia_js` | +| `APP_SERVER_HOST` | IP/DNS of the server where Kamal deploys | +| `REGISTRY_USERNAME` | Container registry username | +| `KAMAL_REGISTRY_PASSWORD` | Container registry token/password (from `.kamal/secrets`) | + +--- + +## 3. Local development + +### 3.1 Prerequisites + +Install on your machine: + +| Tool | Version | Why | +|---|---|---| +| Ruby | 4.0.6 | Matches `.ruby-version` | +| Bundler | any recent | Ruby gems | +| Node.js + npm | 22+ | Vite build/tooling (the Docker build uses `node:22-slim`) | +| SQLite 3 | 3.x | System library for the `sqlite3` gem | +| Foreman / Overmind / Hivemind | optional | `bin/dev` picks the first installed; it auto-installs Foreman otherwise | +| Docker | optional | Only needed for the production image / containerized runs | + +Ruby can be installed with RVM or rbenv: + +```bash +# RVM +rvm install 4.0.6 +rvm use 4.0.6 + +# rbenv +rbenv install 4.0.6 +rbenv local 4.0.6 +``` + +### 3.2 First-time setup + +From the repository root: + +```bash +# 1. Ruby gems +bundle install + +# 2. Frontend packages +npm install + +# 3. Environment file (adjust the values!) +cp .env.example .env + +# 4. Create, migrate and seed the databases +bin/rails db:prepare +``` + +> `bin/setup` runs all of the above automatically (bundle + npm + `db:prepare` + cleanup) +> and then starts the dev server (`bin/dev`). + +Seeding is idempotent in development (see `db/seeds.rb`) and safe to re-run: + +```bash +bin/rails db:seed +``` + +### 3.3 Running the dev server + +```bash +bin/dev +``` + +`bin/dev` starts (in order of preference) **Overmind**, **Hivemind**, or **Foreman**, and +launches the three `Procfile.dev` processes: + +| Service | URL/port | +|---|---| +| Web (Puma) | http://localhost:3000 | +| Vite dev server | http://localhost:3036/vite-dev/ | +| Solid Queue worker | in-process (no port) | + +Alternatively, run each process in its own terminal: + +```bash +bin/vite dev +bin/rails s +bin/jobs +``` + +The app is a single Inertia SPA: Rails renders the page shell and Vite serves the React +assets; hot reload is active. Logs stream to the terminal prefixed by process name. + +### 3.4 Seeds / demo credentials + +After seeding, log in at http://localhost:3000 with: + +| Role | Email | Password | +|---|---|---| +| Admin | `admin@iam.local` | `Password123!` | +| Regular user | `user@iam.local` | `Password123!` | + +Admin is redirected to the **User Admin Dashboard** after login; a regular user goes to the +**Profile** page. + +### 3.5 Tests, coverage and linters + +Backend (Minitest; 138 tests, coverage gate 80% via SimpleCov): + +```bash +bin/rails test +PARALLEL_WORKERS=4 bin/rails test # faster; skips the exact 80% merge gate +bin/rails test:system # if system tests are added +``` + +Frontend (Vitest + Testing Library): + +```bash +npm run test:frontend +``` + +Static analysis / style / types: + +```bash +bundle exec rubocop # Ruby lint (omakase) +bin/brakeman # security static analysis +npm run lint # ESLint +npm run check # TypeScript (tsc) +npm run format:check # Prettier +``` + +CI reference: `.github/workflows/codeql-analysis.yml` (GitHub Actions). + +### 3.6 Docker (production image) + +The `Dockerfile` is multi-stage (base → build-assets → build → final), Thruster/Kamal-ready, +non-root, and runs the Vite build in a separate Node stage so the runtime image keeps no +Node tooling: + +```bash +docker build -t mini_rails8_iam_with_inertia_js . + +# Run it (needs the master key to decrypt credentials) +docker run -d -p 80:80 \ + -e RAILS_MASTER_KEY= \ + --name mini_rails8_iam_with_inertia_js \ + mini_rails8_iam_with_inertia_js +``` + +The entrypoint runs `./bin/rails db:prepare` automatically on `./bin/rails server` start +(`bin/docker-entrypoint`), so a fresh volume gets its schema on boot. + +### 3.7 Troubleshooting + +| Symptom | Fix | +|---|---| +| `Error: Port 3036 is already in use` | An orphaned Vite server is holding the port. Kill it: `pkill -f "node_modules/.bin/vite"` (check with `ss -ltnp \| grep 3036`). | +| `bin/dev` says nothing / falls to `gem install foreman` | Overmind/Hivemind/Foreman are not installed; let it install Foreman or `gem install foreman` first. | +| Password reset emails never arrive | SMTP is not configured. Set `SMTP_HOST` (+ user/password). See `production.rb:72-86`. | +| `MAIL_FROM is required` at boot in prod | Production requires `MAIL_FROM` (fail-fast). Add it to your platform env vars. | +| Rate limit errors during tests | Expected safeguards; tests clear `Rails.cache` between examples (see `test/test_helper.rb`). | + +--- + +## 4. General production notes + +Whatever platform you choose, the same principles apply: + +1. **Single writer.** SQLite supports one writer — keep a **single app instance running the + web + jobs** (`SOLID_QUEUE_IN_PUMA=true`), exactly like `config/deploy.yml` does. Scale + horizontally only after moving the DB layer to Postgres (see [section 6](#6-postgres-note-when-to-scale-horizontally)). +2. **Persistent storage.** SQLite files and Active Storage uploads live under `/rails/storage` + (`storage/production*.sqlite3`). Every platform below must persist that directory. + Local Active Storage service is already the production default (`active_storage.service = :local`). +3. **Required env in production** (the app fails fast without them): + `RAILS_MASTER_KEY`, `APP_HOST`, `MAIL_FROM`. Set `APP_PROTOCOL=https` when SSL terminates + upstream; `config.force_ssl = true` is on for production by default. +4. **Health check.** The stock endpoint `/up` is used by platforms and load balancers + (status 200 when the app is healthy). It is excluded from host authorization. +5. **Secrets.** Never commit `config/master.key`. Get the key locally with + `bin/rails credentials:show --help` or keep the generated `config/master.key` as the + source for `RAILS_MASTER_KEY`. +6. **Port.** The container listens on **80** (`EXPOSE 80`, Thruster). Override `PORT` only + if the platform requires it; Puma reads `PORT` (`config/puma.rb:31`). +7. **SMTP** is optional but recommended — without it, password-reset emails are not delivered. + +Env checklist per platform (fill all `Yes` entries): + +| Variable | Local | Fly.io | Heroku | AWS | Azure | +|---|---|---|---|---|---| +| `RAILS_MASTER_KEY` | Yes | Yes | Yes | Yes | Yes | +| `APP_HOST` | Yes | Yes | Yes | Yes | Yes | +| `MAIL_FROM` | Yes | Yes | Yes | Yes | Yes | +| `SMTP_*` | optional | optional | optional | optional | optional | +| `SOLID_QUEUE_IN_PUMA=true` | n/a | optional | optional | optional | optional | +| `RAILS_SERVE_STATIC_FILES=true` | n/a | optional | optional | optional | optional | + +--- + +## 5. Deploying online + +### 5.1 Kamal 2 (project's first-class deploy) + +The repository ships a complete `config/deploy.yml` for Kamal 2 (deploying the Docker image +to any Linux server over SSH with Let's Encrypt SSL). This path works on **any VM** — +including AWS EC2, Azure VM, or a DigitalOcean/misc droplet. + +On your deploy host (local machine or CI), with the image already pushed: + +```bash +# 1. Fill the Kamal vars (they are read by config/deploy.yml via ERB) +cat >> .env <<'EOF' +DOCKER_IMAGE=/mini_rails8_iam_with_inertia_js +APP_SERVER_HOST= +APP_HOST= +REGISTRY_USERNAME= +EOF +# 2. Provide the registry password for the secret holder +mkdir -p .kamal && touch .kamal/secrets +# add: KAMAL_REGISTRY_PASSWORD= (never commit .kamal) + +# 3. Provision the server (installs Docker, creates the volumes, bootstraps Traefik) +source .env && bin/kamal setup + +# 4. Build + push + deploy (env is already coalesced) +source .env && bin/kamal deploy +``` + +Useful aliases: + +```bash +bin/kamal logs # tail app logs +bin/kamal console # rails console on the server +bin/kamal app exec --interactive --reuse "bin/rails dbconsole" +``` + +Kamal mounts the persistent volume `mini_rails8_iam_with_inertia_js_storage:/rails/storage` +(`deploy.yml:69`) and injects `RAILS_MASTER_KEY` as a secret. `APP_HOST` also configures the +Traefik SSL proxy host. + +### 5.2 Fly.io + +Fly.io runs the app as a **single Machine** with a **persistent volume** — the simplest fit +for SQLite. + +```bash +# 1. Authenticate +fly auth login + +# 2. Launch (answers questions, creates fly.toml / Dockerfile) +fly launch --no-deploy + +# 3. Create and attach a persistent volume for /rails/storage +fly volumes create mini_rails8_iam_with_inertia_js_storage --size 20 --region +``` + +Add to `fly.toml`: + +```toml +app = "" +primary_region = "" + +[build] + dockerfile = "Dockerfile" + +[mounts] + source = "mini_rails8_iam_with_inertia_js_storage" + destination = "/rails/storage" + +[env] + APP_HOST = "https://.fly.dev" + APP_PROTOCOL = "https" + MAIL_FROM = "no-reply@.fly.dev" + SOLID_QUEUE_IN_PUMA = "true" + +[http_service] + internal_port = 80 + force_https = true + auto_stop_machines = true + + [[http_service.health_checks]] + path = "/up" + interval = "30s" + timeout = "5s" + grace_period = "10s" +``` + +Push secrets and deploy: + +```bash +fly secrets set RAILS_MASTER_KEY= +fly deploy +``` + +Because the app is a **single-machine** deployment, keep the machine count at 1 (Fly's +default). If you add machines, move to Postgres first (`fly postgres create` + attach, see +[section 6](#6-postgres-note-when-to-scale-horizontally)). + +### 5.3 Heroku + +Heroku's filesystem is **ephemeral** — a SQLite database written to a dyno dies on every +deploy/restart. Two options: + +**Option A — quick SQLite demo (data NOT durable).** Fine for evaluation only. + +**Option B — durable (recommended).** Attach the managed **Postgres** add-on and switch the +production database, then deploy the container. This keeps data durable and lets you scale +multiple dynos. + +```bash +# 1. Create the app and authenticate to Heroku Container Registry +heroku create +heroku container:login + +# 2. Add Postgres +heroku addons:create heroku-postgresql:mini --app +heroku config:get DATABASE_URL --app + +# 3. Databases: switch production to Postgres (edit config/database.yml) +# production.primary.adapter = postgresql, url = ENV["DATABASE_URL"] +# (Optionally keep cache/queue/cable on sqlite or move them too — see section 6) + +# 4. Set the env vars (RAILS_MASTER_KEY from your local config/master.key) +heroku config:set RAILS_MASTER_KEY= APP_HOST=.herokuapp.com \ + APP_PROTOCOL=https MAIL_FROM=no-reply@.herokuapp.com \ + SOLID_QUEUE_IN_PUMA=true RAILS_SERVE_STATIC_FILES=true + +# 5. Build and push the Docker image +heroku container:push web --app + +# 6. Run migrations, then release +heroku run bin/rails db:prepare --app +heroku container:release web --app +``` + +`.herokuapp.com` gives HTTPS/SSL out of the box. + +### 5.4 AWS + +The cleanest options are **(1) EC2 + Kamal** (exactly what `config/deploy.yml` targets) or +**(2) Amazon ECS** with a single task + EFS. Both give durable disks. + +**Option A — EC2 + Kamal 2 (recommended for this repo):** + +1. Launch an EC2 instance (Ubuntu 24.04 or Amazon Linux 2023), size `t3.small`+. + - Storage: default EBS root of 20 GB is enough (Docker named volumes live on it). + - Security group: allow **22** (SSH), **80** and **443** (HTTPS via Thruster/Traefik). +2. SSH in and install Docker: + + ```bash + sudo apt-get update && sudo apt-get install -y ca-certificates curl + curl -fsSL https://get.docker.com | sudo sh + sudo usermod -aG docker $USER + ``` + +3. From your local machine, fill `.env` and deploy: + + ```bash + cat >> .env <<'EOF' + DOCKER_IMAGE=/mini_rails8_iam_with_inertia_js + APP_SERVER_HOST= + APP_HOST= + REGISTRY_USERNAME= + EOF + mkdir -p .kamal && touch .kamal/secrets # add KAMAL_REGISTRY_PASSWORD= + + source .env && bin/kamal setup + source .env && bin/kamal deploy + ``` + +4. Point a DNS A record (`your-domain.com`) at the EC2 public IP and open a Let's Encrypt + path for the domain — Kamal/Traefik provisions the certificate automatically on deploy. +5. The EBS-backed named volume `mini_rails8_iam_with_inertia_js_storage` persists + SQLite + uploads across deploys. Back it up (EBS snapshots) or add EFS for cross-zone + durability. + +**Option B — Amazon ECS (Fargate single task + EFS):** + +1. Create an EFS file system and mount target (same VPC/subnet as the task). +2. Register a task from your image (ECR), CPU 0.5 vCPU / 1 GB, port mapping **80**. +3. Add a `mountPoints`/`volumes` pair mounting the EFS at `/rails/storage` + (EFS mount path like `/rails-storage`), and set the container's `User` to 1000. +4. Set env vars `RAILS_MASTER_KEY`, `APP_HOST`, `MAIL_FROM`, `SOLID_QUEUE_IN_PUMA=true`. +5. Health check: `/up` on port 80. Use an Application Load Balancer for HTTPS/SSL. + +> Remember: SQLite = one writer. On ECS keep `Desired count = 1`, or move the primary DB to +> RDS Postgres to scale. + +### 5.5 Azure Cloud + +Azure options: **App Service** (managed PaaS), **Container Apps** (with an Azure Files share), +or a **Linux VM + Kamal** (identical to the AWS EC2 path in 5.4). + +**Option A — Azure App Service (Docker container):** + +App Service persistent storage is mounted at `/home`. Symlink `/rails/storage → /home/storage`. + +```bash +az login + +az group create --name --location brazilsouth +az acr create --name --resource-group --sku Basic --admin-enabled true # optional + +# Build & push (or push the Kamal-built image) then create the plan + web app +az appservice plan create --name --resource-group --is-linux --sku B1 +az webapp create \ + --resource-group --plan --name \ + --deployment-container-image-name .azurecr.io/mini_rails8_iam_with_inertia_js:latest \ + --startup-file "sh -c 'mkdir -p /home/storage && ln -sfn /home/storage /rails/storage && exec ./bin/thrust ./bin/rails server'" +``` + +Enable persistent `/home` storage and set config/env: + +```bash +az webapp config appsettings set \ + --resource-group --name \ + --settings \ + WEBSITES_ENABLE_APP_SERVICE_STORAGE=1 \ + RAILS_MASTER_KEY= \ + APP_HOST=https://.azurewebsites.net \ + APP_PROTOCOL=https \ + MAIL_FROM=no-reply@.azurewebsites.net \ + SOLID_QUEUE_IN_PUMA=true RAILS_SERVE_STATIC_FILES=true +``` + +`.azurewebsites.net` provides HTTPS automatically; health check path `/up` can be set +in the portal under "Health checks". + +**Option B — Azure Container Apps (Azure Files volume):** + +```bash +az storage account create --name --resource-group --sku Standard_LRS +az storage share create --account-name --name --quota 20 + +az containerapp env create --name --resource-group --location brazilsouth +az containerapp create \ + --resource-group --name --environment \ + --image /mini_rails8_iam_with_inertia_js:latest \ + --target-port 80 \ + --volume storage-name= mount-path=/rails/storage \ + --secrets RAILS_MASTER_KEY= \ + --env-vars APP_HOST=https://..azurecontainerapps.io \ + APP_PROTOCOL=https MAIL_FROM=no-reply@..azurecontainerapps.io \ + SOLID_QUEUE_IN_PUMA=true RAILS_SERVE_STATIC_FILES=true \ + --scale --min-replicas 1 --max-replicas 1 +``` + +Keep min/max replicas at **1** for SQLite (single writer). Set the probe path to `/up`. + +**Option C — Azure Linux VM + Kamal:** follow the AWS EC2 + Kamal instructions (section 5.4, +Option A) — the `config/deploy.yml` workflow is identical on any Linux box. + +--- + +## 6. Postgres note: when to scale horizontally + +SQLite comfortably serves this app in production on a **single persistent instance**. +To run **multiple web replicas** (HA, rolling zero-downtime) you must move the write path to a +network database — the spec explicitly allows PostgreSQL/MySQL. + +Migration sketch: + +```yaml +# config/database.yml (production) +production: + primary: + adapter: postgresql + url: <%= ENV["DATABASE_URL"] %> + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + # keep cache/queue/cable on SQLite only if they stay single-node: + cache: { <<: *default, database: storage/production_cache.sqlite3, migrations_paths: db/cache_migrate } + queue: { <<: *default, database: storage/production_queue.sqlite3, migrations_paths: db/queue_migrate } + cable: { <<: *default, database: storage/production_cable.sqlite3, migrations_paths: db/cable_migrate } +``` + +- **Fly.io:** `fly postgres create` + `fly secrets set DATABASE_URL=`. +- **Heroku:** `heroku addons:create heroku-postgresql:mini` (DATABASE_URL is auto-injected). +- **AWS:** RDS PostgreSQL, pass the URL via env / Kamal secret. +- **Azure:** Azure Database for PostgreSQL Flexible Server. + +Active Storage uploads should also move to object storage in a multi-node setup +(AWS S3 / GCS / Azure Storage — templates are already commented out in `config/storage.yml`). + +--- + +## 7. AI usage disclosure + +This project was developed with the assistance of the AI coding tool **OpenCode**, using the +**Big Pickle** model for code generation, refactoring, testing and documentation. \ No newline at end of file diff --git a/TECH_SPEC.md b/TECH_SPEC.md new file mode 100644 index 000000000..e835f8567 --- /dev/null +++ b/TECH_SPEC.md @@ -0,0 +1,370 @@ +# Technical Survey — mini Rails 8 IAM + InertiaJS + +Living reference document on the technologies and patterns used in this project, +with the rationale behind each choice and examples pointing to file + line. Cross-cutting rules: strict SOLID, +strict DRY, minimal GoF, Clean Code, and the English language throughout **all** the code +(including code docs/comments); + +--- + +## 1. Base stack + +| Technology | What it does | Why it was used | Proof in code | +|---|---|---|---| +| Ruby 4.0.6 | Runtime | Stack required by the specification; RVM on the host, `ruby:4.0.6-slim` in the container | `.ruby-version`; `Dockerfile:12` (`FROM docker.io/library/ruby:4.0.6-slim AS base`) | +| Rails 8.0.5.1 | Web framework | Required stack; ships native auth, Solid trio, rate limit, tokens, Thruster/Kamal out of the box | `Gemfile:5` (`gem "rails", "~> 8.0.5", ">= 8.0.5.1"`) | +| SQLite 3 (WAL mode) | Database | SQLite is the permitted default; WAL production-ready; `busy_timeout` for concurrent writes | `config/initializers/sqlite_wal.rb:25` (`raw_connection.execute("PRAGMA journal_mode=WAL")`); `config/database.yml:10` (`timeout: 5000`) | +| Docker multi-stage + Thruster + Kamal | Packaging/deploy | Spec requires a multi-stage Dockerfile compatible with Thruster/Kamal; final image without Node/build | `Dockerfile:12,31,47,73` (base → build-assets → build → final); `Dockerfile:90` (`CMD ./bin/thrust ./bin/rails server`) | +| propshaft | Rails asset pipeline | Added for template parity (task `assets:precompile`); the real assets come from Vite | `Gemfile:5`; `Dockerfile:67` (see §6.1) | +| bcrypt | Password hashing | Base of `has_secure_password` (native Rails auth) | `app/models/user.rb:2` | + +--- + +## 2. Backend — libraries + +| Library | What it does | Why it was used | Proof in code | +|---|---|---|---| +| `inertia_rails` | Inertia server-side: renders pages + shares props | Monolithic SPA required (InertiaJS), without a separate JSON API; cache-less pages | `Gemfile:67`; controllers (e.g.: `app/controllers/admin/users_controller.rb:31`) | +| `vite_rails` | Integrates Vite as the app asset pipeline | Spec allows Inertia via Vite; `npm run build` → fingerprinted `public/vite` | `Gemfile:69`; `vite.config.ts:9`; `config/vite.json:3` | +| `roo` + `roo-xls` | Spreadsheet parsing (`.csv`/`.xlsx`/`.xls`) | Asynchronous user import required by the spec | `Gemfile:72,77`; `app/parsers/user_imports/parser/roo.rb:32` | +| `pagy` | Pagination | Community standard; isolated **inside** the Query Object (DIP) | `Gemfile:75`; `app/queries/users/listing_query.rb:22` | +| `solid_cache/queue/cable` | Cache, job queue and realtime on SQLite (no Redis) | Explicit "No Redis" requirement; runs in `storage/` with a volume | `Gemfile:20-22`; `config/database.yml:29-40`; `app/services/dashboard/publisher.rb:16` | +| `simplecov` | Coverage measurement with an 80% gate | Defines the suite's "safety net" | `Gemfile:79`; `test/test_helper.rb:9-28` | +| `brakeman` / `rubocop-rails-omakase` | Security static analysis / lint | Linter rigor (spec) + security | `Gemfile:41,44`; run in CI | + +--- + +## 3. Frontend — libraries + +| Library | What it does | Why it was used | Proof in code | +|---|---|---|---| +| React 19 | UI | Pillar of "React integrated via Inertia.js" | `package.json` (`react`, `react-dom`) | +| `@inertiajs/react` + `@inertiajs/vite` | Inertia client + Vite plugin | SPA without redirects; navigation via props | `package.json`; `vite.config.ts:9`; `app/frontend/entrypoints/inertia.tsx:9` | +| Tailwind CSS 4 | Utility-first CSS with `@theme` tokens | Modern CSS framework required; black/white theme via tokens | `app/frontend/styles/base.css:1,6-31` | +| MobX + `mobx-react-lite` | Reactive shared state | Shared/realtime state in stores; mandated by the owner as required | `package.json`; `app/frontend/stores/*`; `app/frontend/lib/cable.ts:26-34` | +| `clsx` + `tailwind-merge` | Tailwind-aware class merging | **Internal** primitives of the `lib/styled` wrapper; never exported | `package.json`; `app/frontend/lib/styled/cn.ts:7` | +| `@rails/actioncable` | Channel client (Action Cable protocol) | Frontend consumes backend Solid Cable channels via its own `useChannel` wrapper | `app/frontend/lib/cable.ts:6,26-34` | +| Vitest + Testing Library | Component tests (pure TS) | Testability of the `lib/styled` wrapper and components | `app/frontend/atoms/Button/Button.test.tsx:25-29` | + +--- + +## 4. Native Rails 8 features leveraged + +| Feature | Usage | Proof in code | +|---|---|---| +| Authentication generator (`bin/rails generate authentication`) | Cookie-based session auth + CSRF, customized for roles | `config/routes.rb:3-4` (`resource :session`, `passwords`); `app/controllers/concerns/authentication.rb:16-52` | +| `has_secure_password` | Password hashing via bcrypt | `app/models/user.rb:2` | +| `generates_token_for` | Password reset token with expiration | `app/models/user.rb:9-11`; `app/controllers/passwords_controller.rb:27` | +| `rate_limit` (controller) | Anti brute-force on login/registration (store-backed) | `app/controllers/sessions_controller.rb:3`; `app/controllers/registrations_controller.rb:5` | +| `normalizes` + `enum` | Email downcasing, mapped roles | `app/models/user.rb:7,13-14` | +| Solid trio | Realtime (counters, import bar) + async jobs without Redis | `config/routes.rb` `/cable`; `app/services/dashboard/publisher.rb:8,16`; `app/jobs/user_imports/job.rb:9-11` | +| `parallelize` + `SignInHelper` | Parallel tests + login helper | `test/test_helper.rb:49,34-37` (default 1, see §6.4) | + +--- + +## 5. Adopted patterns + +Dependency direction rules (thin controllers → abstractions in the "basement"): +`Routes → Controllers → Form/Query/Policy/Service/Presenter → Value Objects`; models only +hold record invariants; concerns only cross-cutting infrastructure (AuthN/AuthZ). + +### 5.1 Form Object — validates + persists **one** use case + +Shared base (`ActiveModel::Model`), subclasses validate flow-specific rules. +Write controllers **do not** talk to ActiveRecord directly (DIP). + +```ruby +# app/forms/users/form.rb:10-12 +validates :full_name, presence: true, length: { maximum: 100 } +# app/forms/users/registration_form.rb:10-12 +validates :password, presence: true, length: { in: 8..72 } +``` + +```ruby +# app/controllers/registrations_controller.rb:18-20 +form = Users::RegistrationForm.new(registration_params) +if form.save +``` + +Hierarchy: `Users::Form` → `Users::RegistrationForm`, `Users::ProfileForm`, `Admin::UserForm` +(`app/forms/`). Each write controller only wires params → form → Inertia response. + +### 5.2 Query Object — reading, aggregation and pagination isolated + +The same query serves both the initial render **and** the broadcast (DRY). Pagy is hidden here. + +```ruby +# app/queries/users/listing_query.rb:10,22 +def self.call(scope: User.all, search: nil, page: nil) + pagy = Pagy.new(count: filtered.count, page: safe_page(filtered.count), limit: PER_PAGE) +# app/queries/dashboard/totals_query.rb:7 +def self.call(scope) +``` + +```ruby +# app/services/dashboard/publisher.rb:16 +ActionCable.server.broadcast(STREAM, overview.to_inertia) +``` + +### 5.3 Policy Object — permissions and role invariants + +Own interface that mirrors Pundit (trivial migration if the project grows). + +```ruby +# app/policies/users/policy.rb:19-21 +def admin? + @user&.admin? +end +# app/controllers/concerns/authorization.rb:13,17 +def current_administrator? = Users::Policy.admin?(Current.user) +def require_admin +``` + +### 5.4 Service Object — orchestrates multi-step flows with DI + +Dependencies enter via the constructor (DIP in action; tests inject fakes). + +```ruby +# app/services/user_imports/processor.rb:12-17 +def initialize( + parser_class: UserImports::Parser::Roo, + form_class: Users::RegistrationForm, + broadcaster: UserImports::ProgressBroadcaster.new, + dashboard_publisher: Dashboard::Publisher.new +) +``` + +Others: `Users::AccountCloser`, `Users::RoleToggler`, `Dashboard::Publisher`, +`UserImports::ProgressBroadcaster` (in `app/services/`). + +### 5.5 Value Object — immutable data at the boundaries + +Types what crosses the layers (value, without identity). + +```ruby +# app/value_objects/users/overview.rb:6,13,15 +class Overview + attr_reader :total_users, :by_role + def to_inertia +# app/value_objects/user_imports/row_error.rb:7-15 +class RowError + def initialize(row_number:, attribute:, message:) +``` + +### 5.6 Presenter — single contract with the frontend + +Pure presentation (no business rule); the TS types mirror it (front/back DRY). + +```ruby +# app/presenters/user_presenter.rb:10 +def to_inertia +``` + +```ts +// app/frontend/types/index.ts:33-36 +export type UsersOverview = { + total_users: number + by_role: Record +} +``` + +### 5.7 Concern — cross-cutting infrastructure (AuthN/AuthZ) + +Session/authorization mixins; no business rules here. + +```ruby +# app/controllers/concerns/authentication.rb:16,20,24 +def authenticated? = resume_session +def require_authentication +def resume_session +# app/controllers/application_controller.rb:2 +include Authentication +``` + +### 5.8 Job — thin, delegates to the Service + +```ruby +# app/jobs/user_imports/job.rb:9-11 +def perform(user_import) + user_import.update!(status: :processing) unless user_import.processing? + UserImports::Processor.new.call(user_import) +end +``` + +### 5.9 Callable `.call` — convention, **not** the GoF Command + +Objects-that-respond-to-`call` for uniform invocation of queries/services. It is Ruby +idiom, not a design pattern: no state/execute-invoker. + +```ruby +# app/queries/users/listing_query.rb:10 +def self.call(scope: User.all, search: nil, page: nil) +``` + +### 5.10 The single permitted strategy — the Parser (registry = data, not object) + +The project's only "strategy structure" ("minimal GoF" rule). Two formats with +divergent behavior (encoding/sheets) → single interface + selection by extension via a +hash. Extending a format = adding a line to the table (OCP). + +```ruby +# app/parsers/user_imports/parser.rb:8-14 +REGISTRY = { + ".csv" => Roo, + ".xlsx" => Roo, + ".xls" => Roo +}.freeze +# app/parsers/user_imports/parser/roo.rb:32 +spreadsheet = ::Roo::Spreadsheet.open(path, extension: extension, csv_options: CSV_OPTIONS) +``` + +No `AbstractFactory`/`Factory` was created: the registry is **data** (frozen hash), not +a GoF object. + +### 5.11 Frontend — Atomic Design guaranteed by structure (Phase 7) + +Single dependency direction (`atoms ← molecules ← organisms ← pages/templates`). Each +component is a folder with co-located `.tsx`, `.types.ts`, `.test.tsx` +and `.styles.ts`. Routing is the Inertia mapping to `../pages`. + +``` +app/frontend/{atoms,molecules,organisms,templates,pages} +``` + +```tsx +// app/frontend/entrypoints/inertia.tsx:9 +pages: '../pages', +// app/frontend/atoms/Button/Button.styles.ts:28 +export const StyledButton = styled.button(buttonBase, buttonConfig) +``` + +### 5.12 Frontend — Tailwind Styled (own wrapper, compile-time) + +Own implementation in the style of styled-components, **without** runtime CSS-in-JS: +composition of `clsx` + `tailwind-merge` in `styled(tag, base, {variants, defaults})` and +`css(...)`. Typed variants; transient props do not leak to the DOM. No Tailwind class +on JSX tags — the styling lives in `.styles.ts`. + +```ts +// app/frontend/lib/styled/cn.ts:7 +return twMerge(clsx(inputs)) +// app/frontend/lib/styled/styled.tsx:38,78 +export function styled>( +export function css(value: T): T { +``` + +```tsx +// app/frontend/atoms/Button/Button.tsx:6-7 +export function Button({ variant, size, ...rest }: ButtonProps) { + return +} +``` + +### 5.13 Frontend — State with MobX (single store per responsibility) + +Observables/computeds/actions in stores injected into the `RootStore` via Context (no +global singleton). The frontend consumes via `useStore()`; nothing of emitters/bus. + +```ts +// app/frontend/stores/flash-store.ts:12,20-22 +makeAutoObservable(this) +get hasMessage(): boolean { +// app/frontend/stores/store-context.tsx:12-16 +export function useStore(): RootStore { +// app/frontend/stores/index.ts:5 +configure({ enforceActions: 'always' }) +``` + +### 5.14 Frontend — Realtime: channel (external boundary) → `store.ingest` + +Backend broadcasts (Solid Cable) are received by a `useChannel` wrapper and become a +store `action`. No duplicated state snapshot in the component. + +```tsx +// app/frontend/organisms/DashboardStats/DashboardStats.tsx:25-27 +useChannel('DashboardChannel', {}, (payload) => { + dashboard.ingest(payload) +}) +// app/frontend/lib/cable.ts:6,26-34 (createConsumer + subscription.received) +import { createConsumer } from '@rails/actioncable' +``` + +Corresponding backend: `app/services/user_imports/progress_broadcaster.rb:8` +(`ActionCable::Channel::Base.broadcast_to`) and `app/services/dashboard/publisher.rb:16`. + +### 5.15 Back↔Front contract (proof of DRY) + +Test that reads the TS types and compares them with the presenters' payload — breaks if +the boundary diverges. + +```ruby +# test/contracts/presenter_contract_test.rb:33-35 + it "UserPresenter matches UsersOverview (Dashboard)" do +``` + +--- + +## 6. Patterns used with caution + +| Item | Caution | Proof in code | +|---|---|---| +| **propshaft** | Added **only** for the `assets:precompile` task (parity with the Rails template) and `SECRET_KEY_BASE_DUMMY` in Docker; every real app asset comes from Vite (`npm run build`), `app/assets/` is empty. It is a deliberately "dead" dependency at runtime — a conscious compliance decision. | `Gemfile:5`; `Dockerfile:67` | +| **`roo-xls`** | Accepts `.xls` beyond the requested `.csv`/`.xlsx` (neutral superset — the required flow works the same). | `Gemfile:77`; `app/parsers/user_imports/parser.rb:8` | +| **`PARALLEL_WORKERS=1` default** | The `parallelize` feature exists (`test/test_helper.rb:49`), but the default is 1 process so the SimpleCov gate is exact (parallel workers dispute `.resultset.json`). Parallelism remains available via `PARALLEL_WORKERS=N` (used 2× in CI). | `test/test_helper.rb:6` | +| **`rate_limit` + Rails.cache** | Rate limit uses `Rails.cache`; in tests it requires `:memory_store` + `Rails.cache.clear` per test (determinism). | `config/environments/test.rb`; `test/test_helper.rb:63` | +| **`ENV.fetch` fail-fast** | `APP_HOST`/`MAIL_FROM` are required in production; the Docker build has build placeholders (best practices) | `config/environments/production.rb:60-67`; `Dockerfile:67` | +| **Registry `.freeze`** | The parser "table" is data, but new extensions require editing the hash (OCP by data). It is the single evolution point. | `app/parsers/user_imports/parser.rb:8` | +| **Authorization without `UserImports::Policy`** | Only `Users::Policy` exists; the admin import session is covered by the `Authorization` concern — the Pundit-like interface is still unique | `app/controllers/concerns/authorization.rb:13` | +| **Solid Cable (Rails) + Action Cable (client)** | Backend uses Solid Cable (SQLite, no Redis); the browser consumes it with `@rails/actioncable` (standard). No SolidJS on the client — realtime delivered at the channel boundary. | `Gemfile:22`; `app/frontend/lib/cable.ts:1,6` | +| **Capybara/Selenium in the Gemfile** | Present (spec asks for robust tests), but system test usage was **deferred** in Phase 10: current coverage is unit + integration (138 tests, 99.9%). | `Gemfile:group :test`; `test/` | + +--- + +## 7. Patterns **left aside** (and why) + +### 7.1 Backend + +| Pattern/lib | Why it was left aside | +|---|---| +| **Devise** | Rails 8 native auth (`generate authentication`), lighter and with enum roles; Devise is a "heavy/obsolete gem" the spec asks to avoid. | +| **Redis** | Solid Cache/Queue/Cable keep realtime + background on SQLite (spec: *"No Redis installation should be required"*). | +| **Reform / ActiveInteraction** | Pure PORO Form Objects, zero dependency, compatible with Inertia/strong params. | +| **dry-monads** (`Result`/`Maybe`) | Form Objects expose `#save`/`#errors` (ActiveModel convention); services return booleans/errors. | +| **Pundit** | Deferred ("adopt later if it grows"); the `Users::Policy` interface already mirrors Pundit → trivial migration. | +| **Ransack** | Simple search with `ILIKE`/`LIKE` in the Query Objects (YAGNI). | +| **Draper (Decorator)** | Presenters by composition ("minimal GoF" rule). | +| **Repository pattern** | Not Rails idiom; ActiveRecord + Query Objects cover it. | +| **Interactor / Trailblazer** | One Service Object (`UserImports::Processor`) already orchestrates the only multi-step flow. | +| **Model callback for side effects** (e.g.: `after_create → broadcast`) | Side-effect goes through an explicit call in the service/job — deterministic and testable. | +| **Factory/FactoryMethod for the domain** | Form objects create; the Parser registry is data, not a GoF object. | +| **Singleton / Service Locator** | Constructor injection (DIP) with a concrete default and a test fake. | +| **God object / Law of Demeter** | Thin controllers; delegation at the boundary. | +| **System tests (today)** | Gemfile has Capybara/Selenium, but the focus is unit + integration (Phase 10); system stays for a future iteration at no cost. | + +### 7.2 Frontend + +| Pattern/lib | Why it was left aside | +|---|---| +| **styled-components (deprecated) / emotion** | Heavy runtime CSS-in-JS; the own wrapper is compile-time, SSR-safe and preserves Tailwind purge. | +| **Tailwind inline on JSX tags** | Owner rule: classes only in `.styles.ts` via `lib/styled` (single typed API). | +| **CSS Modules** | Pure encapsulation; the wrapper delivers a single typed API with Tailwind-aware merge. | +| **Redux/Toolkit** | Boilerplate; MobX covers reactive state with less ceremony. | +| **Zustand / Effector / XState** | MobX was established by the owner; machines/event-sourcing go against the anti-emitter rule. | +| **Mitt/nanoevents/EventEmitter/signals Godot** | **Forbidden**: state propagation is only React (props/Hooks/Context) + MobX reactivity; channel is boundary → `store.ingest`. | +| **React-Query / TanStack Query** | Server-state via Inertia props; realtime via Solid Cable → store. No extra server-state cache. | +| **cva** | Optional and, if adopted, only internally in `lib/styled` (components don't touch it). | +| **Decorator GoF** | Presenters by composition ("minimal GoF" rule). | + +--- + +## 8. Governance and continuous validation + +- **Lint/static**: `rubocop-rails-omakase` + `brakeman` clean (99 files, 0 offenses). +- **Ruby tests**: 138 runs / 0 failures; coverage 99.9% (SimpleCov 80% gate) — + `test/test_helper.rb:9-28`. +- **TS tests**: Vitest (22 `*.test.tsx` suites), including the wrapper's Tailwind-aware + merge — `app/frontend/atoms/Button/Button.test.tsx:25-29`. +- **Contract**: `test/contracts/presenter_contract_test.rb:6-63` (presenter ↔ TS types). +- **CI**: `PARALLEL_WORKERS=1` + coverage artifact + Docker build; deploy ready for + `kamal setup` (see `plan2/fase-11.md`). +- **Gold rules**: strict SOLID, strict DRY, minimal GoF, Clean Code, everything in English. \ No newline at end of file