From a260c265f00fafcd9b2b7a2ed91e766414d07d5d Mon Sep 17 00:00:00 2001 From: 24c02 <163450896+24c02@users.noreply.github.com> Date: Tue, 2 Sep 2025 13:52:07 -0400 Subject: [PATCH] initial public commit!!! --- .dockerignore | 47 + .gitattributes | 9 + .github/dependabot.yml | 12 + .github/workflows/ci.yml | 39 + .gitignore | 48 + .rubocop.yml | 8 + .ruby-version | 1 + Dockerfile | 98 ++ Dockerfile.worker | 61 + Gemfile | 123 ++ Gemfile.lock | 590 ++++++++ Procfile.dev | 3 + README.md | 29 + Rakefile | 6 + app/assets/stylesheets/application.css | 1 + app/components/api_example.rb | 24 + app/components/base.rb | 24 + app/components/bootleg_turbo.rb | 17 + app/components/brand.rb | 37 + app/components/break_the_glass.rb | 51 + app/components/break_the_glass_form.rb | 46 + app/components/footer.rb | 43 + app/components/home_button.rb | 9 + app/components/identity.rb | 50 + .../identity_review/aadhaar_full.rb | 43 + .../identity_review/aadhaar_info.rb | 21 + .../identity_review/basic_details.rb | 41 + .../identity_review/document_files.rb | 118 ++ .../identity_review/document_info.rb | 39 + app/components/inspector.rb | 22 + app/components/public_activity/container.rb | 23 + app/components/public_activity/snippet.rb | 22 + app/components/resemblance.rb | 15 + app/components/user_mention.rb | 27 + app/components/window.rb | 27 + app/controllers/aadhaar_controller.rb | 41 + app/controllers/addresses_controller.rb | 81 ++ .../api/external/application_controller.rb | 6 + .../api/external/identities_controller.rb | 35 + .../api/v1/application_controller.rb | 48 + app/controllers/api/v1/hcb_controller.rb | 11 + .../api/v1/health_check_controller.rb | 11 + .../api/v1/identities_controller.rb | 42 + app/controllers/application_controller.rb | 66 + .../backend/application_controller.rb | 60 + .../backend/audit_logs_controller.rb | 12 + .../backend/break_glass_controller.rb | 52 + .../backend/dashboard_controller.rb | 95 ++ .../backend/identities_controller.rb | 132 ++ app/controllers/backend/no_auth_controller.rb | 5 + .../backend/programs_controller.rb | 77 + .../backend/sessions_controller.rb | 82 ++ .../backend/static_pages_controller.rb | 19 + app/controllers/backend/users_controller.rb | 73 + .../backend/verifications_controller.rb | 131 ++ app/controllers/concerns/.keep | 0 app/controllers/concerns/is_sneaky.rb | 34 + app/controllers/onboardings_controller.rb | 245 ++++ app/controllers/sessions_controller.rb | 116 ++ app/controllers/slack_accounts_controller.rb | 37 + app/controllers/static_pages_controller.rb | 13 + .../webhooks/aadhaar_controller.rb | 73 + .../webhooks/application_controller.rb | 4 + app/frontend/entrypoints/application.css | 5 + app/frontend/entrypoints/application.js | 5 + app/frontend/entrypoints/backend.css | 8 + app/frontend/entrypoints/backend.js | 1 + app/frontend/entrypoints/direct_upload.js | 2 + app/frontend/images/.keep | 0 app/frontend/images/hc-square.png | Bin 0 -> 22225 bytes app/frontend/images/icons/break-the-glass.png | Bin 0 -> 3484 bytes app/frontend/images/loader.gif | Bin 0 -> 5470 bytes app/frontend/js/alpine.js | 3 + app/frontend/js/click-to-copy.js | 21 + app/frontend/js/lightswitch.js | 23 + app/frontend/stylesheets/application.scss | 29 + app/frontend/stylesheets/backend.scss | 36 + app/frontend/stylesheets/colors.css | 78 ++ app/frontend/stylesheets/layout.css | 215 +++ app/frontend/stylesheets/os9.css | 1104 +++++++++++++++ .../stylesheets/snippets/admin_tools.scss | 41 + .../stylesheets/snippets/banners.scss | 71 + .../stylesheets/snippets/borders.scss | 18 + app/frontend/stylesheets/snippets/brand.scss | 15 + app/frontend/stylesheets/snippets/footer.scss | 83 ++ app/frontend/stylesheets/snippets/forms.scss | 19 + .../stylesheets/snippets/lightswitch.scss | 39 + .../stylesheets/snippets/tooltips.scss | 84 ++ app/helpers/addresses_helper.rb | 2 + app/helpers/api/v1/application_helper.rb | 6 + app/helpers/api/v1/identities_helper.rb | 2 + app/helpers/application_helper.rb | 25 + app/helpers/backend/application_helper.rb | 30 + app/helpers/backend/audit_logs_helper.rb | 2 + app/helpers/backend/identities_helper.rb | 2 + app/helpers/backend/sessions_helper.rb | 2 + app/helpers/backend/users_helper.rb | 2 + app/helpers/credentials_helper.rb | 2 + app/helpers/onboarding_helper.rb | 2 + app/helpers/static_pages_helper.rb | 2 + app/jobs/application_job.rb | 7 + app/jobs/identity/notice_resemblances_job.rb | 7 + app/jobs/slack/notify_guardians_job.rb | 53 + .../verification/check_discrepancies_job.rb | 7 + .../expire_draft_aadhaar_verifications_job.rb | 16 + app/mailers/application_mailer.rb | 8 + app/mailers/identity_mailer.rb | 29 + app/mailers/verification_mailer.rb | 70 + app/models/address.rb | 56 + app/models/application_record.rb | 3 + app/models/backend.rb | 5 + app/models/backend/organizer_position.rb | 29 + app/models/backend/user.rb | 136 ++ app/models/break_glass_record.rb | 40 + app/models/concerns/.keep | 0 app/models/concerns/country_enumable.rb | 280 ++++ app/models/concerns/public_identifiable.rb | 45 + app/models/identity.rb | 296 ++++ app/models/identity/aadhaar_record.rb | 40 + app/models/identity/document.rb | 106 ++ app/models/identity/login_code.rb | 73 + app/models/identity/resemblance.rb | 31 + .../email_subaddress_resemblance.rb | 29 + .../identity/resemblance/name_resemblance.rb | 29 + .../reused_document_resemblance.rb | 31 + app/models/identity_program.rb | 27 + app/models/oauth_token.rb | 59 + app/models/program.rb | 93 ++ app/models/verification.rb | 83 ++ .../verification/aadhaar_verification.rb | 159 +++ .../verification/document_verification.rb | 146 ++ app/models/verification/vouch_verification.rb | 74 + app/policies/application_policy.rb | 43 + app/policies/backend/user_policy.rb | 13 + app/policies/break_glass_record_policy.rb | 5 + app/policies/identity/document_policy.rb | 10 + app/policies/identity_policy.rb | 24 + app/policies/program_policy.rb | 41 + .../aadhaar_verification_policy.rb | 2 + .../document_verification_policy.rb | 2 + .../verification/vouch_verification_policy.rb | 3 + app/policies/verification_policy.rb | 13 + app/services/aadhaar_service.rb | 7 + app/services/aadhaar_service/mock.rb | 15 + app/services/aadhaar_service/production.rb | 19 + app/services/papers_please_engine.rb | 18 + .../aadhaar_scrutinizer.rb | 40 + app/services/papers_please_engine/base.rb | 13 + app/services/resemblance_noticer_engine.rb | 11 + .../resemblance_noticer_engine/base.rb | 13 + .../duplicate_documents.rb | 22 + .../email_subaddressing.rb | 44 + .../name_similarity.rb | 34 + app/services/slack_service.rb | 7 + app/views/aadhaar/digilocker_link.html.erb | 1 + app/views/addresses/_form.html.erb | 39 + app/views/addresses/edit.html.erb | 6 + app/views/addresses/index.html.erb | 38 + app/views/addresses/new.html.erb | 3 + .../addresses/program_create_address.html.erb | 3 + app/views/addresses/show.html.erb | 2 + app/views/api/v1/addresses/_address.jb | 12 + app/views/api/v1/identities/_identity.jb | 31 + app/views/api/v1/identities/index.jb | 3 + app/views/api/v1/identities/me.jb | 4 + app/views/api/v1/identities/show.jb | 3 + app/views/backend/audit_logs/index.html.erb | 12 + app/views/backend/dashboard/show.html.erb | 114 ++ .../backend/identities/_identity.html.erb | 1 + app/views/backend/identities/edit.html.erb | 87 ++ app/views/backend/identities/index.html.erb | 50 + .../backend/identities/new_vouch.html.erb | 11 + app/views/backend/identities/show.html.erb | 225 +++ .../_email_subaddress_resemblance.html.erb | 5 + .../_name_resemblance.html.erb | 1 + .../_reused_document_resemblance.html.erb | 1 + app/views/backend/programs/_program.html.erb | 6 + app/views/backend/programs/edit.html.erb | 5 + app/views/backend/programs/index.html.erb | 54 + app/views/backend/programs/new.html.erb | 5 + app/views/backend/programs/show.html.erb | 66 + app/views/backend/static_pages/index.html.erb | 43 + app/views/backend/static_pages/login.html.erb | 34 + .../static_pages/session_dump.html.erb | 3 + app/views/backend/users/_user.html.erb | 1 + app/views/backend/users/edit.html.erb | 5 + app/views/backend/users/index.html.erb | 33 + app/views/backend/users/new.html.erb | 5 + app/views/backend/users/show.html.erb | 23 + .../backend/verifications/index.html.erb | 71 + .../backend/verifications/pending.html.erb | 52 + app/views/backend/verifications/show.html.erb | 236 ++++ app/views/base.rb | 9 + .../applications/_delete_form.html.erb | 6 + .../doorkeeper/applications/_form.html.erb | 59 + .../doorkeeper/applications/edit.html.erb | 5 + .../doorkeeper/applications/index.html.erb | 33 + .../doorkeeper/applications/new.html.erb | 5 + .../doorkeeper/applications/show.html.erb | 53 + .../doorkeeper/authorizations/error.html.erb | 8 + .../authorizations/form_post.html.erb | 15 + .../doorkeeper/authorizations/new.html.erb | 64 + .../doorkeeper/authorizations/show.html.erb | 7 + .../_delete_form.html.erb | 4 + .../authorized_applications/index.html.erb | 24 + app/views/forms/application_form.rb | 50 + app/views/forms/backend/programs/form.rb | 40 + app/views/forms/backend/users/form.rb | 37 + app/views/kaminari/_first_page.html.erb | 8 + app/views/kaminari/_gap.html.erb | 7 + app/views/kaminari/_last_page.html.erb | 8 + app/views/kaminari/_next_page.html.erb | 8 + app/views/kaminari/_page.html.erb | 13 + app/views/kaminari/_paginator.html.erb | 24 + app/views/kaminari/_prev_page.html.erb | 8 + app/views/layouts/application.html.erb | 36 + app/views/layouts/backend.html.erb | 28 + app/views/layouts/doorkeeper/admin.html.erb | 39 + app/views/layouts/mailer.text.erb | 14 + app/views/mailers/blank_mailer.text.erb | 0 app/views/onboardings/aadhaar.html.erb | 37 + app/views/onboardings/aadhaar_step_2.html.erb | 2 + app/views/onboardings/address.html.erb | 13 + app/views/onboardings/basic_info.html.erb | 84 ++ app/views/onboardings/document.html.erb | 114 ++ app/views/onboardings/submitted.html.erb | 8 + app/views/onboardings/welcome.html.erb | 26 + .../break_glass_record/_create.html.erb | 7 + .../identity/_admin_update.html.erb | 3 + .../identity/_clear_slack_id.html.erb | 3 + .../public_activity/identity/_create.html.erb | 3 + .../identity/_set_slack_id.html.erb | 3 + .../public_activity/identity/_update.html.erb | 3 + .../verification/_approve.html.erb | 6 + .../verification/_create.html.erb | 3 + .../verification/_reject.html.erb | 6 + .../_create.html.erb | 3 + .../_create_link.html.erb | 3 + .../_data_received.html.erb | 3 + .../_approve.html.erb | 6 + .../_create.html.erb | 3 + .../_ignored.html.erb | 3 + .../_reject.html.erb | 6 + .../_update.html.erb | 3 + .../_create.html.erb | 3 + .../_ignored.html.erb | 3 + app/views/pwa/manifest.json.erb | 22 + app/views/pwa/service-worker.js | 26 + app/views/sessions/check_your_email.html.erb | 17 + app/views/sessions/new.html.erb | 21 + app/views/sessions/verify.html.erb | 19 + app/views/shared/_flash.html.erb | 28 + .../shared/_verification_status.html.erb | 53 + app/views/shared/async_flash.erb | 28 + .../static_pages/external_api_docs.html.erb | 64 + app/views/static_pages/faq.html.erb | 77 + app/views/static_pages/index.html.erb | 61 + bin/brakeman | 7 + bin/bundle | 109 ++ bin/dev | 2 + bin/docker-entrypoint | 14 + bin/lint | 7 + bin/rails | 4 + bin/rake | 4 + bin/rubocop | 8 + bin/setup | 34 + bin/thrust | 5 + bin/vite | 27 + config.ru | 6 + config/application.rb | 77 + config/boot.rb | 4 + config/brakeman.ignore | 62 + config/credentials.yml.enc | 1 + config/credentials/production.yml.enc | 1 + config/credentials/staging.yml.enc | 1 + config/database.yml | 86 ++ config/environment.rb | 5 + config/environments/development.rb | 73 + config/environments/production.rb | 95 ++ config/environments/staging.rb | 91 ++ config/environments/test.rb | 53 + config/honeybadger.yml | 39 + .../initializers/content_security_policy.rb | 34 + config/initializers/doorkeeper.rb | 549 ++++++++ .../initializers/filter_parameter_logging.rb | 8 + config/initializers/flipper.rb | 45 + config/initializers/git_version.rb | 22 + config/initializers/good_job.rb | 8 + config/initializers/inflections.rb | 5 + config/initializers/monkey_patches.rb | 20 + config/initializers/paper_trail.rb | 12 + config/initializers/phlex.rb | 16 + config/initializers/public_activity.rb | 3 + config/initializers/slack.rb | 3 + config/locales/doorkeeper.en.yml | 159 +++ config/locales/en.yml | 34 + config/puma.rb | 38 + config/routes.rb | 312 +++++ config/sanctioned_countries.yml | 5 + config/storage.yml | 32 + config/vite.json | 25 + db/migrate/20250822205652_init_schema.rb | 422 ++++++ db/schema.rb | 467 +++++++ db/seeds.rb | 9 + docker-compose-dbonly.yml | 21 + lib/application_component.rb | 2 + lib/tasks/.keep | 0 log/.keep | 0 package.json | 29 + public/.well-known/security.txt | 7 + public/400.html | 114 ++ public/404.html | 114 ++ public/406-unsupported-browser.html | 114 ++ public/422.html | 114 ++ public/500.html | 114 ++ public/ChicagoFLF.ttf | Bin 0 -> 48580 bytes public/icon.png | Bin 0 -> 486 bytes public/icon.svg | 49 + public/robots.txt | 1 + script/.keep | 0 storage/.keep | 0 tmp/.keep | 0 tmp/storage/.keep | 0 vendor/.keep | 0 vite.config.mts | 17 + yarn.lock | 1235 +++++++++++++++++ 326 files changed, 15473 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitattributes create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .rubocop.yml create mode 100644 .ruby-version create mode 100644 Dockerfile create mode 100644 Dockerfile.worker create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 Procfile.dev create mode 100644 README.md create mode 100644 Rakefile create mode 100644 app/assets/stylesheets/application.css create mode 100644 app/components/api_example.rb create mode 100644 app/components/base.rb create mode 100644 app/components/bootleg_turbo.rb create mode 100644 app/components/brand.rb create mode 100644 app/components/break_the_glass.rb create mode 100644 app/components/break_the_glass_form.rb create mode 100644 app/components/footer.rb create mode 100644 app/components/home_button.rb create mode 100644 app/components/identity.rb create mode 100644 app/components/identity_review/aadhaar_full.rb create mode 100644 app/components/identity_review/aadhaar_info.rb create mode 100644 app/components/identity_review/basic_details.rb create mode 100644 app/components/identity_review/document_files.rb create mode 100644 app/components/identity_review/document_info.rb create mode 100644 app/components/inspector.rb create mode 100644 app/components/public_activity/container.rb create mode 100644 app/components/public_activity/snippet.rb create mode 100644 app/components/resemblance.rb create mode 100644 app/components/user_mention.rb create mode 100644 app/components/window.rb create mode 100644 app/controllers/aadhaar_controller.rb create mode 100644 app/controllers/addresses_controller.rb create mode 100644 app/controllers/api/external/application_controller.rb create mode 100644 app/controllers/api/external/identities_controller.rb create mode 100644 app/controllers/api/v1/application_controller.rb create mode 100644 app/controllers/api/v1/hcb_controller.rb create mode 100644 app/controllers/api/v1/health_check_controller.rb create mode 100644 app/controllers/api/v1/identities_controller.rb create mode 100644 app/controllers/application_controller.rb create mode 100644 app/controllers/backend/application_controller.rb create mode 100644 app/controllers/backend/audit_logs_controller.rb create mode 100644 app/controllers/backend/break_glass_controller.rb create mode 100644 app/controllers/backend/dashboard_controller.rb create mode 100644 app/controllers/backend/identities_controller.rb create mode 100644 app/controllers/backend/no_auth_controller.rb create mode 100644 app/controllers/backend/programs_controller.rb create mode 100644 app/controllers/backend/sessions_controller.rb create mode 100644 app/controllers/backend/static_pages_controller.rb create mode 100644 app/controllers/backend/users_controller.rb create mode 100644 app/controllers/backend/verifications_controller.rb create mode 100644 app/controllers/concerns/.keep create mode 100644 app/controllers/concerns/is_sneaky.rb create mode 100644 app/controllers/onboardings_controller.rb create mode 100644 app/controllers/sessions_controller.rb create mode 100644 app/controllers/slack_accounts_controller.rb create mode 100644 app/controllers/static_pages_controller.rb create mode 100644 app/controllers/webhooks/aadhaar_controller.rb create mode 100644 app/controllers/webhooks/application_controller.rb create mode 100644 app/frontend/entrypoints/application.css create mode 100644 app/frontend/entrypoints/application.js create mode 100644 app/frontend/entrypoints/backend.css create mode 100644 app/frontend/entrypoints/backend.js create mode 100644 app/frontend/entrypoints/direct_upload.js create mode 100644 app/frontend/images/.keep create mode 100644 app/frontend/images/hc-square.png create mode 100644 app/frontend/images/icons/break-the-glass.png create mode 100644 app/frontend/images/loader.gif create mode 100644 app/frontend/js/alpine.js create mode 100644 app/frontend/js/click-to-copy.js create mode 100644 app/frontend/js/lightswitch.js create mode 100644 app/frontend/stylesheets/application.scss create mode 100644 app/frontend/stylesheets/backend.scss create mode 100644 app/frontend/stylesheets/colors.css create mode 100644 app/frontend/stylesheets/layout.css create mode 100644 app/frontend/stylesheets/os9.css create mode 100644 app/frontend/stylesheets/snippets/admin_tools.scss create mode 100644 app/frontend/stylesheets/snippets/banners.scss create mode 100644 app/frontend/stylesheets/snippets/borders.scss create mode 100644 app/frontend/stylesheets/snippets/brand.scss create mode 100644 app/frontend/stylesheets/snippets/footer.scss create mode 100644 app/frontend/stylesheets/snippets/forms.scss create mode 100644 app/frontend/stylesheets/snippets/lightswitch.scss create mode 100644 app/frontend/stylesheets/snippets/tooltips.scss create mode 100644 app/helpers/addresses_helper.rb create mode 100644 app/helpers/api/v1/application_helper.rb create mode 100644 app/helpers/api/v1/identities_helper.rb create mode 100644 app/helpers/application_helper.rb create mode 100644 app/helpers/backend/application_helper.rb create mode 100644 app/helpers/backend/audit_logs_helper.rb create mode 100644 app/helpers/backend/identities_helper.rb create mode 100644 app/helpers/backend/sessions_helper.rb create mode 100644 app/helpers/backend/users_helper.rb create mode 100644 app/helpers/credentials_helper.rb create mode 100644 app/helpers/onboarding_helper.rb create mode 100644 app/helpers/static_pages_helper.rb create mode 100644 app/jobs/application_job.rb create mode 100644 app/jobs/identity/notice_resemblances_job.rb create mode 100644 app/jobs/slack/notify_guardians_job.rb create mode 100644 app/jobs/verification/check_discrepancies_job.rb create mode 100644 app/jobs/verification/expire_draft_aadhaar_verifications_job.rb create mode 100644 app/mailers/application_mailer.rb create mode 100644 app/mailers/identity_mailer.rb create mode 100644 app/mailers/verification_mailer.rb create mode 100644 app/models/address.rb create mode 100644 app/models/application_record.rb create mode 100644 app/models/backend.rb create mode 100644 app/models/backend/organizer_position.rb create mode 100644 app/models/backend/user.rb create mode 100644 app/models/break_glass_record.rb create mode 100644 app/models/concerns/.keep create mode 100644 app/models/concerns/country_enumable.rb create mode 100644 app/models/concerns/public_identifiable.rb create mode 100644 app/models/identity.rb create mode 100644 app/models/identity/aadhaar_record.rb create mode 100644 app/models/identity/document.rb create mode 100644 app/models/identity/login_code.rb create mode 100644 app/models/identity/resemblance.rb create mode 100644 app/models/identity/resemblance/email_subaddress_resemblance.rb create mode 100644 app/models/identity/resemblance/name_resemblance.rb create mode 100644 app/models/identity/resemblance/reused_document_resemblance.rb create mode 100644 app/models/identity_program.rb create mode 100644 app/models/oauth_token.rb create mode 100644 app/models/program.rb create mode 100644 app/models/verification.rb create mode 100644 app/models/verification/aadhaar_verification.rb create mode 100644 app/models/verification/document_verification.rb create mode 100644 app/models/verification/vouch_verification.rb create mode 100644 app/policies/application_policy.rb create mode 100644 app/policies/backend/user_policy.rb create mode 100644 app/policies/break_glass_record_policy.rb create mode 100644 app/policies/identity/document_policy.rb create mode 100644 app/policies/identity_policy.rb create mode 100644 app/policies/program_policy.rb create mode 100644 app/policies/verification/aadhaar_verification_policy.rb create mode 100644 app/policies/verification/document_verification_policy.rb create mode 100644 app/policies/verification/vouch_verification_policy.rb create mode 100644 app/policies/verification_policy.rb create mode 100644 app/services/aadhaar_service.rb create mode 100644 app/services/aadhaar_service/mock.rb create mode 100644 app/services/aadhaar_service/production.rb create mode 100644 app/services/papers_please_engine.rb create mode 100644 app/services/papers_please_engine/aadhaar_scrutinizer.rb create mode 100644 app/services/papers_please_engine/base.rb create mode 100644 app/services/resemblance_noticer_engine.rb create mode 100644 app/services/resemblance_noticer_engine/base.rb create mode 100644 app/services/resemblance_noticer_engine/duplicate_documents.rb create mode 100644 app/services/resemblance_noticer_engine/email_subaddressing.rb create mode 100644 app/services/resemblance_noticer_engine/name_similarity.rb create mode 100644 app/services/slack_service.rb create mode 100644 app/views/aadhaar/digilocker_link.html.erb create mode 100644 app/views/addresses/_form.html.erb create mode 100644 app/views/addresses/edit.html.erb create mode 100644 app/views/addresses/index.html.erb create mode 100644 app/views/addresses/new.html.erb create mode 100644 app/views/addresses/program_create_address.html.erb create mode 100644 app/views/addresses/show.html.erb create mode 100644 app/views/api/v1/addresses/_address.jb create mode 100644 app/views/api/v1/identities/_identity.jb create mode 100644 app/views/api/v1/identities/index.jb create mode 100644 app/views/api/v1/identities/me.jb create mode 100644 app/views/api/v1/identities/show.jb create mode 100644 app/views/backend/audit_logs/index.html.erb create mode 100644 app/views/backend/dashboard/show.html.erb create mode 100644 app/views/backend/identities/_identity.html.erb create mode 100644 app/views/backend/identities/edit.html.erb create mode 100644 app/views/backend/identities/index.html.erb create mode 100644 app/views/backend/identities/new_vouch.html.erb create mode 100644 app/views/backend/identities/show.html.erb create mode 100644 app/views/backend/identity/resemblance/email_subaddress_resemblances/_email_subaddress_resemblance.html.erb create mode 100644 app/views/backend/identity/resemblance/name_resemblances/_name_resemblance.html.erb create mode 100644 app/views/backend/identity/resemblance/reused_document_resemblances/_reused_document_resemblance.html.erb create mode 100644 app/views/backend/programs/_program.html.erb create mode 100644 app/views/backend/programs/edit.html.erb create mode 100644 app/views/backend/programs/index.html.erb create mode 100644 app/views/backend/programs/new.html.erb create mode 100644 app/views/backend/programs/show.html.erb create mode 100644 app/views/backend/static_pages/index.html.erb create mode 100644 app/views/backend/static_pages/login.html.erb create mode 100644 app/views/backend/static_pages/session_dump.html.erb create mode 100644 app/views/backend/users/_user.html.erb create mode 100644 app/views/backend/users/edit.html.erb create mode 100644 app/views/backend/users/index.html.erb create mode 100644 app/views/backend/users/new.html.erb create mode 100644 app/views/backend/users/show.html.erb create mode 100644 app/views/backend/verifications/index.html.erb create mode 100644 app/views/backend/verifications/pending.html.erb create mode 100644 app/views/backend/verifications/show.html.erb create mode 100644 app/views/base.rb create mode 100644 app/views/doorkeeper/applications/_delete_form.html.erb create mode 100644 app/views/doorkeeper/applications/_form.html.erb create mode 100644 app/views/doorkeeper/applications/edit.html.erb create mode 100644 app/views/doorkeeper/applications/index.html.erb create mode 100644 app/views/doorkeeper/applications/new.html.erb create mode 100644 app/views/doorkeeper/applications/show.html.erb create mode 100644 app/views/doorkeeper/authorizations/error.html.erb create mode 100644 app/views/doorkeeper/authorizations/form_post.html.erb create mode 100644 app/views/doorkeeper/authorizations/new.html.erb create mode 100644 app/views/doorkeeper/authorizations/show.html.erb create mode 100644 app/views/doorkeeper/authorized_applications/_delete_form.html.erb create mode 100644 app/views/doorkeeper/authorized_applications/index.html.erb create mode 100644 app/views/forms/application_form.rb create mode 100644 app/views/forms/backend/programs/form.rb create mode 100644 app/views/forms/backend/users/form.rb create mode 100644 app/views/kaminari/_first_page.html.erb create mode 100644 app/views/kaminari/_gap.html.erb create mode 100644 app/views/kaminari/_last_page.html.erb create mode 100644 app/views/kaminari/_next_page.html.erb create mode 100644 app/views/kaminari/_page.html.erb create mode 100644 app/views/kaminari/_paginator.html.erb create mode 100644 app/views/kaminari/_prev_page.html.erb create mode 100644 app/views/layouts/application.html.erb create mode 100644 app/views/layouts/backend.html.erb create mode 100644 app/views/layouts/doorkeeper/admin.html.erb create mode 100644 app/views/layouts/mailer.text.erb create mode 100644 app/views/mailers/blank_mailer.text.erb create mode 100644 app/views/onboardings/aadhaar.html.erb create mode 100644 app/views/onboardings/aadhaar_step_2.html.erb create mode 100644 app/views/onboardings/address.html.erb create mode 100644 app/views/onboardings/basic_info.html.erb create mode 100644 app/views/onboardings/document.html.erb create mode 100644 app/views/onboardings/submitted.html.erb create mode 100644 app/views/onboardings/welcome.html.erb create mode 100644 app/views/public_activity/break_glass_record/_create.html.erb create mode 100644 app/views/public_activity/identity/_admin_update.html.erb create mode 100644 app/views/public_activity/identity/_clear_slack_id.html.erb create mode 100644 app/views/public_activity/identity/_create.html.erb create mode 100644 app/views/public_activity/identity/_set_slack_id.html.erb create mode 100644 app/views/public_activity/identity/_update.html.erb create mode 100644 app/views/public_activity/verification/_approve.html.erb create mode 100644 app/views/public_activity/verification/_create.html.erb create mode 100644 app/views/public_activity/verification/_reject.html.erb create mode 100644 app/views/public_activity/verification_aadhaar_verification/_create.html.erb create mode 100644 app/views/public_activity/verification_aadhaar_verification/_create_link.html.erb create mode 100644 app/views/public_activity/verification_aadhaar_verification/_data_received.html.erb create mode 100644 app/views/public_activity/verification_document_verification/_approve.html.erb create mode 100644 app/views/public_activity/verification_document_verification/_create.html.erb create mode 100644 app/views/public_activity/verification_document_verification/_ignored.html.erb create mode 100644 app/views/public_activity/verification_document_verification/_reject.html.erb create mode 100644 app/views/public_activity/verification_document_verification/_update.html.erb create mode 100644 app/views/public_activity/verification_vouch_verification/_create.html.erb create mode 100644 app/views/public_activity/verification_vouch_verification/_ignored.html.erb create mode 100644 app/views/pwa/manifest.json.erb create mode 100644 app/views/pwa/service-worker.js create mode 100644 app/views/sessions/check_your_email.html.erb create mode 100644 app/views/sessions/new.html.erb create mode 100644 app/views/sessions/verify.html.erb create mode 100644 app/views/shared/_flash.html.erb create mode 100644 app/views/shared/_verification_status.html.erb create mode 100644 app/views/shared/async_flash.erb create mode 100644 app/views/static_pages/external_api_docs.html.erb create mode 100644 app/views/static_pages/faq.html.erb create mode 100644 app/views/static_pages/index.html.erb create mode 100755 bin/brakeman create mode 100755 bin/bundle create mode 100755 bin/dev create mode 100755 bin/docker-entrypoint create mode 100755 bin/lint 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.ignore create mode 100644 config/credentials.yml.enc create mode 100644 config/credentials/production.yml.enc create mode 100644 config/credentials/staging.yml.enc create mode 100644 config/database.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/staging.rb create mode 100644 config/environments/test.rb create mode 100644 config/honeybadger.yml create mode 100644 config/initializers/content_security_policy.rb create mode 100644 config/initializers/doorkeeper.rb create mode 100644 config/initializers/filter_parameter_logging.rb create mode 100644 config/initializers/flipper.rb create mode 100644 config/initializers/git_version.rb create mode 100644 config/initializers/good_job.rb create mode 100644 config/initializers/inflections.rb create mode 100644 config/initializers/monkey_patches.rb create mode 100644 config/initializers/paper_trail.rb create mode 100644 config/initializers/phlex.rb create mode 100644 config/initializers/public_activity.rb create mode 100644 config/initializers/slack.rb create mode 100644 config/locales/doorkeeper.en.yml create mode 100644 config/locales/en.yml create mode 100644 config/puma.rb create mode 100644 config/routes.rb create mode 100644 config/sanctioned_countries.yml create mode 100644 config/storage.yml create mode 100644 config/vite.json create mode 100644 db/migrate/20250822205652_init_schema.rb create mode 100644 db/schema.rb create mode 100644 db/seeds.rb create mode 100644 docker-compose-dbonly.yml create mode 100644 lib/application_component.rb create mode 100644 lib/tasks/.keep create mode 100644 log/.keep create mode 100644 package.json create mode 100644 public/.well-known/security.txt 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/ChicagoFLF.ttf 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 tmp/.keep create mode 100644 tmp/storage/.keep create mode 100644 vendor/.keep create mode 100644 vite.config.mts create mode 100644 yarn.lock diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7540593 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,47 @@ +# 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 + +# Ignore CI service files. +/.github + +# Ignore development files +/.devcontainer + +# Ignore Docker-related files +/.dockerignore +/Dockerfile* diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8dc4323 --- /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/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f0527e6 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: +- package-ecosystem: bundler + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 +- package-ecosystem: github-actions + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fb0f4ea --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + pull_request: + push: + branches: [ main ] + +jobs: + scan_ruby: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Scan for common Rails security vulnerabilities using static analysis + run: bin/brakeman --no-pager + + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Lint code for consistent style + run: bin/rubocop -f github + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6d40285 --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# 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* + +# 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 + + +/config/credentials/production.key + +/config/credentials/staging.key + +/config/credentials/development.key diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..f9d86d4 --- /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 0000000..f989260 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.4.4 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3862804 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,98 @@ +# syntax=docker/dockerfile:1.4 +# check=error=true + +# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand: +# docker build -t identity_vault . +# docker run -d -p 80:80 -e RAILS_MASTER_KEY= --name identity_vault identity_vault + +# 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=3.4.4 +FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base + +# Rails app lives here +WORKDIR /rails + +# Install base packages with runtime libraries for libheif +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y curl libjemalloc2 libvips imagemagick postgresql-client libffi-dev \ + libjpeg62-turbo libaom3 libx265-199 libde265-0 libpng16-16 wget && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +ARG NODE_VERSION=23.6.0 +ARG YARN_VERSION=1.22.22 +ENV PATH=/usr/local/node/bin:$PATH +RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && \ + /tmp/node-build-master/bin/node-build "${NODE_VERSION}" /usr/local/node && \ + npm install -g yarn@$YARN_VERSION && \ + rm -rf /tmp/node-build-master + +ENV BUNDLE_DEPLOYMENT="1" \ + BUNDLE_PATH="/usr/local/bundle" \ + BUNDLE_WITHOUT="development" \ + LD_LIBRARY_PATH="/usr/local/lib" + +# 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 curl libjemalloc2 libvips imagemagick postgresql-client libffi-dev build-essential git libpq-dev libyaml-dev pkg-config \ + cmake libjpeg-dev libpng-dev libaom-dev libx265-dev libde265-dev && \ + # Build libheif from latest source with examples + cd /tmp && \ + git clone --depth 1 https://github.com/strukturag/libheif.git && \ + cd libheif && \ + mkdir build && cd build && \ + cmake --preset=release -DWITH_EXAMPLES=ON -DENABLE_PLUGIN_LOADING=NO .. && \ + make -j$(nproc) && \ + make install && \ + ldconfig && \ + # Clean up + cd / && rm -rf /tmp/libheif && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# Install application gems +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 package.json yarn.lock ./ +RUN yarn install --frozen-lockfile + +# Copy application code +COPY . . + +# Precompile bootsnap code for faster boot times +RUN bundle exec bootsnap precompile app/ lib/ && \ + RAILS_ENV=production SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile && \ + rm -rf node_modules + +# Final stage for app image +FROM base + +# Copy built artifacts: gems, application +COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" +COPY --from=build /rails /rails +# Copy libheif libraries and examples from build stage +COPY --from=build /usr/local/lib/libheif* /usr/local/lib/ +COPY --from=build /usr/local/bin/heif-* /usr/local/bin/ +COPY --from=build /usr/local/include/libheif /usr/local/include/libheif +RUN ldconfig + +# Run and own only the runtime files as a non-root users 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/Dockerfile.worker b/Dockerfile.worker new file mode 100644 index 0000000..7bb1a1c --- /dev/null +++ b/Dockerfile.worker @@ -0,0 +1,61 @@ +# 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 identity_vault . +# docker run -d -p 80:80 -e RAILS_MASTER_KEY= --name identity_vault identity_vault + +# 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=3.4.4 +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 imagemagick libheif-dev postgresql-client libffi-dev && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +ENV BUNDLE_DEPLOYMENT="1" \ + BUNDLE_PATH="/usr/local/bundle" \ + BUNDLE_WITHOUT="development" + +# 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 curl libjemalloc2 libvips imagemagick libheif-dev postgresql-client libffi-dev build-essential git libpq-dev libyaml-dev pkg-config && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# Install application gems +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 +COPY . . + +# 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 users 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 + +CMD ["bundle", "exec", "good_job", "start"] \ No newline at end of file diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..f9a6859 --- /dev/null +++ b/Gemfile @@ -0,0 +1,123 @@ +source "https://rubygems.org" + +# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" +gem "rails", "~> 8.0.2" +# Use postgresql as the database for Active Record +gem "pg", "~> 1.1" +# Use the Puma web server [https://github.com/puma/puma] +gem "puma", ">= 5.0" + +# 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 ] + +# Reduces boot times through caching; required in config/boot.rb +gem "bootsnap", 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" + + # 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 + +gem "dotenv", groups: [ :development, :test ] + +gem "vite_rails" + +gem "pundit", "~> 2.5" + +gem "honeybadger", "~> 5.28" + +gem "http", "~> 5.2" + +gem "superform", "~> 0.5.1" + +gem "phlex", "~> 2.2" + +gem "phlex-rails", "~> 2.2" + +gem "literal", "~> 1.7" + +gem "jb", "~> 0.8.2" + +gem "wicked", "~> 2.0" + +gem "countries", "~> 7.1" + +gem "awesome_print", "~> 1.9" + +gem "active_storage_encryption", "~> 0.3.0" + +gem "doorkeeper", "~> 5.8" + +gem "aasm", "~> 5.5" + +gem "kaminari", "~> 1.2" + +gem "blind_index", "~> 2.7" + +gem "lockbox", "~> 2.0" + +gem "hashid-rails", "~> 1.4" + +gem "public_activity", "~> 3.0" + +gem "paper_trail", "~> 16.0" + +gem "good_job", "~> 4.10" + +group :development do + gem "letter_opener_web", "~> 3.0" +end + +gem "aws-sdk-s3", "~> 1.189" + +gem "lz_string", "~> 0.3.0" + +gem "valid_email2", "~> 7.0" + +gem "rails_semantic_logger", "~> 4.17" + +gem "acts_as_paranoid", "~> 0.10.3" + +gem "console1984", "~> 0.2.2" + +gem "audits1984", "~> 0.1.7" + +gem "propshaft", "~> 1.1" + +gem "mini-levenshtein", "~> 0.1.2" + +gem "faraday", "~> 2.13" + +gem "ruby-vips", "~> 2.2" + +gem "slack-ruby-client", "~> 2.6" + +gem "redcarpet", "~> 3.6" + +gem "flipper", "~> 1.3" +gem "flipper-ui", "~> 1.3" +gem "flipper-active_record", "~> 1.3" + +gem "annotaterb", "~> 4.19" + +gem "erb_lint", "~> 0.9.0", group: :development diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..48f4762 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,590 @@ +GEM + remote: https://rubygems.org/ + specs: + aasm (5.5.0) + concurrent-ruby (~> 1.0) + actioncable (8.0.2) + actionpack (= 8.0.2) + activesupport (= 8.0.2) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (8.0.2) + actionpack (= 8.0.2) + activejob (= 8.0.2) + activerecord (= 8.0.2) + activestorage (= 8.0.2) + activesupport (= 8.0.2) + mail (>= 2.8.0) + actionmailer (8.0.2) + actionpack (= 8.0.2) + actionview (= 8.0.2) + activejob (= 8.0.2) + activesupport (= 8.0.2) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.0.2) + actionview (= 8.0.2) + activesupport (= 8.0.2) + 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.2) + actionpack (= 8.0.2) + activerecord (= 8.0.2) + activestorage (= 8.0.2) + activesupport (= 8.0.2) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (8.0.2) + activesupport (= 8.0.2) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + active_storage_encryption (0.3.0) + activestorage + block_cipher_kit (>= 0.0.4) + rails (>= 7.2.2.1) + serve_byte_range (~> 1.0) + activejob (8.0.2) + activesupport (= 8.0.2) + globalid (>= 0.3.6) + activemodel (8.0.2) + activesupport (= 8.0.2) + activerecord (8.0.2) + activemodel (= 8.0.2) + activesupport (= 8.0.2) + timeout (>= 0.4.0) + activestorage (8.0.2) + actionpack (= 8.0.2) + activejob (= 8.0.2) + activerecord (= 8.0.2) + activesupport (= 8.0.2) + marcel (~> 1.0) + activesupport (8.0.2) + 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) + acts_as_paranoid (0.10.3) + activerecord (>= 6.1, < 8.1) + activesupport (>= 6.1, < 8.1) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + annotaterb (4.19.0) + activerecord (>= 6.0.0) + activesupport (>= 6.0.0) + argon2-kdf (0.3.1) + fiddle + ast (2.4.3) + audits1984 (0.1.7) + console1984 + rinku + rouge + turbo-rails + awesome_print (1.9.2) + aws-eventstream (1.4.0) + aws-partitions (1.1110.0) + aws-sdk-core (3.225.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + jmespath (~> 1, >= 1.6.1) + logger + aws-sdk-kms (1.102.0) + aws-sdk-core (~> 3, >= 3.225.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.189.0) + aws-sdk-core (~> 3, >= 3.225.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.0) + aws-eventstream (~> 1, >= 1.0.2) + base64 (0.2.0) + benchmark (0.4.0) + better_html (2.1.1) + actionview (>= 6.0) + activesupport (>= 6.0) + ast (~> 2.0) + erubi (~> 1.4) + parser (>= 2.4) + smart_properties + bigdecimal (3.1.9) + bindex (0.8.1) + blind_index (2.7.0) + activesupport (>= 7.1) + argon2-kdf (>= 0.2) + block_cipher_kit (0.0.4) + bootsnap (1.18.6) + msgpack (~> 1.2) + brakeman (7.1.0) + racc + builder (3.3.0) + childprocess (5.1.0) + logger (~> 1.5) + concurrent-ruby (1.3.5) + connection_pool (2.5.3) + console1984 (0.2.2) + irb (~> 1.13) + parser + rails (>= 7.0) + rainbow + countries (7.1.1) + unaccent (~> 0.3) + crass (1.0.6) + date (3.4.1) + debug (1.10.0) + irb (~> 1.10) + reline (>= 0.3.8) + domain_name (0.6.20240107) + doorkeeper (5.8.2) + railties (>= 5) + dotenv (3.1.8) + drb (2.2.3) + dry-cli (1.2.0) + erb (5.0.1) + erb_lint (0.9.0) + activesupport + better_html (>= 2.0.1) + parser (>= 2.7.1.4) + rainbow + rubocop (>= 1) + smart_properties + erubi (1.13.1) + et-orbi (1.2.11) + tzinfo + faraday (2.13.1) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-mashify (1.0.0) + faraday (~> 2.0) + hashie + faraday-multipart (1.1.0) + multipart-post (~> 2.0) + faraday-net_http (3.4.0) + net-http (>= 0.5.0) + ffi (1.17.2-aarch64-linux-gnu) + ffi (1.17.2-aarch64-linux-musl) + ffi (1.17.2-arm-linux-gnu) + ffi (1.17.2-arm-linux-musl) + ffi (1.17.2-arm64-darwin) + ffi (1.17.2-x86_64-darwin) + ffi (1.17.2-x86_64-linux-gnu) + ffi (1.17.2-x86_64-linux-musl) + ffi-compiler (1.3.2) + ffi (>= 1.15.5) + rake + fiddle (1.1.8) + flipper (1.3.5) + concurrent-ruby (< 2) + flipper-active_record (1.3.5) + activerecord (>= 4.2, < 9) + flipper (~> 1.3.5) + flipper-ui (1.3.5) + erubi (>= 1.0.0, < 2.0.0) + flipper (~> 1.3.5) + rack (>= 1.4, < 4) + rack-protection (>= 1.5.3, < 5.0.0) + rack-session (>= 1.0.2, < 3.0.0) + sanitize (< 8) + fugit (1.11.1) + et-orbi (~> 1, >= 1.2.11) + raabro (~> 1.4) + gli (2.22.2) + ostruct + globalid (1.2.1) + activesupport (>= 6.1) + good_job (4.10.2) + activejob (>= 6.1.0) + activerecord (>= 6.1.0) + concurrent-ruby (>= 1.3.1) + fugit (>= 1.11.0) + railties (>= 6.1.0) + thor (>= 1.0.0) + hashid-rails (1.4.1) + activerecord (>= 4.0) + hashids (~> 1.0) + hashids (1.0.6) + hashie (5.0.0) + honeybadger (5.28.0) + logger + ostruct + http (5.2.0) + addressable (~> 2.8) + base64 (~> 0.1) + http-cookie (~> 1.0) + http-form_data (~> 2.2) + llhttp-ffi (~> 0.5.0) + http-cookie (1.0.8) + domain_name (~> 0.5) + http-form_data (2.3.0) + i18n (1.14.7) + concurrent-ruby (~> 1.0) + image_processing (1.14.0) + mini_magick (>= 4.9.5, < 6) + ruby-vips (>= 2.0.17, < 3) + io-console (0.8.0) + irb (1.15.2) + pp (>= 0.6.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + jb (0.8.2) + jmespath (1.6.2) + json (2.12.0) + kaminari (1.2.2) + activesupport (>= 4.1.0) + kaminari-actionview (= 1.2.2) + kaminari-activerecord (= 1.2.2) + kaminari-core (= 1.2.2) + kaminari-actionview (1.2.2) + actionview + kaminari-core (= 1.2.2) + kaminari-activerecord (1.2.2) + activerecord + kaminari-core (= 1.2.2) + kaminari-core (1.2.2) + language_server-protocol (3.17.0.5) + launchy (3.1.1) + addressable (~> 2.8) + childprocess (~> 5.0) + logger (~> 1.6) + letter_opener (1.10.0) + launchy (>= 2.2, < 4) + letter_opener_web (3.0.0) + actionmailer (>= 6.1) + letter_opener (~> 1.9) + railties (>= 6.1) + rexml + lint_roller (1.1.0) + literal (1.7.1) + zeitwerk + llhttp-ffi (0.5.1) + ffi-compiler (~> 1.0) + rake (~> 13.0) + lockbox (2.0.1) + logger (1.7.0) + loofah (2.24.1) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + lz_string (0.3.0) + mail (2.8.1) + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.0.4) + mini-levenshtein (0.1.2) + mini_magick (5.2.0) + benchmark + logger + mini_mime (1.1.5) + minitest (5.25.5) + msgpack (1.8.0) + multipart-post (2.4.1) + mutex_m (0.3.0) + net-http (0.6.0) + uri + net-imap (0.5.8) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-smtp (0.5.1) + net-protocol + nio4r (2.7.4) + nokogiri (1.18.8-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.8-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.18.8-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.8-arm-linux-musl) + racc (~> 1.4) + nokogiri (1.18.8-arm64-darwin) + racc (~> 1.4) + nokogiri (1.18.8-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.18.8-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.8-x86_64-linux-musl) + racc (~> 1.4) + ostruct (0.6.1) + paper_trail (16.0.0) + activerecord (>= 6.1) + request_store (~> 1.4) + parallel (1.27.0) + parser (3.3.8.0) + ast (~> 2.4.1) + racc + pg (1.5.9) + phlex (2.2.1) + zeitwerk (~> 2.7) + phlex-rails (2.2.0) + phlex (~> 2.2.1) + railties (>= 7.1, < 9) + pp (0.6.2) + prettyprint + prettyprint (0.2.0) + prism (1.4.0) + propshaft (1.1.0) + actionpack (>= 7.0.0) + activesupport (>= 7.0.0) + rack + railties (>= 7.0.0) + psych (5.2.6) + date + stringio + public_activity (3.0.1) + actionpack (>= 6.1.0) + activerecord (>= 6.1) + i18n (>= 0.5.0) + railties (>= 6.1.0) + public_suffix (6.0.2) + puma (6.6.0) + nio4r (~> 2.0) + pundit (2.5.0) + activesupport (>= 3.0.0) + raabro (1.4.0) + racc (1.8.1) + rack (3.1.15) + rack-protection (4.1.1) + base64 (>= 0.1.0) + logger (>= 1.6.0) + rack (>= 3.0.0, < 4) + rack-proxy (0.7.7) + rack + rack-session (2.1.1) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.2.1) + rack (>= 3) + rails (8.0.2) + actioncable (= 8.0.2) + actionmailbox (= 8.0.2) + actionmailer (= 8.0.2) + actionpack (= 8.0.2) + actiontext (= 8.0.2) + actionview (= 8.0.2) + activejob (= 8.0.2) + activemodel (= 8.0.2) + activerecord (= 8.0.2) + activestorage (= 8.0.2) + activesupport (= 8.0.2) + bundler (>= 1.15.0) + railties (= 8.0.2) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.6.2) + loofah (~> 2.21) + 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) + rails_semantic_logger (4.17.0) + rack + railties (>= 5.1) + semantic_logger (~> 4.16) + railties (8.0.2) + actionpack (= 8.0.2) + activesupport (= 8.0.2) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + zeitwerk (~> 2.6) + rainbow (3.1.1) + rake (13.2.1) + rdoc (6.14.0) + erb + psych (>= 4.0.0) + redcarpet (3.6.1) + regexp_parser (2.10.0) + reline (0.6.1) + io-console (~> 0.5) + request_store (1.7.0) + rack (>= 1.4) + rexml (3.4.1) + rinku (2.0.6) + rouge (4.5.2) + rubocop (1.75.7) + 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.44.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.44.1) + parser (>= 3.3.7.2) + prism (~> 1.4) + rubocop-performance (1.25.0) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) + rubocop-rails (2.32.0) + activesupport (>= 4.2.0) + lint_roller (~> 1.1) + rack (>= 1.1) + rubocop (>= 1.75.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-progressbar (1.13.0) + ruby-vips (2.2.4) + ffi (~> 1.12) + logger + sanitize (7.0.0) + crass (~> 1.0.2) + nokogiri (>= 1.16.8) + securerandom (0.4.1) + semantic_logger (4.16.1) + concurrent-ruby (~> 1.0) + serve_byte_range (1.0.0) + rack (>= 1.0) + slack-ruby-client (2.6.0) + faraday (>= 2.0) + faraday-mashify + faraday-multipart + gli + hashie + logger + smart_properties (1.17.0) + stringio (3.1.7) + superform (0.5.1) + phlex-rails (>= 1.0, < 3.0) + zeitwerk (~> 2.6) + thor (1.3.2) + thruster (0.1.13) + thruster (0.1.13-aarch64-linux) + thruster (0.1.13-arm64-darwin) + thruster (0.1.13-x86_64-darwin) + thruster (0.1.13-x86_64-linux) + timeout (0.4.3) + turbo-rails (2.0.16) + actionpack (>= 7.1.0) + railties (>= 7.1.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unaccent (0.4.0) + unicode-display_width (3.1.4) + unicode-emoji (~> 4.0, >= 4.0.4) + unicode-emoji (4.0.4) + uri (1.0.3) + useragent (0.16.11) + valid_email2 (7.0.13) + activemodel (>= 6.0) + mail (~> 2.5) + vite_rails (3.0.19) + railties (>= 5.1, < 9) + vite_ruby (~> 3.0, >= 3.2.2) + vite_ruby (3.9.2) + dry-cli (>= 0.7, < 2) + logger (~> 1.6) + mutex_m + rack-proxy (~> 0.6, >= 0.6.1) + zeitwerk (~> 2.2) + web-console (4.2.1) + actionview (>= 6.0.0) + activemodel (>= 6.0.0) + bindex (>= 0.4.0) + railties (>= 6.0.0) + websocket-driver (0.7.7) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + wicked (2.0.0) + railties (>= 3.0.7) + zeitwerk (2.7.3) + +PLATFORMS + aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin + x86_64-darwin + x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + aasm (~> 5.5) + active_storage_encryption (~> 0.3.0) + acts_as_paranoid (~> 0.10.3) + annotaterb (~> 4.19) + audits1984 (~> 0.1.7) + awesome_print (~> 1.9) + aws-sdk-s3 (~> 1.189) + blind_index (~> 2.7) + bootsnap + brakeman + console1984 (~> 0.2.2) + countries (~> 7.1) + debug + doorkeeper (~> 5.8) + dotenv + erb_lint (~> 0.9.0) + faraday (~> 2.13) + flipper (~> 1.3) + flipper-active_record (~> 1.3) + flipper-ui (~> 1.3) + good_job (~> 4.10) + hashid-rails (~> 1.4) + honeybadger (~> 5.28) + http (~> 5.2) + image_processing (~> 1.2) + jb (~> 0.8.2) + kaminari (~> 1.2) + letter_opener_web (~> 3.0) + literal (~> 1.7) + lockbox (~> 2.0) + lz_string (~> 0.3.0) + mini-levenshtein (~> 0.1.2) + paper_trail (~> 16.0) + pg (~> 1.1) + phlex (~> 2.2) + phlex-rails (~> 2.2) + propshaft (~> 1.1) + public_activity (~> 3.0) + puma (>= 5.0) + pundit (~> 2.5) + rails (~> 8.0.2) + rails_semantic_logger (~> 4.17) + redcarpet (~> 3.6) + rubocop-rails-omakase + ruby-vips (~> 2.2) + slack-ruby-client (~> 2.6) + superform (~> 0.5.1) + thruster + tzinfo-data + valid_email2 (~> 7.0) + vite_rails + web-console + wicked (~> 2.0) + +BUNDLED WITH + 2.6.9 diff --git a/Procfile.dev b/Procfile.dev new file mode 100644 index 0000000..1acf605 --- /dev/null +++ b/Procfile.dev @@ -0,0 +1,3 @@ + +vite: bin/vite dev +web: bin/rails s diff --git a/README.md b/README.md new file mode 100644 index 0000000..3bc0db5 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# Identity Vault + +This is the Rails codebase powering https://identity.hackclub.com! + +## contributing: +poke [nora](https://hackclub.slack.com/team/U06QK6AG3RD)! +avoid questions that can be answered by reading the source code, but otherwise i'd be happy to help you get up to speed :-D + +kindly `bin/lint` your code before you submit it! +### areas of focus: +the ops view components (look in `app/components`) are a hot mess... + +so is the onboarding controller, she should really be ripped out and replaced. + +## dev setup: +- make sure you have working installations of ruby ≥ 3.4.4 & nodejs +- clone repo +- create .env.development, populate `DATABASE_URL` w/ a local postgres instance +- run `bundle install` +- run `rails db:prepare` +- console in (`bin/rails console`) + - `Backend::User.create!(slack_id: "U", username: "", active: true, super_admin: true)` +- run `bin/dev` (and `bin/vite dev` if you want hot reload on css & js) +- visit `http://localhost:3000/backend/login`, paste that Slack ID in, and "fake it til' you make it" + +## security + +this oughta go without saying, but if you find a security-relevant issue please either contact me directly or go through the security.hackclub.com flow – +if you just open an issue or a PR there's a chance a bad actor sees it and exploits it before we can patch or merge. \ No newline at end of file diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..9a5ea73 --- /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/stylesheets/application.css b/app/assets/stylesheets/application.css new file mode 100644 index 0000000..dcd7273 --- /dev/null +++ b/app/assets/stylesheets/application.css @@ -0,0 +1 @@ +/* Application styles */ diff --git a/app/components/api_example.rb b/app/components/api_example.rb new file mode 100644 index 0000000..9b0a74a --- /dev/null +++ b/app/components/api_example.rb @@ -0,0 +1,24 @@ +class Components::APIExample < Components::Base + extend Literal::Properties + prop :method, _Nilable(String) + prop :url, _Nilable(String) + prop :path_only, _Boolean? + + def view_template + div style: { margin: "10px 0" } do + code style: { background: "black", padding: "0.2em", color: "white" } do + span style: { color: "cyan" } do + @method + end + plain " " + copy_to_clipboard @url do + if @path_only + CGI.unescape(URI.parse(@url).tap { |u| u.host = u.scheme = u.port = nil }.to_s) + else + @url + end + end + end + end + end +end diff --git a/app/components/base.rb b/app/components/base.rb new file mode 100644 index 0000000..ae384ee --- /dev/null +++ b/app/components/base.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class Components::Base < Phlex::HTML + include Components + + # Include any helpers you want to be available across all components + include Phlex::Rails::Helpers::Routes + include Phlex::Rails::Helpers::FormWith + include Phlex::Rails::Helpers::DistanceOfTimeInWords + + # Register Rails form helpers + register_value_helper :form_authenticity_token + register_value_helper :dev_tool + register_output_helper :vite_image_tag + register_value_helper :ap + register_output_helper :copy_to_clipboard + + if Rails.env.development? + def before_template + comment { "Before #{self.class.name}" } + super + end + end +end diff --git a/app/components/bootleg_turbo.rb b/app/components/bootleg_turbo.rb new file mode 100644 index 0000000..917feed --- /dev/null +++ b/app/components/bootleg_turbo.rb @@ -0,0 +1,17 @@ +class Components::BootlegTurbo < Components::Base + def initialize(path, text: nil, **opts) + @path = path + @text = text + @opts = opts + end + + def view_template + div(hx_get: @path, hx_trigger: :load, **@opts) do + if @text + plain @text + br + end + vite_image_tag "images/loader.gif", class: :htmx_indicator, style: "image-rendering: pixelated;" + end + end +end diff --git a/app/components/brand.rb b/app/components/brand.rb new file mode 100644 index 0000000..c08037e --- /dev/null +++ b/app/components/brand.rb @@ -0,0 +1,37 @@ +class Components::Brand < Components::Base + def initialize(identity:) + @identity = identity + end + + def view_template + div(class: "brand") do + if @identity.present? + copy_to_clipboard @identity.public_id, tooltip_direction: "e", label: "click to copy your internal ID" do + logo + end + else + logo + end + h1 { "Hack Club Identity" } + end + button id: "lightswitch", class: "lightswitch-btn", type: "button", "aria-label": "Toggle theme" do + span class: "lightswitch-icon" do + "🌙" + end + end + case Rails.env + when "staging" + div(class: "banner purple") do + safe "this is a staging environment. do not upload any actual personal information here." + end + when "development" + div(class: "banner success") do + plain "you're in dev! go nuts :3" + end + end + end + + def logo + vite_image_tag "images/hc-square.png", alt: "Hack Club logo", class: "brand-logo" + end +end diff --git a/app/components/break_the_glass.rb b/app/components/break_the_glass.rb new file mode 100644 index 0000000..8a0566d --- /dev/null +++ b/app/components/break_the_glass.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class Components::BreakTheGlass < Components::Base + attr_reader :break_glassable, :auto_break_glass + + def initialize(break_glassable, auto_break_glass: nil) + @break_glassable = break_glassable + @auto_break_glass = auto_break_glass + end + + def view_template + if glass_broken? + yield if block_given? + else + render_break_the_glass + end + end + + private + + def glass_broken? + return false unless helpers.user_signed_in? + + # Check if a recent break glass record already exists + existing_record = BreakGlassRecord.for_user_and_document(helpers.current_user, @break_glassable) + .recent + .exists? + + return true if existing_record + + # If auto_break_glass is enabled, automatically create a break glass record + if @auto_break_glass + BreakGlassRecord.create!( + backend_user: helpers.current_user, + break_glassable: @break_glassable, + reason: @auto_break_glass, + accessed_at: Time.current, + automatic: true, + ) + return true + end + + false + end + + def render_break_the_glass + div do + render Components::BreakTheGlassForm.new(@break_glassable) + end + end +end diff --git a/app/components/break_the_glass_form.rb b/app/components/break_the_glass_form.rb new file mode 100644 index 0000000..20fce43 --- /dev/null +++ b/app/components/break_the_glass_form.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class Components::BreakTheGlassForm < Components::Base + def initialize(break_glassable) + @break_glassable = break_glassable + end + + def view_template + div(class: "break-glass-form", style: "padding: 2rem;") do + div(style: "margin-bottom: 1rem;") do + vite_image_tag "images/icons/break-the-glass.png", style: "width: 64px; image-rendering: pixelated;" + div(style: "display: inline-block; vertical-align: top; margin-left: 0.5em;") do + h1(style: "margin 0; display: inline-block; vertical-align: top;") { "Break the Glass" } + br + plain "This #{document_type} has already been reviewed." + br + plain "Please affirm that you have a legitimate need to view this #{document_type}." + end + end + + form_with url: "/backend/break_glass", method: :post, local: true, style: "max-width: 400px; margin: 0 auto;" do |form| + form.hidden_field :break_glassable_id, value: @break_glassable.id + form.hidden_field :break_glassable_type, value: @break_glassable.class.name + + div(style: "display: flex; align-items: center; gap: 0.5em;") do + p { "I'm accessing this #{document_type} " } + form.text_field :reason, placeholder: "because i'm investigating a fraud claim", style: "width: 30%;" + form.submit "i promise.", class: "button button-primary" + end + end + end + end + + private + + def document_type + case @break_glassable.class.name + when "Identity::Document" + "identity document" + when "Identity::AadhaarRecord" + "aadhaar record" + else + "document" + end + end +end diff --git a/app/components/footer.rb b/app/components/footer.rb new file mode 100644 index 0000000..f34f6e3 --- /dev/null +++ b/app/components/footer.rb @@ -0,0 +1,43 @@ +class Components::Footer < Components::Base + include Phlex::Rails::Helpers::TimeAgoInWords + + def view_template + footer(class: "app-footer") do + div(class: "footer-content") do + div(class: "footer-main") do + p(class: "app-name") { "Identity Vault" } + end + + div(class: "footer-version") do + div(class: "version-info") do + p do + plain "Build " + if git_version.present? + if commit_link.present? + a(href: commit_link, target: "_blank", class: "version-link") do + "v#{git_version}" + end + else + span(class: "version-text") { "v#{git_version}" } + end + end + plain " from #{time_ago_in_words(server_start_time)} ago" + end + end + end + + div(class: "environment-badge #{Rails.env.downcase}") do + Rails.env.upcase + end + end + end + end + + def git_version = Rails.application.config.try(:git_version) + + def commit_link = Rails.application.config.try(:commit_link) + + def server_start_time + Rails.application.config.try(:server_start_time) + end +end diff --git a/app/components/home_button.rb b/app/components/home_button.rb new file mode 100644 index 0000000..9871e2e --- /dev/null +++ b/app/components/home_button.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Components::HomeButton < Components::Base + def view_template + a href: root_path do + "← back to home" + end + end +end diff --git a/app/components/identity.rb b/app/components/identity.rb new file mode 100644 index 0000000..47fe3c0 --- /dev/null +++ b/app/components/identity.rb @@ -0,0 +1,50 @@ +class Components::Identity < Components::Base + attr_reader :identity + + def initialize(identity, show_legal_name: false) + @identity = identity + @show_legal_name = show_legal_name + end + + def field(label, value = nil) + b { "#{label}: " } + if block_given? + yield + else + span { value.to_s } + end + br + end + + def view_template + div do + render @identity + br + if @identity.legal_first_name.present? && @show_legal_name + field "Legal First Name", @identity.legal_first_name + field "Legal Last Name", @identity.legal_last_name + end + field "Country", @identity.country + field "Primary Email", @identity.primary_email + field "Birthday", @identity.birthday + field "Phone", @identity.phone_number + field "Verification status", @identity.verification_status.humanize + if defined?(@identity.ysws_eligible) && !@identity.ysws_eligible.nil? + field "YSWS eligible", @identity.ysws_eligible + end + + field "Slack ID" do + if identity.slack_id.present? + a(href: "https://hackclub.slack.com/team/#{identity.slack_id}") do + identity.slack_id + end + copy_to_clipboard(identity.slack_id) do + plain " (copy)" + end + else + plain "not set" + end + end + end + end +end diff --git a/app/components/identity_review/aadhaar_full.rb b/app/components/identity_review/aadhaar_full.rb new file mode 100644 index 0000000..18dc872 --- /dev/null +++ b/app/components/identity_review/aadhaar_full.rb @@ -0,0 +1,43 @@ +class Components::IdentityReview::AadhaarFull < Components::Base + def initialize(aadhaar_record) + @aadhaar_record = aadhaar_record + @data = @aadhaar_record.doc_json[:data] + end + + def field(key, name) + res = @data.dig(key) + return if res.blank? + li do + b { "#{name}: " } + plain res + end + end + + def view_template + h2 { "Full Aadhaar data:" } + br + + ul style: { list_style_type: "disc" } do + li do + b { "Photo:" } + br + img src: "data:image/jpeg;base64,#{@data[:photo]}", style: { width: "100px", margin_left: "1em" } + end + field :name, "Full name" + field :"Father Name", "Father's name" + field :dob, "Date of birth" + field :aadhar_number, "Aadhaar number" + field :gender, "Assigned gender" + field :co, "C/O" + li do + b { "Address:" } + ul style: { margin_left: "1rem", list_style_type: "square" } do + @data.dig(:address).each do |key, value| + next if value.blank? + li { b { "#{key}: " }; plain value } + end + end + end + end + end +end diff --git a/app/components/identity_review/aadhaar_info.rb b/app/components/identity_review/aadhaar_info.rb new file mode 100644 index 0000000..4e06e24 --- /dev/null +++ b/app/components/identity_review/aadhaar_info.rb @@ -0,0 +1,21 @@ +class Components::IdentityReview::AadhaarInfo < Components::Base + def initialize(verification) + @verification = verification + end + + def view_template + div class: "lowered padding" do + h2(style: "margin-top: 0;") { "Aadhaar Information" } + table style: "width: 100%;" do + tr do + td(style: "font-weight: bold; padding: 0.25rem 0;") { "Aadhaar Number:" } + td(style: "padding: 0.25rem 0;") { @verification.identity.aadhaar_number } + end + tr do + td(style: "font-weight: bold; padding: 0.25rem 0;") { "Uploaded:" } + td(style: "padding: 0.25rem 0;") { @verification.pending_at&.strftime("%B %d, %Y at %I:%M %p") || "N/A" } + end + end + end + end +end diff --git a/app/components/identity_review/basic_details.rb b/app/components/identity_review/basic_details.rb new file mode 100644 index 0000000..2208c22 --- /dev/null +++ b/app/components/identity_review/basic_details.rb @@ -0,0 +1,41 @@ +class Components::IdentityReview::BasicDetails < Components::Base + def initialize(identity) + @identity = identity + end + + def view_template + div class: "lowered padding" do + h2(style: "margin-top: 0;") { "Identity Information" } + table style: "width: 100%;" do + tr do + td(style: "font-weight: bold; padding: 0.25rem 0;") { "Name:" } + td(style: "padding: 0.25rem 0;") { render(@identity) } + end + if @identity.legal_first_name.present? + tr do + td(style: "font-weight: bold; padding: 0.25rem 0;") { "Legal Name:" } + td(style: "padding: 0.25rem 0;") { + "#{@identity.legal_first_name} #{@identity.legal_last_name}" + } + end + end + tr do + td(style: "font-weight: bold; padding: 0.25rem 0;") { "Email:" } + td(style: "padding: 0.25rem 0;") { @identity.primary_email } + end + tr do + td(style: "font-weight: bold; padding: 0.25rem 0;") { "Birthday:" } + td(style: "padding: 0.25rem 0;") { @identity.birthday.strftime("%B %d, %Y") } + end + tr do + td(style: "font-weight: bold; padding: 0.25rem 0;") { "Age:" } + td(style: "padding: 0.25rem 0;") { @identity.age.round(2) } + end + tr do + td(style: "font-weight: bold; padding: 0.25rem 0;") { "Country:" } + td(style: "padding: 0.25rem 0;") { @identity.country } + end + end + end + end +end diff --git a/app/components/identity_review/document_files.rb b/app/components/identity_review/document_files.rb new file mode 100644 index 0000000..e0c5ea8 --- /dev/null +++ b/app/components/identity_review/document_files.rb @@ -0,0 +1,118 @@ +class Components::IdentityReview::DocumentFiles < Components::Base + def initialize(document) + @document = document + end + + def view_template + h2(style: "margin-top: 0;") { "Document Files" } + + if @document.files.attached? + @document.files.each_with_index do |file, index| + div(style: "margin-bottom: 2rem;") do + h3 { "File #{index + 1}: #{file.filename}" } + + if file.content_type.start_with?("image/") + # Display image files (use variants for format conversion) + image_src = if file.content_type.in?(%w[image/heic image/heif]) + helpers.url_for(file.variant(format: :png)) + else + helpers.url_for(file) + end + + div(style: "position: relative; display: inline-block;") do + # Loader + div( + id: "loader-#{index}", + style: "display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 200px; border: 1px solid #ddd; border-radius: 4px; background: #f9f9f9;", + ) do + div(style: "text-align: center;") do + plain file.content_type.in?(%w[image/heic image/heif]) ? "Converting HEIC image..." : "Loading image..." + br + vite_image_tag "images/loader.gif", style: "image-rendering: pixelated; width: 32px; height: 32px;" + end + end + + # Image container (hidden until loaded) + div(id: "image-container-#{index}", style: "display: none;") do + img( + src: image_src, + alt: "Document file #{index + 1}", + style: "max-width: 100%; max-height: 600px; height: auto; border: 1px solid #ddd; border-radius: 4px; transition: transform 0.3s ease;", + id: "image-#{index}", + data: { rotation: 0 }, + onload: safe("document.getElementById('loader-#{index}').style.display='none'; document.getElementById('image-container-#{index}').style.display='block';"), + onerror: safe("document.getElementById('loader-#{index}').innerHTML='

Error loading image

';"), + ) + button( + type: "button", + class: "button", + style: "position: absolute; top: 10px; right: 10px;", + onclick: safe("rotateImage(#{index})"), + title: "Rotate image", + ) { "↻ Rotate" } + end + end + elsif file.content_type == "application/pdf" + # Display PDF files inline + div(style: "border: 1px solid #ddd; border-radius: 4px; background: #f9f9f9;") do + div(style: "padding: 1rem; border-bottom: 1px solid #ddd; background: #f5f5f5;") do + span(style: "font-weight: bold;") { "📄 #{file.filename}" } + a( + href: helpers.url_for(file), + target: "_blank", + style: "color: #0066cc; text-decoration: underline; margin-left: 1rem;", + ) { "Open in new tab" } + end + # Embed PDF using iframe + iframe( + src: helpers.url_for(file), + width: "100%", + height: "600", + style: "border: none; display: block;", + type: "application/pdf", + ) do + # Fallback for browsers that don't support PDF embedding + p(style: "padding: 2rem; text-align: center;") do + plain "Your browser doesn't support PDF embedding. " + a( + href: helpers.url_for(file), + target: "_blank", + style: "color: #0066cc; text-decoration: underline;", + ) { "Click here to view the PDF" } + end + end + end + else + # Display other file types + div(style: "border: 1px solid #ddd; border-radius: 4px; padding: 1rem; background: #f9f9f9;") do + p { "📁 File: #{file.filename}" } + p { "Type: #{file.content_type}" } + p do + a( + href: helpers.url_for(file), + target: "_blank", + style: "color: #0066cc; text-decoration: underline;", + ) { "Download File" } + end + end + end + end + end + else + p(style: "color: #666; font-style: italic;") { "No files attached to this document." } + end + + # Add JavaScript for image rotation + script do + raw safe <<~JAVASCRIPT + function rotateImage(index) { + const img = document.getElementById('image-' + index); + let currentRotation = parseInt(img.dataset.rotation || 0); + currentRotation = (currentRotation + 90); + img.dataset.rotation = currentRotation; + img.style.transform = 'rotate(' + currentRotation + 'deg)'; + } + JAVASCRIPT + end + end +end diff --git a/app/components/identity_review/document_info.rb b/app/components/identity_review/document_info.rb new file mode 100644 index 0000000..a5b4c17 --- /dev/null +++ b/app/components/identity_review/document_info.rb @@ -0,0 +1,39 @@ +class Components::IdentityReview::DocumentInfo < Components::Base + def initialize(verification) + @verification = verification + end + + def view_template + div class: "lowered padding" do + h2(style: "margin-top: 0;") { "Document Information" } + table style: "width: 100%;" do + tr do + td(style: "font-weight: bold; padding: 0.25rem 0;") { "Type:" } + td(style: "padding: 0.25rem 0;") { @verification.document_type } + end + tr do + td(style: "font-weight: bold; padding: 0.25rem 0;") { "Uploaded:" } + td(style: "padding: 0.25rem 0;") { @verification.identity_document.created_at.strftime("%B %d, %Y at %I:%M %p") } + end + if @verification.identity.country == "IN" + tr do + td(style: "font-weight: bold; padding: 0.25rem 0;") { "Suggested Aadhaar password:" } + td(style: "padding: 0.25rem 0;") { copy_to_clipboard(@verification.identity.suggested_aadhaar_password, tooltip_direction: "e") } + end + end + tr do + td(style: "font-weight: bold; padding: 0.25rem 0;") { "Status:" } + td(style: "padding: 0.25rem 0;") do + span class: (@verification.pending? ? "status-pending" : @verification.approved? ? "status-verified" : "status-rejected") do + @verification.status.humanize + end + end + end + tr do + td(style: "font-weight: bold; padding: 0.25rem 0;") { "Files:" } + td(style: "padding: 0.25rem 0;") { @verification.identity_document.files.count } + end + end + end + end +end diff --git a/app/components/inspector.rb b/app/components/inspector.rb new file mode 100644 index 0000000..91e3ed6 --- /dev/null +++ b/app/components/inspector.rb @@ -0,0 +1,22 @@ +class Components::Inspector < Components::Base + def initialize(record, small: false) + @record = record + @small = small + @id_line = "#{@record.class.name}#{" record" unless @small} #{@record&.try(:public_id) || @record&.id}" + end + + def view_template + return unless Rails.env.development? + + details(class: @small ? nil : "dev-tool") do + summary { "#{"Inspect" unless @small} #{@id_line}" } + pre class: %i[input readonly] do + unless @record.nil? + raw safe(ap @record) + else + "no record?" + end + end + end + end +end diff --git a/app/components/public_activity/container.rb b/app/components/public_activity/container.rb new file mode 100644 index 0000000..fac7196 --- /dev/null +++ b/app/components/public_activity/container.rb @@ -0,0 +1,23 @@ +class Components::PublicActivity::Container < Components::Base + register_value_helper :render_activities + + def initialize(activities) + @activities = activities + end + + def view_template + table class: %i[table detailed] do + thead do + tr do + th { "User" } + th { "Action" } + th { "Time" } + th { "Inspect" } if Rails.env.development? + end + end + tbody do + render_activities(@activities) + end + end + end +end diff --git a/app/components/public_activity/snippet.rb b/app/components/public_activity/snippet.rb new file mode 100644 index 0000000..27c0ffd --- /dev/null +++ b/app/components/public_activity/snippet.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class Components::PublicActivity::Snippet < Components::Base + def initialize(activity, owner: nil) + @activity = activity + @owner = owner + end + + def view_template + tr do + td { render @owner || @activity.owner } + td { yield } + td { @activity.created_at.strftime("%Y-%m-%d %H:%M:%S") } + td do + if Rails.env.development? + render Components::Inspector.new(@activity, small: true) + render Components::Inspector.new(@activity.trackable, small: true) + end + end + end + end +end diff --git a/app/components/resemblance.rb b/app/components/resemblance.rb new file mode 100644 index 0000000..8dafb09 --- /dev/null +++ b/app/components/resemblance.rb @@ -0,0 +1,15 @@ +class Components::Resemblance < Components::Base + attr_reader :resemblance + + def initialize(resemblance) + @resemblance = resemblance + end + + def view_template + div style: { border: "1px solid", padding: "10px", margin: "10px" } do + render @resemblance + render Components::Identity.new(@resemblance.past_identity) + render Components::Inspector.new(@resemblance) + end + end +end diff --git a/app/components/user_mention.rb b/app/components/user_mention.rb new file mode 100644 index 0000000..b5ed74b --- /dev/null +++ b/app/components/user_mention.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class Components::UserMention < Components::Base + extend Literal::Properties + + prop :user, _Union(Backend::User, ::Identity), :positional + + def view_template + div class: "icon", role: "option" do + case @user + when Backend::User + img src: @user.icon_url, width: "16px", class: "inline pr-2" + div class: "icon-label" do + a(href: backend_user_path(@user)) do + span { @user.username } + span { " ⚡" } if @user.super_admin? + end + end + when ::Identity + div(class: "inline pr-2") { "🪪" } + div class: "icon-label" do + a(href: backend_identity_path(@user)) { span { "#{@user.first_name} #{@user.last_name}" } } + end + end + end + end +end diff --git a/app/components/window.rb b/app/components/window.rb new file mode 100644 index 0000000..4c0fdfe --- /dev/null +++ b/app/components/window.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class Components::Window < Components::Base + extend Literal::Properties + + prop :window_title, String, :positional + prop :close_url, _Nilable(String) + prop :maximize_url, _Nilable(String) + prop :max_width, Integer, default: 400.freeze + + def view_template + div class: "window active", style: "max-width: #{@max_width}px" do + div class: "title-bar" do + div(class: "title-bar-text") { @window_title } + if @close_url || @maximize_url + div class: "title-bar-buttons" do + button(data_maximize: "", onclick: safe("window.location.href='#{@maximize_url}'")) if @maximize_url + button(data_close: "", onclick: safe("window.location.href='#{@close_url}'")) if @close_url + end + end + end + div class: "window-body" do + yield + end + end + end +end diff --git a/app/controllers/aadhaar_controller.rb b/app/controllers/aadhaar_controller.rb new file mode 100644 index 0000000..f6016ac --- /dev/null +++ b/app/controllers/aadhaar_controller.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class AadhaarController < ApplicationController + before_action :ensure_step, :set_verification + layout false + + def async_digilocker_link + begin + @verification.generate_link!( + callback_url: webhooks_aadhaar_callback_url( + Rails.application.credentials.dig(:aadhaar, :webhook_secret) + ), + redirect_url: submitted_onboarding_url, + ) unless @verification.aadhaar_external_transaction_id.present? + + render :digilocker_link + rescue StandardError => e + uuid = Honeybadger.notify(e) + response.set_header("HX-Retarget", "#async_flash") + render "shared/async_flash", locals: { f: { error: "error generating digilocker link – #{e.message} #{uuid}" } } + end + end + + def digilocker_redirect + redirect_to @verification.aadhaar_link, allow_other_host: true + end + + private + + def set_verification + @verification = current_identity.aadhaar_verifications.draft.first + end + + def ensure_step + render html: "🥐" unless current_identity&.onboarding_step == :aadhaar + + if current_identity&.verification_status == "ineligible" + redirect_to submitted_onboarding_path and return + end + end +end diff --git a/app/controllers/addresses_controller.rb b/app/controllers/addresses_controller.rb new file mode 100644 index 0000000..672a8bd --- /dev/null +++ b/app/controllers/addresses_controller.rb @@ -0,0 +1,81 @@ +class AddressesController < ApplicationController + include IsSneaky + before_action :set_address, only: [ :show, :edit, :update, :destroy ] + before_action :hide_some_data_away, only: %i[program_create_address] + def index + @addresses = current_identity.addresses + end + + def show + end + + def new + build_address + end + + def create + @address = current_identity.addresses.new(address_params) + if @address.save + if current_identity.primary_address.nil? + current_identity.update(primary_address: @address) + end + if params[:address][:from_program] == "true" + redirect_to safe_redirect_url("address_return_to") || addresses_path, notice: "address created successfully!", allow_other_host: true + else + redirect_to addresses_path, notice: "address created successfully!" + end + else + render params[:address][:from_program] == "true" ? :program_create_address : :new + end + end + + def program_create_address + build_address + end + + def edit + end + + def update + if params[:make_primary] == "true" + current_identity.update(primary_address: @address) + redirect_to addresses_path, notice: "Primary address updated!" + elsif @address.update(address_params) + redirect_to addresses_path, notice: "address updated successfully!" + else + render :edit + end + end + + def destroy + if current_identity.primary_address == @address + if Rails.env.production? + flash[:alert] = "can't delete your primary address..." + redirect_to addresses_path + return + else + current_identity.update(primary_address: nil) + end + end + @address.destroy + redirect_to addresses_path, notice: "address deleted successfully!" + end + + private + + def build_address + @address = current_identity.addresses.build( + country: current_identity.country, + first_name: current_identity.first_name, + last_name: current_identity.last_name, + ) + end + + def set_address + @address = current_identity.addresses.find(params[:id]) + end + + def address_params + params.require(:address).permit(:first_name, :last_name, :line_1, :line_2, :city, :state, :postal_code, :country) + end +end diff --git a/app/controllers/api/external/application_controller.rb b/app/controllers/api/external/application_controller.rb new file mode 100644 index 0000000..99069d6 --- /dev/null +++ b/app/controllers/api/external/application_controller.rb @@ -0,0 +1,6 @@ +module API + module External + class ApplicationController < ActionController::API + end + end +end diff --git a/app/controllers/api/external/identities_controller.rb b/app/controllers/api/external/identities_controller.rb new file mode 100644 index 0000000..3051701 --- /dev/null +++ b/app/controllers/api/external/identities_controller.rb @@ -0,0 +1,35 @@ +module API + module External + class IdentitiesController < ApplicationController + def check + ident = if (public_id = params[:idv_id]).present? + Identity.find_by_public_id(public_id) + elsif (primary_email = params[:email]).present? + Identity.find_by(primary_email:) + elsif (slack_id = params[:slack_id]).present? + Identity.find_by(slack_id:) + else + raise ActionController::ParameterMissing, "provide one of: idv_id, email, slack_id" + end + + result = if ident + case ident.verification_status + when "needs_submission", "pending" + ident.verification_status + when "verified" + ident.ysws_eligible? ? "verified_eligible" : "verified_but_over_18" + when "ineligible" + "rejected" + else + "unknown" + end + else + "not_found" + end + render json: { + result: + } + end + end + end +end diff --git a/app/controllers/api/v1/application_controller.rb b/app/controllers/api/v1/application_controller.rb new file mode 100644 index 0000000..cd77c96 --- /dev/null +++ b/app/controllers/api/v1/application_controller.rb @@ -0,0 +1,48 @@ +module API + module V1 + class ApplicationController < ActionController::API + prepend_view_path "app/views/api/v1" + + helper_method :current_identity, :current_program, :current_scopes, :acting_as_program + + attr_reader :current_identity + attr_reader :current_program + attr_reader :current_scopes + attr_reader :acting_as_program + + before_action :authenticate! + + include ActionController::HttpAuthentication::Token::ControllerMethods + + rescue_from Pundit::NotAuthorizedError do |e| + render json: { error: "not_authorized" }, status: :forbidden + end + + rescue_from ActionController::ParameterMissing do |e| + render json: { error: e.message }, status: :bad_request + end + + private + + def authenticate! + @current_token = authenticate_with_http_token do |t, _options| + OAuthToken.find_by(token: t) || Program.find_by(program_key: t) + end + unless @current_token&.active? + return render json: { error: "invalid_auth" }, status: :unauthorized + end + if @current_token.is_a?(OAuthToken) + @current_identity = @current_token.resource_owner + @current_program = @current_token.application + unless @current_program&.active? + return render json: { error: "invalid_auth" }, status: :unauthorized + end + else + @acting_as_program = true + @current_program = @current_token + end + @current_scopes = @current_program.scopes + end + end + end +end diff --git a/app/controllers/api/v1/hcb_controller.rb b/app/controllers/api/v1/hcb_controller.rb new file mode 100644 index 0000000..10a80df --- /dev/null +++ b/app/controllers/api/v1/hcb_controller.rb @@ -0,0 +1,11 @@ +module API + module V1 + class HCBController < ApplicationController + skip_before_action :authenticate! + + def show + render json: { pending: Verification.where(status: "pending").count } + end + end + end +end diff --git a/app/controllers/api/v1/health_check_controller.rb b/app/controllers/api/v1/health_check_controller.rb new file mode 100644 index 0000000..416608f --- /dev/null +++ b/app/controllers/api/v1/health_check_controller.rb @@ -0,0 +1,11 @@ +module API + module V1 + class HealthCheckController < ApplicationController + skip_before_action :authenticate! + def show + _ = Identity.last + render json: { message: "we're chillin'" } + end + end + end +end diff --git a/app/controllers/api/v1/identities_controller.rb b/app/controllers/api/v1/identities_controller.rb new file mode 100644 index 0000000..f0b24e8 --- /dev/null +++ b/app/controllers/api/v1/identities_controller.rb @@ -0,0 +1,42 @@ +module API + module V1 + class IdentitiesController < ApplicationController + def me + @identity = current_identity + raise ActiveRecord::RecordNotFound unless current_identity + render :me + end + + def show + raise Pundit::NotAuthorizedError unless acting_as_program + @identity = ident_scope.find_by_public_id!(params[:id]) + render :show + end + + def set_slack_id + raise Pundit::NotAuthorizedError unless acting_as_program && current_scopes.include?("set_slack_id") + @identity = ident_scope.find_by_public_id!(params[:id]) + + if @identity.slack_id.present? + return render json: { message: "slack already associated?" } + end + + @identity.update!(slack_id: params.require(:slack_id)) + @identity.create_activity(key: "identity.set_slack_id", owner: current_program) + render :show + end + + def index + raise Pundit::NotAuthorizedError unless acting_as_program + @identities = ident_scope.all + render :index + end + + private + + def ident_scope + current_program.identities + end + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb new file mode 100644 index 0000000..a8e4146 --- /dev/null +++ b/app/controllers/application_controller.rb @@ -0,0 +1,66 @@ +class ApplicationController < ActionController::Base + include PublicActivity::StoreController + include IsSneaky + + helper_method :current_identity, :identity_signed_in?, :current_onboarding_step + + before_action :invalidate_v1_sessions, :authenticate_identity!, :set_honeybadger_context + + before_action :set_paper_trail_whodunnit + + def current_identity + @current_identity ||= Identity.find_by(id: session[:identity_id]) if session[:identity_id] + end + + alias_method :user_for_public_activity, :current_identity + + def user_for_paper_trail = current_identity&.id + + def identity_signed_in? = !!current_identity + + + def invalidate_v1_sessions + if cookies["_identity_vault_session"] + cookies.delete("_identity_vault_session", + path: "/", + secure: Rails.env.production?, + httponly: true) + end + end + + def authenticate_identity! + unless identity_signed_in? + session[:oauth_return_to] = request.original_url unless request.xhr? + # JANK ALERT + hide_some_data_away + + # EW + return if controller_name == "onboardings" + + redirect_to welcome_onboarding_path + end + end + + def set_honeybadger_context + Honeybadger.context({ + identity_id: current_identity&.id + }) + end + + def current_onboarding_step + identity = current_identity + + return :basic_info unless identity&.persisted? + return :document unless identity.verifications.where(status: [ "approved", "pending" ]).any? + + :submitted + rescue => e + Rails.logger.error "Error determining onboarding step: #{e.message}" + :basic_info + end + + rescue_from ActiveRecord::RecordNotFound do |e| + flash[:error] = "sorry, couldn't find that object... (404)" + redirect_to root_path + end +end diff --git a/app/controllers/backend/application_controller.rb b/app/controllers/backend/application_controller.rb new file mode 100644 index 0000000..6a00c06 --- /dev/null +++ b/app/controllers/backend/application_controller.rb @@ -0,0 +1,60 @@ +module Backend + class ApplicationController < ActionController::Base + include PublicActivity::StoreController + include Pundit::Authorization + + layout "backend" + + after_action :verify_authorized + + helper_method :current_user, :user_signed_in? + + before_action :authenticate_user!, :set_honeybadger_context + + before_action :set_paper_trail_whodunnit + + def current_user + @current_user ||= User.find_by(id: session[:user_id]) if session[:user_id] + end + + def current_impersonator + @current_impersonator ||= User.find_by(id: session[:impersonator_user_id]) if session[:impersonator_user_id] + end + + alias_method :find_current_auditor, :current_user + alias_method :user_for_public_activity, :current_user + + def user_for_paper_trail = current_impersonator&.id || current_user&.id + def info_for_paper_trail = { extra_data: { ip: request.remote_ip, user_agent: request.user_agent, impersonating: !!current_impersonator, pretending_to_be: current_impersonator && current_user }.compact_blank } + + def user_signed_in? = !!current_user + + def authenticate_user! + unless user_signed_in? + return redirect_to backend_login_path, alert: ("you need to be logged in!") + end + unless @current_user&.active? + session[:user_id] = nil + @current_user = nil + redirect_to backend_login_path, alert: ("you need to be logged in!") + end + end + + def set_honeybadger_context + Honeybadger.context({ + user_id: current_user&.id, + user_username: current_user&.username + }) + end + + rescue_from Pundit::NotAuthorizedError do |e| + flash[:error] = "you don't seem to be authorized to do that?" + redirect_to backend_root_path + end + + rescue_from ActiveRecord::RecordNotFound do |e| + flash[:error] = "sorry, couldn't find that object... (404)" + redirect_to backend_root_path + end + end +end diff --git a/app/controllers/backend/audit_logs_controller.rb b/app/controllers/backend/audit_logs_controller.rb new file mode 100644 index 0000000..88546e6 --- /dev/null +++ b/app/controllers/backend/audit_logs_controller.rb @@ -0,0 +1,12 @@ +module Backend + class AuditLogsController < ApplicationController + skip_after_action :verify_authorized + + def index + scope = PublicActivity::Activity.order(created_at: :desc) + scope = scope.where(owner_type: "Backend::User") if params[:admin_actions_only] + + @activities = scope.page(params[:page]).per(50) + end + end +end diff --git a/app/controllers/backend/break_glass_controller.rb b/app/controllers/backend/break_glass_controller.rb new file mode 100644 index 0000000..5d42e36 --- /dev/null +++ b/app/controllers/backend/break_glass_controller.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class Backend::BreakGlassController < Backend::ApplicationController + def create + @break_glassable = find_break_glassable + + authorize BreakGlassRecord + + break_glass_record = BreakGlassRecord.new( + backend_user: current_user, + break_glassable: @break_glassable, + reason: params[:reason], + accessed_at: Time.current, + ) + + if break_glass_record.save + redirect_back(fallback_location: backend_root_path, notice: "Access granted. #{document_type.capitalize} is now visible.") + else + redirect_back(fallback_location: backend_root_path, alert: "Failed to grant access: #{break_glass_record.errors.full_messages.join(", ")}") + end + end + + private + + # it'd be neat if this was polymorphic + def find_break_glassable + case params[:break_glassable_type] + when "Identity::Document" + Identity::Document.find(params[:break_glassable_id]) + when "Identity::AadhaarRecord" + Identity::AadhaarRecord.find(params[:break_glassable_id]) + when "Identity" + Identity.find_by_public_id!(params[:break_glassable_id]) + else + raise ArgumentError, "Invalid break_glassable_type: #{params[:break_glassable_type]}" + end + end + + # TODO: these should be model methods! @break_glassable.try(:thing_name) || "item" + def document_type + case @break_glassable.class.name + when "Identity::Document" + "document" + when "Identity::AadhaarRecord" + "aadhaar record" + when "Identity" + "identity" + else + "item" + end + end +end diff --git a/app/controllers/backend/dashboard_controller.rb b/app/controllers/backend/dashboard_controller.rb new file mode 100644 index 0000000..a5c36b7 --- /dev/null +++ b/app/controllers/backend/dashboard_controller.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module Backend + class DashboardController < ApplicationController + # i really hope any of this math is right! + + def show + authorize Verification + + @time_period = params[:time_period] || "this_month" + @start_date = case @time_period + when "today" + Time.current.beginning_of_day + when "this_month" + Time.current.beginning_of_month + when "all_time" + Time.at(0) + end + + @verifications = Verification.not_ignored.where("created_at >= ?", @start_date) + + @stats = { + total: @verifications.count, + approved: @verifications.approved.count, + rejected: @verifications.rejected.count, + pending: @verifications.pending.count, + average_hangtime: calculate_average_hangtime(@verifications) + } + + # Calculate rejection reason breakdown + @rejection_breakdown = calculate_rejection_breakdown(@verifications.rejected) + + # Get leaderboard data + activity_counts = PublicActivity::Activity + .where(key: [ "verification.approve", "verification.reject" ]) + .where("activities.created_at >= ?", @start_date) + .where.not(owner_id: nil) + .group(:owner_id) + .count + .sort_by { |_, count| -count } + + user_ids = activity_counts.map(&:first) + users = Backend::User.where(id: user_ids).index_by(&:id) + + @leaderboard = activity_counts.map do |user_id, count| + { + user: users[user_id], + processed_count: count + } + end + end + + private + + def calculate_average_hangtime(verifications) + return "0 seconds" if verifications.empty? + + total_seconds = verifications.sum do |verification| + start_time = verification.pending_at || verification.created_at + end_time = verification.approved_at || verification.rejected_at || (verification.updated_at if verification.approved? || verification.rejected?) || Time.now + + end_time - start_time + end + + total_seconds / verifications.count + end + + # this should be less bad + def calculate_rejection_breakdown(rejected_verifications) + return {} if rejected_verifications.empty? + + breakdown = {} + + rejected_verifications.each do |verification| + next unless verification.rejection_reason.present? + + is_fatal = case verification.class.name + when "Verification::DocumentVerification" + Verification::DocumentVerification::FATAL_REJECTION_REASONS.include?(verification.rejection_reason) + when "Verification::AadhaarVerification" + Verification::AadhaarVerification::FATAL_REJECTION_REASONS.include?(verification.rejection_reason) + else + false + end + + reason_name = verification.try(:rejection_reason_name) || verification.rejection_reason.humanize + + breakdown[reason_name] ||= { count: 0, fatal: is_fatal } + breakdown[reason_name][:count] += 1 + end + + breakdown.sort_by { |_, data| -data[:count] }.to_h + end + end +end diff --git a/app/controllers/backend/identities_controller.rb b/app/controllers/backend/identities_controller.rb new file mode 100644 index 0000000..c54e431 --- /dev/null +++ b/app/controllers/backend/identities_controller.rb @@ -0,0 +1,132 @@ +module Backend + class IdentitiesController < ApplicationController + before_action :set_identity, except: [ :index ] + + def index + authorize Identity + + if (search = params[:search])&.start_with? "ident!" + ident = Identity.find_by_public_id(search) + return redirect_to backend_identity_path ident if ident.present? + end + + @identities = policy_scope(Identity) + .search(search&.sub("mailto:", "")) + .order(created_at: :desc) + .page(params[:page]) + .per(25) + end + + def show + authorize @identity + + if current_user.super_admin? || current_user.manual_document_verifier? + @available_scopes = [ "basic_info", "legal_name", "address" ] + elsif current_user.organized_programs.any? + organized_program_ids = current_user.organized_programs.pluck(:id) + + granted_tokens = @identity.access_tokens.where(application_id: organized_program_ids) + + @available_scopes = granted_tokens + .map { |token| token.scopes } + .flatten + .uniq + .reject(&:blank?) + else + @available_scopes = [ "basic_info" ] + end + + @verifications = @identity.verifications.includes(:identity_document).order(created_at: :desc) + + @addresses = @identity.addresses.order(created_at: :desc) + + @all_programs = @identity.all_programs.distinct + + identity_activities = @identity.activities.includes(:owner) + + verification_activities = PublicActivity::Activity + .where(trackable_type: "Verification", trackable_id: @identity.verifications.pluck(:id)) + .includes(:trackable, :owner) + + document_ids = @identity.documents.pluck(:id) + break_glass_record_ids = BreakGlassRecord.where(break_glassable_type: "Identity::Document", break_glassable_id: document_ids).pluck(:id) + break_glass_activities = PublicActivity::Activity + .where(trackable_type: "BreakGlassRecord", trackable_id: break_glass_record_ids) + .includes(:trackable, :owner) + + @activities = (identity_activities + verification_activities + break_glass_activities) + .sort_by(&:created_at).reverse + end + + def edit + authorize @identity, :edit? + end + + def update + authorize @identity, :update? + + if params[:reason].blank? + flash[:alert] = "Reason is required for identity updates" + render :edit and return + end + + if @identity.update(identity_params) + @identity.create_activity( + :admin_update, + owner: current_user, + parameters: { + reason: params[:reason], + changed_fields: @identity.previous_changes.except("updated_at").keys + }, + ) + + flash[:notice] = "Identity updated successfully" + redirect_to backend_identity_path(@identity) + else + render :edit + end + end + + def clear_slack_id + authorize @identity + + @identity.update!(slack_id: nil) + @identity.create_activity( + :clear_slack_id, + owner: current_user, + ) + flash[:notice] = "Slack ID cleared." + redirect_to backend_identity_path(@identity) + end + + def new_vouch + authorize Verification::VouchVerification, :create? + @vouch = @identity.vouch_verifications.build + end + + def create_vouch + authorize Verification::VouchVerification, :create? + @vouch = @identity.vouch_verifications.build(vouch_params) + if @vouch.save + flash[:notice] = "Vouch verification created successfully" + redirect_to backend_identity_path(@identity) + else + render :new_vouch + end + end + + private + + def set_identity + @identity = policy_scope(Identity).find_by_public_id!(params[:id]) + end + + def identity_params + params.require(:identity).permit(:first_name, :last_name, :legal_first_name, :legal_last_name, :primary_email, :phone_number, :birthday, :country, :hq_override, :ysws_eligible, :permabanned) + end + + def vouch_params + params.require(:verification_vouch_verification).permit(:evidence) + end + end +end diff --git a/app/controllers/backend/no_auth_controller.rb b/app/controllers/backend/no_auth_controller.rb new file mode 100644 index 0000000..d42631d --- /dev/null +++ b/app/controllers/backend/no_auth_controller.rb @@ -0,0 +1,5 @@ +module Backend + class NoAuthController < ApplicationController + skip_before_action :authenticate_user! + end +end diff --git a/app/controllers/backend/programs_controller.rb b/app/controllers/backend/programs_controller.rb new file mode 100644 index 0000000..4286193 --- /dev/null +++ b/app/controllers/backend/programs_controller.rb @@ -0,0 +1,77 @@ +class Backend::ProgramsController < Backend::ApplicationController + before_action :set_program, only: [ :show, :edit, :update, :destroy ] + + def index + authorize Program + @programs = policy_scope(Program).includes(:identities).order(:name) + end + + def show + authorize @program + @identities_count = @program.identities.distinct.count + end + + def new + @program = Program.new + authorize @program + end + + def create + @program = Program.new(program_params) + authorize @program + + if params[:oauth_application] && params[:oauth_application][:redirect_uri].present? + @program.redirect_uri = params[:oauth_application][:redirect_uri] + end + + if @program.save + redirect_to backend_program_path(@program), notice: "Program was successfully created." + else + render :new, status: :unprocessable_entity + end + end + + def edit + authorize @program + end + + def update + authorize @program + + if params[:oauth_application] && params[:oauth_application][:redirect_uri].present? + @program.redirect_uri = params[:oauth_application][:redirect_uri] + end + + if @program.update(program_params_for_user) + redirect_to backend_program_path(@program), notice: "Program was successfully updated." + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + authorize @program + @program.destroy + redirect_to backend_programs_path, notice: "Program was successfully deleted." + end + + private + + def set_program + @program = Program.find(params[:id]) + end + + def program_params + params.require(:program).permit(:name, :description, :active, scopes_array: []) + end + + def program_params_for_user + permitted_params = [ :name, :redirect_uri ] + + if policy(@program).update_scopes? + permitted_params += [ :description, :active, scopes_array: [] ] + end + + params.require(:program).permit(permitted_params) + end +end diff --git a/app/controllers/backend/sessions_controller.rb b/app/controllers/backend/sessions_controller.rb new file mode 100644 index 0000000..d084d1c --- /dev/null +++ b/app/controllers/backend/sessions_controller.rb @@ -0,0 +1,82 @@ +module Backend + class SessionsController < ApplicationController + skip_before_action :authenticate_user!, only: [ :new, :create, :fake_slack_callback_for_dev ] + + skip_after_action :verify_authorized + + def new + redirect_uri = url_for(action: :create, only_path: false) + redirect_to User.authorize_url(redirect_uri), + host: "https://slack.com", + allow_other_host: true + end + + def create + redirect_uri = url_for(action: :create, only_path: false) + + if params[:error].present? + uuid = Honeybadger.notify("Slack OAuth error: #{params[:error]}") + redirect_to backend_login_path, alert: "failed to authenticate with Slack! (error: #{uuid})" + return + end + + begin + @user = User.from_slack_token(params[:code], redirect_uri) + rescue => e + uuid = Honeybadger.notify(e) + redirect_to backend_login_path, alert: "error authenticating! (error: #{uuid})" + return + end + + if @user&.persisted? + session[:user_id] = @user.id + flash[:success] = "welcome aboard!" + redirect_to backend_root_path + else + redirect_to backend_login_path, alert: "you haven't been provisioned an account on this service yet – this attempt been logged." + end + end + + def fake_slack_callback_for_dev + unless Rails.env.development? + Honeybadger.notify("Fake Slack callback attempted in non-development environment. WTF?!") + redirect_to backend_root_path, alert: "this is only available in development mode." + return + end + + @user = User.find_by(slack_id: params[:slack_id], active: true) + if @user.nil? + redirect_to backend_root_path, alert: "dunno who that is, sorry." + return + end + + session[:user_id] = @user.id + redirect_to backend_root_path, notice: "welcome aboard!" + end + + def impersonate + unless current_user.superadmin? + redirect_to backend_root_path, alert: "you are not authorized to impersonate users. this incident has been reported :-P" + Honeybadger.notify("Impersonation attempt by #{current_user.username} to #{params[:id]}") + return + end + + session[:impersonator_user_id] ||= current_user.id + user = User.find(params[:id]) + session[:user_id] = user.id + flash[:success] = "hey #{user.username}! how's it going? nice 'stache and glasses!" + redirect_to backend_root_path + end + + def stop_impersonating + session[:user_id] = session[:impersonator_user_id] + session[:impersonator_user_id] = nil + redirect_to backend_root_path, notice: "welcome back, 007!" + end + + def destroy + session[:user_id] = nil + redirect_to backend_root_path, notice: "bye, see you next time!" + end + end +end diff --git a/app/controllers/backend/static_pages_controller.rb b/app/controllers/backend/static_pages_controller.rb new file mode 100644 index 0000000..3b85c8d --- /dev/null +++ b/app/controllers/backend/static_pages_controller.rb @@ -0,0 +1,19 @@ +module Backend + class StaticPagesController < ApplicationController + skip_before_action :authenticate_user!, only: [ :login ] + skip_after_action :verify_authorized + + def index + if current_user&.manual_document_verifier? || current_user&.super_admin? + @pending_verifications_count = Verification.where(status: "pending").count + end + end + + def login + end + + def session_dump + raise "can't do that!" if Rails.env.production? + end + end +end diff --git a/app/controllers/backend/users_controller.rb b/app/controllers/backend/users_controller.rb new file mode 100644 index 0000000..2f58a55 --- /dev/null +++ b/app/controllers/backend/users_controller.rb @@ -0,0 +1,73 @@ +module Backend + class UsersController < ApplicationController + before_action :set_user, except: [ :index, :new, :create ] + + def index + authorize Backend::User + @users = User.all + end + + def new + authorize User + @user = User.new + end + + def edit + authorize @user + end + + def update + authorize @user + @user.update!(user_params) + redirect_to backend_users_path, notice: "User updated!" + rescue => e + redirect_to backend_users_path, alert: e.message + end + + def create + authorize User + @user = User.new(new_user_params.merge(active: true)) + if @user.save + redirect_to backend_users_path, notice: "User created!" + else + render :new + end + end + + def show + authorize @user + end + + def activate + authorize @user + @user.activate! + flash[:success] = "User activated!" + redirect_to @user + end + + def deactivate + authorize @user + if @user == current_user + flash[:warning] = "i'm not sure that's a great idea..." + return redirect_to @user + end + @user.deactivate! + flash[:success] = "User deactivated." + redirect_to @user + end + + private + + def set_user + @user = User.find(params[:id]) + end + + def user_params + params.require(:backend_user).permit(:username, :icon_url, :all_fields_access, :human_endorser, :program_manager, :manual_document_verifier, :super_admin, organized_program_ids: []) + end + + def new_user_params + params.require(:backend_user).permit(:slack_id, :username, :all_fields_access, :human_endorser, :program_manager, :manual_document_verifier, :super_admin, organized_program_ids: []) + end + end +end diff --git a/app/controllers/backend/verifications_controller.rb b/app/controllers/backend/verifications_controller.rb new file mode 100644 index 0000000..97df772 --- /dev/null +++ b/app/controllers/backend/verifications_controller.rb @@ -0,0 +1,131 @@ +module Backend + class VerificationsController < ApplicationController + before_action :set_verification, only: [ :show, :approve, :reject, :ignore ] + + def index + authorize Verification + @recent_verifications = Verification.includes(:identity, :identity_document) + .where.not(status: "pending") + .order(updated_at: :desc) + .page(params[:page]) + .per(20) + end + + def pending + authorize Verification + @pending_verifications = Verification.includes(:identity, :identity_document, identity_document: { files_attachments: :blob }) + .where(status: "pending") + .order(created_at: :asc) + .page(params[:page]) + .per(20) + @average_hangtime = @pending_verifications.average("EXTRACT(EPOCH FROM (NOW() - COALESCE(verifications.pending_at, verifications.created_at)))").to_i if @pending_verifications.any? + end + + def show + authorize @verification + + # Fetch verification activities + verification_activities = @verification.activities.includes(:owner) + + # Fetch break glass activities efficiently with a single query + break_glass_activities = [] + unless @verification.is_a?(Verification::VouchVerification) + @relevant_object = @verification.identity_document || @verification.aadhaar_record + break_glass_record_ids = @relevant_object&.break_glass_records&.pluck(:id) || [] + break_glass_activities = PublicActivity::Activity + .where(trackable_type: "BreakGlassRecord", trackable_id: break_glass_record_ids) + .includes(:trackable, :owner) + end + @activities = (verification_activities + break_glass_activities).sort_by(&:created_at).reverse + end + + def approve + authorize @verification, :approve? + + @verification.approve! + + # Set YSWS eligibility if provided + if params[:ysws_eligible].present? + ysws_eligible = params[:ysws_eligible] == "true" + @verification.identity.update!(ysws_eligible: ysws_eligible) + + # Send appropriate mailer based on YSWS eligibility and adult program status + if ysws_eligible || @verification.identity.came_in_through_adult_program + VerificationMailer.approved(@verification).deliver_now + else + IdentityMailer.approved_but_ysws_ineligible(@verification.identity).deliver_now + Slack::NotifyGuardiansJob.perform_later(@verification.identity) + end + + eligibility_text = ysws_eligible ? "YSWS eligible" : "YSWS ineligible" + flash[:success] = "Document approved and marked as #{eligibility_text}!" + else + VerificationMailer.approved(@verification).deliver_now + flash[:success] = "Document approved successfully!" + end + + @verification.create_activity(key: "verification.approve", owner: current_user, parameters: { ysws_eligible: ysws_eligible }) + + redirect_to pending_backend_verifications_path + end + + def reject + authorize @verification, :reject? + + reason = params[:rejection_reason] + details = params[:rejection_reason_details] + internal_comment = params[:internal_rejection_comment] + + if reason.blank? + flash[:error] = "Rejection reason is required" + redirect_to backend_verification_path(@verification) + return + end + + @verification.mark_as_rejected!(reason, details) + @verification.internal_rejection_comment = internal_comment if internal_comment.present? + @verification.save! + + @verification.create_activity(key: "verification.reject", owner: current_user, parameters: { reason: reason, details: details, internal_comment: internal_comment }) + + flash[:success] = "Document rejected with feedback" + redirect_to pending_backend_verifications_path + end + + def ignore + authorize @verification, :ignore? + + if params[:reason].blank? + flash[:alert] = "Reason is required to ignore verification" + redirect_to backend_verification_path(@verification) and return + end + + @verification.update!( + ignored_at: Time.current, + ignored_reason: params[:reason], + ) + + @verification.create_activity( + :ignored, + owner: current_user, + parameters: { reason: params[:reason] }, + ) + + flash[:notice] = "Verification ignored successfully" + redirect_to backend_identity_path(@verification.identity) + end + + rescue_from AASM::InvalidTransition, with: :oops + + private + + def set_verification + @verification = Verification.includes(:identity, identity_document: :break_glass_records).find_by_public_id!(params[:id]) + end + + def oops + flash[:warning] = "This verification has already been processed?" + redirect_to pending_backend_verifications_path + end + end +end diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/controllers/concerns/is_sneaky.rb b/app/controllers/concerns/is_sneaky.rb new file mode 100644 index 0000000..b06f7ef --- /dev/null +++ b/app/controllers/concerns/is_sneaky.rb @@ -0,0 +1,34 @@ +module IsSneaky + extend ActiveSupport::Concern + + def hide_some_data_away + if params[:stash_data] + stash = begin + b64ed = Base64.urlsafe_decode64(params[:stash_data]).force_encoding("UTF-8") + unlzed = LZString::UTF16.decompress(b64ed) + JSON.parse(unlzed) + rescue StandardError + {} + end + request.reset_session if stash["invalidate_session"] + session[:stashed_data] = stash + end + end + + def safe_redirect_url(key) + return unless session[:stashed_data]&.[](key) + redirect_url = session[:stashed_data][key] + redirect_domain = URI.parse(redirect_url).host rescue nil + return unless redirect_domain + allowed_domains = Program.pluck(:redirect_uri).flat_map { |uri| + uri.split("\n").map { |u| URI.parse(u).host rescue nil } + }.compact.uniq + allowed_domains << "localhost" unless Rails.env.production? + + if allowed_domains.include?(redirect_domain) + redirect_url + else + nil + end + end +end diff --git a/app/controllers/onboardings_controller.rb b/app/controllers/onboardings_controller.rb new file mode 100644 index 0000000..65da0e6 --- /dev/null +++ b/app/controllers/onboardings_controller.rb @@ -0,0 +1,245 @@ +# HERE BE DRAGONS. +# this controller sucks! +# replace this with zombocom/wicked or something, this is a terrible way to do a wizard +class OnboardingsController < ApplicationController + skip_before_action :authenticate_identity!, only: [ :show, :welcome, :signin, :basic_info, :create_basic_info ] + before_action :ensure_correct_step, except: [ :show, :create_basic_info, :create_document, :submit_aadhaar, :address, :create_address, :signin, :continue, :submitted ] + before_action :set_identity, except: [ :show, :welcome, :signin, :basic_info, :create_basic_info ] + before_action :ensure_aadhaar_makes_sense, only: [ :aadhaar, :submit_aadhaar, :aadhaar_step_2 ] + + ONBOARDING_STEPS = %w[welcome basic_info document aadhaar address submitted].freeze + + def show + redirect_to determine_current_step + end + + def welcome + end + + def signin + flash[:warning] = nil + redirect_to new_sessions_path + end + + def basic_info + @identity = current_identity || Identity.new + end + + def create_basic_info + return redirect_to_current_step if current_identity&.persisted? + params[:identity]&.[](:primary_email)&.downcase! + + existing_identity = Identity.find_by(primary_email: params.dig(:identity, :primary_email)) + if existing_identity + session[:sign_in_email] = existing_identity.primary_email + flash[:warning] = "An account with this email already exists. Sign in here if it's yours.".html_safe + @identity = Identity.new(basic_info_params) + render :basic_info, status: :unprocessable_entity + return + end + + @identity = Identity.new(basic_info_params) + + if @identity.save + session[:identity_id] = @identity.id + redirect_to_current_step + else + render :basic_info, status: :unprocessable_entity + end + end + + def document + if @identity.verification_status == "ineligible" + redirect_to submitted_onboarding_path and return + end + + @document = @identity.documents.build + @is_resubmission = resubmission_scenario? + @rejected_verifications = rejected_verifications_for_resubmission if @is_resubmission + end + + def create_document + return redirect_to_basic_info_onboarding_path unless @identity + + if @identity.verification_status == "ineligible" + redirect_to submitted_onboarding_path and return + end + + @document = @identity.documents.build(document_params) + + if create_document_and_verification + VerificationMailer.created(@verification).deliver_later + Identity::NoticeResemblancesJob.perform_later(@identity) + redirect_to_current_step + else + @is_resubmission = resubmission_scenario? + @rejected_verifications = rejected_verifications_for_resubmission if @is_resubmission + set_default_document_type + render :document, status: :unprocessable_entity + end + end + + def aadhaar + if @identity.verification_status == "ineligible" + redirect_to submitted_onboarding_path and return + end + end + + def submit_aadhaar + if @identity.verification_status == "ineligible" + redirect_to submitted_onboarding_path and return + end + + if aadhaar_params[:aadhaar_number].present? && Identity.where(aadhaar_number: aadhaar_params[:aadhaar_number]).where.not(id: current_identity.id).exists? + flash[:warning] = "An account with this Aadhaar number already exists. Sign in here if it's yours.".html_safe + render :aadhaar, status: :unprocessable_entity + return + end + + Rails.logger.info "Updating identity with Aadhaar number: #{aadhaar_params.inspect}" + + begin + @identity.update!(aadhaar_params) + @aadhaar_verification = @identity.aadhaar_verifications.create! + redirect_to_current_step + rescue StandardError => e + uuid = Honeybadger.notify(e) + Rails.logger.error "Aadhaar update failed with errors: #{e.message} (report error ID: #{uuid})" + render :aadhaar, status: :unprocessable_entity + end + end + + def aadhaar_step_2 + if @identity.verification_status == "ineligible" + redirect_to submitted_onboarding_path and return + end + + @verification = @identity.aadhaar_verifications.draft.first + end + + def address + @address = @identity.addresses.build + @address.first_name = @identity.first_name + @address.last_name = @identity.last_name + @address.country = @identity.country + end + + def create_address + return redirect_to_basic_info_onboarding_path unless @identity + + @address = @identity.addresses.build(address_params) + + if @address.save + if @identity.primary_address.nil? + @identity.update!(primary_address: @address) + end + redirect_to_current_step + else + render :address, status: :unprocessable_entity + end + end + + def submitted + if session[:oauth_return_to] + redirect_to continue_onboarding_path + return + end + + @documents = @identity.documents.includes(:verifications) + end + + def continue + return_path = session[:oauth_return_to] || root_path + session[:oauth_return_to] = nil + redirect_to return_path, allow_other_host: true + end + + private + + def set_identity + @identity = current_identity + redirect_to basic_info_onboarding_path unless @identity&.persisted? + end + + def ensure_correct_step + correct_step_path = determine_current_step + + return if request.path == correct_step_path + + Rails.logger.info "Onboarding step redirect: #{request.path} -> #{correct_step_path}" + redirect_to correct_step_path + end + + def redirect_to_current_step + redirect_to determine_current_step + end + + def ensure_aadhaar_makes_sense + redirect_to_current_step unless Flipper.enabled?(:integrated_aadhaar_2025_07_10, @identity) && @identity&.country == "IN" + end + + # disgusting disgusting disgusting + def determine_current_step + identity = current_identity + + unless identity&.persisted? + return basic_info_onboarding_path if request.path == basic_info_onboarding_path + return welcome_onboarding_path + end + + identity.onboarding_redirect_path + rescue StandardError => e + Rails.logger.error "Onboarding step determination failed: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + welcome_onboarding_path + end + + def resubmission_scenario? = @identity.in_resubmission_flow? + + def rejected_verifications_for_resubmission = @identity.rejected_verifications_for_context + + def create_document_and_verification + return false unless @document.save + + @verification = @identity.document_verifications.build( + identity_document: @document, + ) + + unless @verification.save + Rails.logger.error "Verification creation failed: #{@verification.errors.full_messages}" + @document.errors.add(:base, "Unable to create verification: #{@verification.errors.full_messages.join(", ")}") + return false + end + + true + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error "Document creation failed: #{e.message}" + @document.errors.add(:base, "Unable to save document: #{e.message}") + false + rescue StandardError => e + Rails.logger.error "Unexpected error creating document: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + Honeybadger.notify(e) + @document.errors.add(:base, "An unexpected error occurred. Please try again.") + false + end + + def set_default_document_type + return if @document.document_type.present? + + @document.document_type = Identity::Document.selectable_types_for_country(@identity.country)&.first + end + + def basic_info_params + params.require(:identity).permit( + :first_name, :last_name, :legal_first_name, :legal_last_name, + :country, :primary_email, :slack_id, :birthday, :phone_number + ) + end + + def document_params = params.require(:identity_document).permit(:document_type, files: []) + + def aadhaar_params = params.require(:identity).permit(:aadhaar_number) + + def address_params = params.require(:address).permit(:first_name, :last_name, :line_1, :line_2, :city, :state, :postal_code, :country) +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 0000000..d385050 --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,116 @@ +class SessionsController < ApplicationController + skip_before_action :authenticate_identity!, only: [ :new, :create, :check_your_email, :verify, :confirm ] + + def new + end + + def create + params[:email]&.downcase! + @identity = Identity.find_by(primary_email: params[:email]) + + if @identity + return_url = params[:return_url] || session[:oauth_return_to] + + @login_code = Identity::LoginCode.generate(@identity, return_url: return_url) + + if Rails.env.production? + IdentityMailer.login_code(@login_code).deliver_later + else + IdentityMailer.login_code(@login_code).deliver_now + end + + redirect_to check_your_email_sessions_path, notice: "Login code sent to #{@identity.primary_email}" + else + flash[:info] = "we don't seem to have that email on file – let's get you on board!" + session[:stashed_data] ||= {} + session[:stashed_data]["prefill"] ||= {} + session[:stashed_data]["prefill"]["email"] = params[:email] + redirect_to basic_info_onboarding_path + end + end + + def check_your_email + end + + def verify + token = params[:token] + + unless token + redirect_to check_your_email_sessions_path, alert: "No login token provided." + return + end + + @login_code = Identity::LoginCode.valid.find_by(token: token) + + if @login_code + else + redirect_to new_sessions_path, alert: "Invalid or expired login link." + end + end + + def confirm + token = params[:token] + + unless token + redirect_to new_sessions_path, alert: "No login token provided." + return + end + + @login_code = Identity::LoginCode.valid.find_by(token: token) + + if @login_code + @login_code.mark_used! + + session[:identity_id] = @login_code.identity.id + + redirect_path = determine_redirect_after_login(@login_code) + flash[:success] = "You're in!" + redirect_to redirect_path + else + redirect_to new_sessions_path, alert: "Invalid or expired login link." + end + end + + def destroy + session[:identity_id] = nil + redirect_to root_path, notice: "Successfully signed out" + end + + private + + def deliver_login_code(login_code) + login_link = verify_sessions_url(token: login_code.token) + Rails.logger.info "LOGIN LINK for #{login_code.identity.primary_email}:" + Rails.logger.info login_link + Rails.logger.info "Token: #{login_code.token}" + end + + def determine_redirect_after_login(login_code) + if login_code.return_url.present? && safe_return_url?(login_code.return_url) + return login_code.return_url + end + + identity = login_code.identity + + if identity.verification_status != "verified" + determine_onboarding_step(identity) + else + session[:oauth_return_to] || root_path + end + end + + def safe_return_url?(url) + return false if url.blank? + + begin + uri = URI.parse(url) + uri.relative? || uri.host == request.host + rescue URI::InvalidURIError + false + end + end + + def determine_onboarding_step(identity) + identity.onboarding_redirect_path + end +end diff --git a/app/controllers/slack_accounts_controller.rb b/app/controllers/slack_accounts_controller.rb new file mode 100644 index 0000000..1ff5930 --- /dev/null +++ b/app/controllers/slack_accounts_controller.rb @@ -0,0 +1,37 @@ +class SlackAccountsController < ApplicationController + before_action :authenticate_identity! + + def new + redirect_uri = url_for(action: :create, only_path: false) + Rails.logger.info "Starting Slack OAuth flow for account linking with redirect URI: #{redirect_uri}" + redirect_to Identity.slack_authorize_url(redirect_uri), + host: "https://slack.com", + allow_other_host: true + end + + def create + redirect_uri = url_for(action: :create, only_path: false) + + if params[:error].present? + Rails.logger.error "Slack OAuth error: #{params[:error]}" + uuid = Honeybadger.notify("Slack OAuth error: #{params[:error]}") + redirect_to root_path, alert: "failed to link Slack account! (error: #{uuid})" + return + end + + begin + result = Identity.link_slack_account(params[:code], redirect_uri, current_identity) + + if result[:success] + Rails.logger.info "Successfully linked Slack account #{result[:slack_id]} to Identity #{current_identity.id}" + redirect_to root_path, notice: "Successfully linked your Slack account!" + else + redirect_to root_path, alert: result[:error] + end + rescue => e + Rails.logger.error "Error linking Slack account: #{e.message}" + uuid = Honeybadger.notify(e) + redirect_to root_path, alert: "error linking Slack account! (error: #{uuid})" + end + end +end diff --git a/app/controllers/static_pages_controller.rb b/app/controllers/static_pages_controller.rb new file mode 100644 index 0000000..be9c5ab --- /dev/null +++ b/app/controllers/static_pages_controller.rb @@ -0,0 +1,13 @@ +class StaticPagesController < ApplicationController + skip_before_action :authenticate_identity!, only: [ :faq, :external_api_docs ] + + def index + end + + def faq + end + + def external_api_docs + render :external_api_docs, layout: "backend" + end +end diff --git a/app/controllers/webhooks/aadhaar_controller.rb b/app/controllers/webhooks/aadhaar_controller.rb new file mode 100644 index 0000000..e306389 --- /dev/null +++ b/app/controllers/webhooks/aadhaar_controller.rb @@ -0,0 +1,73 @@ +module Webhooks + class AadhaarController < ApplicationController + def create + Honeybadger.context({ + tags: "webhook, aadhaar" + }) + + unless params[:secret_key] == Rails.application.credentials.aadhaar.webhook_secret + Honeybadger.notify("Aadhaar webhook: invalid secret key :-/") + return render json: { error: "aw hell nah" }, status: :unauthorized + end + + data = params[:response_data] + unless data + Honeybadger.notify("Aadhaar webhook: invalid data :-O") + return render json: { error: "???" }, status: :unauthorized + end + + data = Base64.decode64(data) + data = JSON.parse(data, symbolize_names: true) + + raise "unknown digilocker status API status!" unless data[:status] == 1 + + data[:data].each_pair do |tx_id, tx_data| + aadhaar_verification = Verification::AadhaarVerification.find_by(aadhaar_external_transaction_id: tx_id) + + unless aadhaar_verification + Honeybadger.notify("Aadhaar webhook: no verification found for tx_id #{tx_id}") + next + end + + if tx_data[:final_status] == "Denied" + aadhaar_verification.mark_as_rejected!("service_unavailable", "Verification denied by Aadhaar service") + next + end + + unless tx_data[:final_status] == "Completed" + Honeybadger.notify("Aadhaar webhook: verification #{tx_id} not completed") + next + end + + aadhaar_doc = tx_data[:msg].first { |doc| doc[:doc_type] == "ADHAR" } + + unless aadhaar_doc + Honeybadger.notify("Aadhaar webhook: no Aadhaar document found for tx_id #{tx_id}???") + next + end + + aadhaar_data = aadhaar_doc[:data] + + aadhaar_verification.create_aadhaar_record!( + identity: aadhaar_verification.identity, + date_of_birth: Date.parse(aadhaar_data[:dob]), + name: aadhaar_data[:name], + raw_json_response: aadhaar_doc.to_json, + ) + + aadhaar_verification.create_activity( + "data_received", + owner: aadhaar_verification.identity, + ) + + aadhaar_verification.mark_pending! + end + + render json: { message: "thanks!" }, status: :ok + rescue StandardError => e + raise + Honeybadger.notify(e) + render json: { error: "???" }, status: :internal_server_error + end + end +end diff --git a/app/controllers/webhooks/application_controller.rb b/app/controllers/webhooks/application_controller.rb new file mode 100644 index 0000000..1008699 --- /dev/null +++ b/app/controllers/webhooks/application_controller.rb @@ -0,0 +1,4 @@ +module Webhooks + class ApplicationController < ActionController::API + end +end diff --git a/app/frontend/entrypoints/application.css b/app/frontend/entrypoints/application.css new file mode 100644 index 0000000..2a1a755 --- /dev/null +++ b/app/frontend/entrypoints/application.css @@ -0,0 +1,5 @@ +@import "../stylesheets/application.scss"; + +body { + font-family: var(--preferred-font), system-ui; +} \ No newline at end of file diff --git a/app/frontend/entrypoints/application.js b/app/frontend/entrypoints/application.js new file mode 100644 index 0000000..d81c475 --- /dev/null +++ b/app/frontend/entrypoints/application.js @@ -0,0 +1,5 @@ +import "../js/alpine.js"; +import "../js/lightswitch.js"; +import "../js/click-to-copy"; +import htmx from "htmx.org" +window.htmx = htmx \ No newline at end of file diff --git a/app/frontend/entrypoints/backend.css b/app/frontend/entrypoints/backend.css new file mode 100644 index 0000000..1e9e29c --- /dev/null +++ b/app/frontend/entrypoints/backend.css @@ -0,0 +1,8 @@ +@import "../stylesheets/backend.scss"; + +@import "../stylesheets/os9.css"; +@import "../stylesheets/layout.css"; + +body { + font-family: var(--preferred-font), system-ui; +} \ No newline at end of file diff --git a/app/frontend/entrypoints/backend.js b/app/frontend/entrypoints/backend.js new file mode 100644 index 0000000..687e1c3 --- /dev/null +++ b/app/frontend/entrypoints/backend.js @@ -0,0 +1 @@ +import "../js/click-to-copy"; \ No newline at end of file diff --git a/app/frontend/entrypoints/direct_upload.js b/app/frontend/entrypoints/direct_upload.js new file mode 100644 index 0000000..6e2008e --- /dev/null +++ b/app/frontend/entrypoints/direct_upload.js @@ -0,0 +1,2 @@ +import * as ActiveStorage from "@rails/activestorage" +ActiveStorage.start() \ No newline at end of file diff --git a/app/frontend/images/.keep b/app/frontend/images/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/frontend/images/hc-square.png b/app/frontend/images/hc-square.png new file mode 100644 index 0000000000000000000000000000000000000000..6823ccf7fac7beacb5cd16651cbfd8d481f236fe GIT binary patch literal 22225 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

&JUljA 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 literal 0 HcmV?d00001 diff --git a/app/frontend/js/alpine.js b/app/frontend/js/alpine.js new file mode 100644 index 0000000..73757b1 --- /dev/null +++ b/app/frontend/js/alpine.js @@ -0,0 +1,3 @@ +import Alpine from 'alpinejs' +window.Alpine = Alpine +Alpine.start() \ No newline at end of file diff --git a/app/frontend/js/click-to-copy.js b/app/frontend/js/click-to-copy.js new file mode 100644 index 0000000..94690dd --- /dev/null +++ b/app/frontend/js/click-to-copy.js @@ -0,0 +1,21 @@ +import $ from "jquery"; + +$('[data-copy-to-clipboard]').on('click', async function(e) { + const element = e.currentTarget; + const textToCopy = element.getAttribute('data-copy-to-clipboard'); + + try { + await navigator.clipboard.writeText(textToCopy); + + if (element.hasAttribute('aria-label')) { + const previousLabel = element.getAttribute('aria-label'); + element.setAttribute('aria-label', 'copied!'); + + setTimeout(() => { + element.setAttribute('aria-label', previousLabel); + }, 1000); + } + } catch (err) { + console.error('Failed to copy text: ', err); + } +}); diff --git a/app/frontend/js/lightswitch.js b/app/frontend/js/lightswitch.js new file mode 100644 index 0000000..5053a1d --- /dev/null +++ b/app/frontend/js/lightswitch.js @@ -0,0 +1,23 @@ +// Get the current theme that was already set in the head +const savedTheme = localStorage.getItem("theme") || "light"; + +function updateIcon(theme) { + const icon = document.querySelector(".lightswitch-icon"); + if (icon) { + icon.textContent = theme === "dark" ? "💡" : "🌙"; + } +} + +// Set initial icon and show button after theme is set +updateIcon(savedTheme); +const lightswitchBtn = document.getElementById("lightswitch"); +if (lightswitchBtn) { + lightswitchBtn.classList.add("theme-loaded"); +} + +document.getElementById("lightswitch").addEventListener("click", () => { + const theme = document.body.parentElement.dataset.theme === "dark" ? "light" : "dark"; + document.body.parentElement.dataset.theme = theme; + localStorage.setItem("theme", theme); + updateIcon(theme); +}); \ No newline at end of file diff --git a/app/frontend/stylesheets/application.scss b/app/frontend/stylesheets/application.scss new file mode 100644 index 0000000..c266d1f --- /dev/null +++ b/app/frontend/stylesheets/application.scss @@ -0,0 +1,29 @@ +// $theme-color: "amber"; +$theme-color: "lime"; +@import "@picocss/pico/scss/pico"; + +:root { + --pico-font-family-sans-serif: Inter, system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, Helvetica, Arial, "Helvetica Neue", sans-serif, var(--pico-font-family-emoji) !important; + --pico-font-size: 16px !important; + /* Original: 100% */ + --pico-line-height: 1.25 !important; + /* Original: 1.5 */ + --pico-form-element-spacing-vertical: 0.5rem !important; + /* Original: 1rem */ + --pico-form-element-spacing-horizontal: 1.0rem !important; + /* Original: 1.25rem */ + --pico-border-radius: 0.375rem !important; + /* Original: 0.25rem */ +} + +[x-cloak] { display: none !important; } + +@import "./colors.css"; +@import "./snippets/banners.scss"; +@import "./snippets/admin_tools.scss"; +@import "./snippets/forms.scss"; +@import "./snippets/brand.scss"; +@import "./snippets/borders.scss"; +@import "./snippets/lightswitch.scss"; +@import "./snippets/tooltips.scss"; +@import "./snippets/footer.scss"; \ No newline at end of file diff --git a/app/frontend/stylesheets/backend.scss b/app/frontend/stylesheets/backend.scss new file mode 100644 index 0000000..d44690b --- /dev/null +++ b/app/frontend/stylesheets/backend.scss @@ -0,0 +1,36 @@ +@import "./colors.css"; +@import "./snippets/banners.scss"; +@import "./snippets/admin_tools.scss"; +@import "./snippets/borders.scss"; +@import "./snippets/tooltips.scss"; + +body { + font-size: 12px; + margin: 5rem +} + +form { + grid-template-columns: max-content auto; +} + +a { + text-decoration: none; +} +.link { + color: blue; + text-decoration: underline; +} + +.identity-link { + color: blue; + text-decoration: underline; +} + + +ul { + list-style: square; list-style-position: inside; +} + +.pointer { + cursor: pointer; +} \ No newline at end of file diff --git a/app/frontend/stylesheets/colors.css b/app/frontend/stylesheets/colors.css new file mode 100644 index 0000000..ae7a8e6 --- /dev/null +++ b/app/frontend/stylesheets/colors.css @@ -0,0 +1,78 @@ +:root { + --error-bg: #fbf2f4; + --error-border: #eab4bc; + --error-fg: #b9031f; + --error-fg-strong: #78202e; + + --warning-bg: #fffcf2; + --warning-border: #ffe69b; + --warning-fg: #ffc107; + --warning-fg-strong: #6a311c; + + --success-bg: #f3f8f5; + --success-border: #a1caad; + --success-fg: #147b33; + --success-fg-strong: #285a37; + + --info-bg: #f2f7fb; + --info-bg-selected: #cce0f1; + --info-border: #b2d1ea; + --info-fg: #0067b9; + --info-fg-strong: #1f5077; + + --quote-fg-1: #2b497d; + --quote-bg-1: #e8ecf2; + --quote-fg-2: #1d3e0f; + --quote-bg-2: #e4f1df; + --quote-fg-3: #5c0a0a; + --quote-bg-3: #f7d4d4; + --quote-fg-4: #472215; + --quote-bg-4: #dbbeb3; + --quote-fg-5: #335f61; + --quote-bg-5: #e0e6e6; + --purple-bg: #f7f3fb; + --purple-border: #c4a3d4; + --purple-fg: #7c3aed; + --purple-fg-strong: #4c1d95; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --error-bg: #3b1017; + --error-border: #8b2535; + --error-fg: #dc818f; + --error-fg-strong: #e39aa5; + + --warning-bg: #33290b; + --warning-border: #7f661c; + --warning-fg: #ffe083; + --warning-fg-strong: #ffecb4; + + --success-bg: #142c1b; + --success-border: #285a37; + --success-fg: #8abd99; + --success-fg-strong: #b9d8c2; + + --info-bg: #0f273b; + --info-bg-selected: #436075; + --info-border: #436075; + --info-fg: #b4d9f3; + --info-fg-strong: #d2e8f7; + + --quote-fg-1: #b3cbff; + --quote-bg-1: #373a3f; + --quote-fg-2: #bee3aa; + --quote-bg-2: #313b2d; + --quote-fg-3: #ffc4b3; + --quote-bg-3: #55393a; + --quote-fg-4: #ffd3c0; + --quote-bg-4: #5e473e; + --quote-fg-5: #9ac9ca; + --quote-bg-5: #393d3e; + + --purple-bg: #2d1b3b; + --purple-border: #6b46c1; + --purple-fg: #c4b5fd; + --purple-fg-strong: #ddd6fe; + } +} \ No newline at end of file diff --git a/app/frontend/stylesheets/layout.css b/app/frontend/stylesheets/layout.css new file mode 100644 index 0000000..6ea1386 --- /dev/null +++ b/app/frontend/stylesheets/layout.css @@ -0,0 +1,215 @@ +:root { + --spacing: 8px; + --padding: 8px; + --margin: 8px; +} + +* { + padding: 0; + margin: 0; + border: 0; + box-sizing: border-box; +} + +p { + margin: var(--margin) 0; +} + +pre { + overflow: auto; +} + +.margin { + margin: var(--margin); +} + +.margin-top { + margin-top: var(--margin); +} + +.margin-right { + margin-right: var(--margin); +} + +.margin-bottom { + margin-bottom: var(--margin); +} + +.margin-left { + margin-left: var(--margin); +} + +.margin-horizontal { + margin-left: var(--margin); + margin-right: var(--margin); +} + +.margin-vertical { + margin-top: var(--margin); + margin-bottom: var(--margin); +} + +.padding { + padding: var(--padding); +} + +.padding-top { + padding-top: var(--padding); +} + +.padding-right { + padding-right: var(--padding); +} + +.padding-bottom { + padding-bottom: var(--padding); +} + +.padding-left { + padding-left: var(--padding); +} + +.padding-horizontal { + padding-left: var(--padding); + padding-right: var(--padding); +} + +.padding-vertical { + padding-top: var(--padding); + padding-bottom: var(--padding); +} + +.flex-row { + display: flex; + flex-direction: row; +} + +.flex-column { + display: flex; + flex-direction: column; +} + +.flex-row.gap, +.flex-column.gap { + gap: var(--spacing); +} + +.flex-row.align-start, +.flex-column.align-start { + align-items: flex-start; +} + +.flex-row.align-center, +.flex-column.align-center, +.grid.align-center { + align-items: center; +} + +.flex-row.align-end, +.flex-column.align-end { + align-items: flex-end; +} + +.flex-row.align-stretch, +.flex-column.align-stretch { + align-items: stretch; +} + +.flex-row.justify-start, +.flex-column.justify-start { + justify-content: flex-start; +} + +.flex-row.justify-center, +.flex-column.justify-center { + justify-content: center; +} + +.flex-row.justify-end, +.flex-column.justify-end { + justify-content: flex-end; +} + +.grow { + flex-grow: 1; +} + +.grid { + display: grid; +} + +.grid.gap { + grid-column-gap: var(--spacing); + grid-row-gap: var(--spacing); +} + +.grid.gap-vertical { + grid-row-gap: var(--spacing); +} + +.grid.gap-horizontal { + grid-column-gap: var(--spacing); +} + +.checkbox-row, .radio-row { + display: flex; + align-items: center; +} + +.checkbox-row label, .radio-row label { + padding-left: var(--spacing); +} + +.window { + display: flex; + flex-direction: column; +} + +.title-bar { + flex-shrink: 0; +} + +.title-bar-text { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.window-body { + flex-grow: 1; +} + +[role=menu], +[role=menubar] { + user-select: none; + list-style-type: none; + display: flex; + white-space: nowrap; +} + +[role=menu] { + flex-direction: column; +} + +[role=menu] li, +[role=menubar] li { + position: relative; + text-overflow: ellipsis; + cursor: default; +} + +[role=menu] li > ul, +[role=menubar] li > ul { + position: absolute; + z-index: 1; + display: none; +} + +[role=menu] li:focus-within > ul, +[role=menubar] li:focus-within > ul { + display: block; +} + +.list [role="option"] { + user-select: none; +} diff --git a/app/frontend/stylesheets/os9.css b/app/frontend/stylesheets/os9.css new file mode 100644 index 0000000..2c4c710 --- /dev/null +++ b/app/frontend/stylesheets/os9.css @@ -0,0 +1,1104 @@ +:root { + --preferred-font: "ChicagoFLF"; + --desktop-bg: #9999CC; + --window-bg: #DDDDDD; + --window-fg: #000000; + --window-frame-bg: #CCCCCC; + --primary-0: #000; + --primary-1: #005; + --primary-2: #339; + --primary-3: #66c; + --primary-4: #99f; + --primary-5: #ccf; + --primary-6: #eee; + --selection-bg: var(--primary-5); + --selection-fg: #000; +} + +body { + background-color: #000; + color: var(--window-fg); +} + +*, *::before, *::after { + box-sizing: border-box; +} + +button, input, textarea, select { + font-family: inherit; + font-size: inherit; +} + +a:focus { + outline: 1px dotted var(--darkest); +} + +code { + font-family: monospace; +} + +.desktop { + background: var(--desktop-bg); +} + +.top-panel, .right-panel, .buttom-panel, .left-panel { + background: var(--window-bg); +} + +.top-panel { + border-bottom: 1px solid #000000; + box-shadow: inset 1px 0 0 #fff, inset 0 1px 0 #fff, inset -1px 0 0 #999, inset 0 -1px 0 #999; + border-top-left-radius: 10px; + border-top-right-radius: 10px; +} + +.lowered { + border: 1px solid #888; + border-right-color: #fff; + border-bottom-color: #fff; +} + +.raised { + border: 1px solid #fff; + border-right-color: #888; + border-bottom-color: #888; +} + +fieldset { + border: 1px solid #888; + box-shadow: inset 1px 1px 0 #fff, 1px 1px 0 #fff; + position: relative; +} + +legend { + text-align: left; + padding: 0 4px 0.65em; + background-color: #ddd; + display: block; +} + +.horizontal-scrollbar, +.vertical-scrollbar { + display: grid; +} +.horizontal-scrollbar > button, +.vertical-scrollbar > button { + display: flex; + flex-shrink: 0; + width: 16px; + height: 16px; + min-width: 0; + padding: 0; + background: #ddd; + box-shadow: inset 1px 1px #fff, inset -1px -1px #bbb; + border-radius: 0; +} +.horizontal-scrollbar > button::after, +.vertical-scrollbar > button::after { + content: ""; + display: block; + width: 0; + height: 0; +} +.horizontal-scrollbar > button:not(:disabled):active, +.vertical-scrollbar > button:not(:disabled):active { + padding: 0; + background: #777; + box-shadow: inset 1px 1px #555, inset -1px -1px #999; + box-shadow: none; + outline: none; +} +.horizontal-scrollbar > .scrollbar-track, +.vertical-scrollbar > .scrollbar-track { + flex-grow: 1; + background: #aaa; + position: relative; + box-shadow: inset 1px 1px #777, inset 2px 2px #888, inset -1px -1px #ccc, inset -2px -2px #bbb; + overflow: hidden; + border: 1px solid #000; +} +.horizontal-scrollbar > .scrollbar-track > .scrollbar-thumb, +.vertical-scrollbar > .scrollbar-track > .scrollbar-thumb { + position: absolute; + background: var(--primary-4); + box-shadow: inset 1px 1px var(--primary-5), inset -1px -1px var(--primary-3), 1px 1px #777, 2px 2px #888; + display: flex; + align-items: center; + justify-content: center; +} +.horizontal-scrollbar > .scrollbar-track > .scrollbar-thumb:active, +.vertical-scrollbar > .scrollbar-track > .scrollbar-thumb:active { + background: var(--primary-3); + box-shadow: inset 1px 1px var(--primary-4), inset -1px -1px var(--primary-2), 1px 1px #777, 2px 2px #888; +} + +.horizontal-scrollbar { + grid-template-columns: 1fr 16px 16px; +} +.horizontal-scrollbar > button:first-child { + border-left: 0; + border-right: 0; + grid-column: 2; +} +.horizontal-scrollbar > button:first-child::after { + border-right: 4px solid #000; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + margin: 3px 0 0 5px; +} +.horizontal-scrollbar > button:last-child { + grid-column: 3; +} +.horizontal-scrollbar > button:last-child::after { + border-left: 4px solid #000; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + margin: 3px 0 0 5px; +} +.horizontal-scrollbar > .scrollbar-track { + grid-column: 1; + grid-row: 1; +} +.horizontal-scrollbar > .scrollbar-track > .scrollbar-thumb { + top: 0; + bottom: 0; + border-left: 1px solid #000; + border-right: 1px solid #000; +} +.horizontal-scrollbar > .scrollbar-track > .scrollbar-thumb::after { + display: block; + content: ""; + width: 8px; + height: 8px; + background: repeating-linear-gradient(90deg, var(--primary-5), var(--primary-5) 1px, var(--primary-2) 1px, var(--primary-2) 2px); +} +.horizontal-scrollbar > .scrollbar-track > .scrollbar-thumb:active::after { + background: repeating-linear-gradient(90deg, var(--primary-4), var(--primary-4) 1px, var(--primary-1) 1px, var(--primary-1) 2px); +} + +.vertical-scrollbar { + grid-template-rows: 1fr 16px 16px; + width: 16px; +} +.vertical-scrollbar > button:first-child { + border-top: 0; + border-bottom: 0; + grid-row: 2; +} +.vertical-scrollbar > button:first-child::after { + border-bottom: 4px solid #000; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + margin: 5px 0 0 3px; +} +.vertical-scrollbar > button:last-child { + grid-row: 3; +} +.vertical-scrollbar > button:last-child::after { + border-top: 4px solid #000; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + margin: 5px 0 0 3px; +} +.vertical-scrollbar > .scrollbar-track { + grid-row: 1; +} +.vertical-scrollbar > .scrollbar-track > .scrollbar-thumb { + left: 0; + right: 0; + border-top: 1px solid #000; + border-bottom: 1px solid #000; +} +.vertical-scrollbar > .scrollbar-track > .scrollbar-thumb::after { + display: block; + content: ""; + width: 8px; + height: 8px; + background: repeating-linear-gradient(0, var(--primary-3), var(--primary-3) 1px, var(--primary-5) 1px, var(--primary-5) 2px); +} +.vertical-scrollbar > .scrollbar-track > .scrollbar-thumb:active::after { + background: repeating-linear-gradient(0, var(--primary-1), var(--primary-1) 1px, var(--primary-4) 1px, var(--primary-4) 2px); +} + +::-webkit-scrollbar { + background: #aaa; + box-shadow: inset 1px 1px #777, inset 2px 2px #888, inset -1px -1px #ccc, inset -2px -2px #bbb; + border: 1px solid #000; + width: 16px; + height: 16px; +} + +::-webkit-scrollbar-corner { + background: #aaa; +} + +::-webkit-scrollbar-button, +::-webkit-scrollbar-thumb { + background: #ddd; + box-shadow: inset 1px 1px #fff, inset -1px -1px #bbb; + border: 1px solid #000; +} + +::-webkit-scrollbar-thumb { + background-color: var(--primary-4); + background-repeat: no-repeat; + background-size: 8px 8px; + background-position: center center; +} +::-webkit-scrollbar-thumb:vertical { + background-image: repeating-linear-gradient(0, var(--primary-2), var(--primary-2) 1px, var(--primary-5) 1px, var(--primary-5) 2px); + box-shadow: inset 1px 1px var(--primary-5), inset -1px -1px var(--primary-3), 0 1px #777, 0 2px #888; +} +::-webkit-scrollbar-thumb:horizontal { + background-image: repeating-linear-gradient(90deg, var(--primary-5), var(--primary-5) 1px, var(--primary-2) 1px, var(--primary-2) 2px); + box-shadow: inset 1px 1px var(--primary-5), inset -1px -1px var(--primary-3), 1px 0 #777, 2px 0 #888; +} +::-webkit-scrollbar-thumb:active { + background-color: var(--primary-3); +} +::-webkit-scrollbar-thumb:active:vertical { + background-image: repeating-linear-gradient(0, var(--primary-1), var(--primary-1) 1px, var(--primary-4) 1px, var(--primary-4) 2px); + box-shadow: inset 1px 1px var(--primary-4), inset -1px -1px var(--primary-2), 0 1px #777, 0 2px #888; +} +::-webkit-scrollbar-thumb:active:horizontal { + background-image: repeating-linear-gradient(90deg, var(--primary-4), var(--primary-4) 1px, var(--primary-1) 1px, var(--primary-1) 2px); + box-shadow: inset 1px 1px var(--primary-4), inset -1px -1px var(--primary-2), 1px 0 #777, 2px 0 #888; +} + +::-webkit-scrollbar-button { + width: 16px; + height: 16px; + display: none; +} +::-webkit-scrollbar-button:active { + background: #777; + box-shadow: inset 1px 1px #555, inset -1px -1px #999; + box-shadow: none; +} + +::-webkit-scrollbar-button:horizontal:end:decrement, +::-webkit-scrollbar-button:horizontal:end:increment, +::-webkit-scrollbar-button:vertical:end:decrement, +::-webkit-scrollbar-button:vertical:end:increment { + display: block; +} + +::-webkit-scrollbar-button:active { + box-shadow: none; +} + +::-webkit-scrollbar-button:vertical:decrement { + background-image: url(arrow-up.svg); + background-position: 3px 5px; + background-repeat: no-repeat; +} + +::-webkit-scrollbar-button:vertical:increment { + background-image: url(arrow-down.svg); + background-position: 3px 5px; + background-repeat: no-repeat; +} + +::-webkit-scrollbar-button:horizontal:decrement { + background-image: url(arrow-left.svg); + background-position: 4px 3px; + background-repeat: no-repeat; +} + +::-webkit-scrollbar-button:horizontal:increment { + background-image: url(arrow-right.svg); + background-position: 4px 3px; + background-repeat: no-repeat; +} + +button, input[type=button], input[type=submit], input[type=reset], .button, +select:not([size], [multiple]), select[size="1"] { + background-color: #DDDDDD; + color: #000000; + border: 1px solid #000; + border-radius: 3px; + box-shadow: inset 1px 1px 0 #DDDDDD, inset 2px 2px 0 #fff, inset -1px -1px 1px #555; + padding: 2px 10px; + min-width: 58px; + text-align: center; + font-weight: bold; + cursor: default; + user-select: none; +} +button:disabled, button.disabled, input[type=button]:disabled, input[type=button].disabled, input[type=submit]:disabled, input[type=submit].disabled, input[type=reset]:disabled, input[type=reset].disabled, .button:disabled, .button.disabled, +select:not([size], [multiple]):disabled, +select:not([size], [multiple]).disabled, select[size="1"]:disabled, select[size="1"].disabled { + color: #777; + border-color: #888; + box-shadow: none; +} +button:not(:disabled, .disabled):active, button.active, input[type=button]:not(:disabled, .disabled):active, input[type=button].active, input[type=submit]:not(:disabled, .disabled):active, input[type=submit].active, input[type=reset]:not(:disabled, .disabled):active, input[type=reset].active, .button:not(:disabled, .disabled):active, .button.active, +select:not([size], [multiple]):not(:disabled, .disabled):active, +select:not([size], [multiple]).active, select[size="1"]:not(:disabled, .disabled):active, select[size="1"].active { + background-color: #666666; + color: #fff; + box-shadow: inset 1px 1px 1px #333, inset -1px -1px 1px #999; +} +button:focus-visible, input[type=button]:focus-visible, input[type=submit]:focus-visible, input[type=reset]:focus-visible, .button:focus-visible, +select:not([size], [multiple]):focus-visible, select[size="1"]:focus-visible { + outline: 2px solid var(--primary-3); + border-radius: 1px; +} + +input:not([type=button], [type=submit], [type=reset], [type=checkbox], [type=radio], [type=range]), textarea, .input, +select[multiple], select[size]:not([size="1"]) { + background: #fff; + color: #000; + border: 1px solid #000; + box-shadow: -1px -1px #888; + margin: 2px; + padding: 2px; +} +input:not([type=button], [type=submit], [type=reset], [type=checkbox], [type=radio], [type=range])::selection, textarea::selection, .input::selection, +select[multiple]::selection, select[size]:not([size="1"])::selection { + background-color: var(--selection-bg); + color: var(--selection-fg); +} +input:not([type=button], [type=submit], [type=reset], [type=checkbox], [type=radio], [type=range]):disabled, input:not([type=button], [type=submit], [type=reset], [type=checkbox], [type=radio], [type=range]).disabled, textarea:disabled, textarea.disabled, .input:disabled, .input.disabled, +select[multiple]:disabled, +select[multiple].disabled, select[size]:not([size="1"]):disabled, select[size]:not([size="1"]).disabled { + color: #808080; + border-color: #555; + box-shadow: 0 0 0 1px #ddd; +} +input:not([type=button], [type=submit], [type=reset], [type=checkbox], [type=radio], [type=range]):focus, input:not([type=button], [type=submit], [type=reset], [type=checkbox], [type=radio], [type=range]).focus, textarea:focus, textarea.focus, .input:focus, .input.focus, +select[multiple]:focus, +select[multiple].focus, select[size]:not([size="1"]):focus, select[size]:not([size="1"]).focus { + outline: 2px solid var(--primary-3); + border-radius: 1px; +} + +select[multiple], select[size]:not([size="1"]) { + padding: 0; +} +select[multiple] > option, select[size]:not([size="1"]) > option { + padding: 2px 6px; +} +select[multiple] > option:checked, select[size]:not([size="1"]) > option:checked { + background-color: var(--selection-bg); + color: var(--selection-fg); +} +select[multiple]:focus, select[size]:not([size="1"]):focus { + box-shadow: none; + outline: 2px solid var(--primary-3); + border-radius: 1px; +} +select[multiple]:focus > option:checked, select[size]:not([size="1"]):focus > option:checked { + outline: none; +} + +.dropdown { + display: inline-grid; + grid-template-areas: "s"; +} +.dropdown > select { + appearance: none; + grid-area: s; + padding-right: 27px; + text-align: left; + box-shadow: inset 1px 1px #fff, inset -1px -1px #aaa; +} +.dropdown > select:disabled + .dropdown-button { + color: #888; + box-shadow: -1px 0 #aaa; +} +.dropdown > select:not(:disabled):active + .dropdown-button { + color: #fff; + box-shadow: inset 1px 1px #444, inset 2px 2px #555, inset -1px -1px #888, inset -2px -2px #777, -1px 0 #222; +} +.dropdown > .dropdown-button { + justify-self: end; + grid-area: s; + display: flex; + justify-content: center; + align-items: center; + color: #000000; + width: 20px; + pointer-events: none; + box-shadow: inset 1px 1px #ddd, inset 2px 2px #fff, inset -1px -1px #777, inset -2px -2px #aaa, -1px 0 #aaa; + margin: 1px; +} +.dropdown > .dropdown-button::after { + content: ""; + display: block; + width: 7px; + height: 10px; + -webkit-mask-image: url(dropdown.svg); + -webkit-mask-repeat: no-repeat; + mask-image: url(dropdown.svg); + mask-repeat: no-repeat; + background: currentColor; +} + +input[type=checkbox] { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background: #ddd; + border: 1px solid #000; + box-shadow: inset 1px 1px 0 #fff, inset -1px -1px 0 #888; + width: 12px; + height: 12px; + border-radius: 0; +} +input[type=checkbox]:not(:disabled):active { + background: #777; + box-shadow: inset 1px 1px 0 #555, inset -1px -1px 0 #999; +} +input[type=checkbox]:checked::after { + content: ""; + display: block; + width: 10px; + height: 5px; + border-bottom: 1.5px solid #000; + border-left: 1.5px solid #000; + margin-left: 1.5px; + transform: rotate(-45deg); + box-shadow: -1px 1px 0 rgba(0, 0, 0, 0.3); +} +input[type=checkbox]:disabled { + border-color: #888; + box-shadow: none; +} +input[type=checkbox]:disabled::after { + box-shadow: none; + border-color: #888; +} + +input[type=checkbox]:disabled + label, +input[type=radio]:disabled + label { + color: #777; +} + +input[type=radio] { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background: linear-gradient(to bottom right, #fff, #888); + box-shadow: inset 1px 1px 0 #ddd, inset -1px -1px 0 #888; + border: 1px solid #000; + width: 12px; + height: 12px; + border-radius: 50%; +} +input[type=radio]:checked { + background: linear-gradient(to bottom right, #555, #fff); + box-shadow: inset 1px 1px 0 #555; +} +input[type=radio]:checked::after { + content: ""; + display: block; + width: 6px; + height: 6px; + border-radius: 50%; + background-color: #000; + margin-top: 2px; + margin-left: 2px; +} +input[type=radio]:not(:disabled):active { + background: linear-gradient(to bottom right, #444, #bbb); + box-shadow: inset 1px 1px 0 #444; +} +input[type=radio]:disabled { + background: #ddd; + border-color: #888; + box-shadow: none; +} +input[type=radio]:disabled::after { + background-color: #888; +} + +input[type=radio]:focus-visible + label, +input[type=checkbox]:focus-visible + label { + border-radius: 1px; + outline-offset: 0; + position: relative; +} +input[type=radio]:focus-visible + label::after, +input[type=checkbox]:focus-visible + label::after { + content: ""; + display: block; + position: absolute; + top: 0; + left: calc(var(--spacing) - 1px); + right: 0; + bottom: 0; + outline: 2px solid var(--primary-3); + border-radius: 1px; +} + +input[type=radio]:focus, +input[type=checkbox]:focus { + outline: none; +} + +label > input[type=radio]:focus-visible, +label > input[type=checkbox]:focus-visible { + outline: 2px solid var(--primary-3); + border-radius: 1px; +} + +input[type=range] { + -webkit-appearance: none; + width: 100%; + background: transparent; + padding: 2px; +} +input[type=range]:focus-visible { + outline: 2px solid var(--primary-3); + border-radius: 1px; +} +input[type=range]::-moz-range-track { + height: 3px; + background: #AAA; + border: 1px solid #222; + border-radius: 2px; + box-shadow: -1px -1px #AAA, 1px 1px #FFF; + margin: 0 5px; +} +input[type=range]::-moz-range-thumb { + border-radius: 0; + background-color: var(--primary-4); + background-image: repeating-linear-gradient(90deg, var(--primary-5), var(--primary-5) 1px, var(--primary-2) 1px, var(--primary-2) 2px); + background-size: 6px 5px; + background-repeat: no-repeat; + background-position: center center; + border: 1px solid #000; + width: 13px; + height: 11px; + border-radius: 2px; + box-shadow: inset 1px 1px var(--primary-5), inset -1px -1px var(--primary-3); +} +input[type=range]::-moz-range-thumb:active { + background-color: var(--primary-3); + background-image: repeating-linear-gradient(90deg, var(--primary-4), var(--primary-4) 1px, var(--primary-1) 1px, var(--primary-1) 2px); + box-shadow: inset 1px 1px var(--primary-4), inset -1px -1px var(--primary-2); +} +input[type=range]::-webkit-slider-runnable-track { + height: 3px; + background: #AAA; + border: 1px solid #222; + border-radius: 2px; + box-shadow: -1px -1px #AAA, 1px 1px #FFF; + margin: 0 5px; + margin: 10px 5px; +} +input[type=range]::-webkit-slider-thumb { + -webkit-appearance: none; + margin-top: -5px; + border-radius: 0; + background-color: var(--primary-4); + background-image: repeating-linear-gradient(90deg, var(--primary-5), var(--primary-5) 1px, var(--primary-2) 1px, var(--primary-2) 2px); + background-size: 6px 5px; + background-repeat: no-repeat; + background-position: center center; + border: 1px solid #000; + width: 13px; + height: 11px; + border-radius: 2px; + box-shadow: inset 1px 1px var(--primary-5), inset -1px -1px var(--primary-3); +} +input[type=range]::-webkit-slider-thumb:active { + background-color: var(--primary-3); + background-image: repeating-linear-gradient(90deg, var(--primary-4), var(--primary-4) 1px, var(--primary-1) 1px, var(--primary-1) 2px); + box-shadow: inset 1px 1px var(--primary-4), inset -1px -1px var(--primary-2); +} + +progress, +.progress-bar { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background: #BBBBBB; + border: 1px solid #000; + box-shadow: 1px 1px #fff, -1px -1px #AAAAAA, inset 1px 1px #888888, inset -1px -1px #DDDDDD; + height: 12px; +} + +.progress-bar { + position: relative; + overflow: hidden; +} +.progress-bar:not([aria-valuenow])::after, .progress-bar > .progress-bar-value { + content: ""; + display: block; + position: absolute; + top: 0; + bottom: 0; + left: 0; + background: linear-gradient(to bottom, var(--primary-2), var(--primary-3), var(--primary-4), var(--primary-5), var(--primary-6), var(--primary-5), var(--primary-4), var(--primary-3), var(--primary-2), var(--primary-1)); + border-right: 1px solid #000; + box-shadow: inset 1px 0 var(--primary-3), inset 2px 0 rgba(255, 255, 255, 0.2), inset -1px 0 var(--primary-1), inset -2px 0 var(--primary-2), 1px 0 #555555, 2px 0 #888888; +} +.progress-bar:not([aria-valuenow])::after { + width: 50%; + border-left: 1px solid #000; + animation: 1s infinite linear indeterminate; +} + +progress::-webkit-progress-bar { + background: #BBBBBB; + box-shadow: inset 1px 1px #888888, inset -1px -1px #DDDDDD; +} + +progress::-webkit-progress-value { + background: linear-gradient(to bottom, var(--primary-2), var(--primary-3), var(--primary-4), var(--primary-5), var(--primary-6), var(--primary-5), var(--primary-4), var(--primary-3), var(--primary-2), var(--primary-1)); + border-right: 1px solid #000; + box-shadow: inset 1px 0 var(--primary-3), inset 2px 0 rgba(255, 255, 255, 0.2), inset -1px 0 var(--primary-1), inset -2px 0 var(--primary-2), 1px 0 #555555, 2px 0 #888888; +} + +progress::-moz-progress-bar { + background: linear-gradient(to bottom, var(--primary-2), var(--primary-3), var(--primary-4), var(--primary-5), var(--primary-6), var(--primary-5), var(--primary-4), var(--primary-3), var(--primary-2), var(--primary-1)); + border-right: 1px solid #000; + box-shadow: inset 1px 0 var(--primary-3), inset 2px 0 rgba(255, 255, 255, 0.2), inset -1px 0 var(--primary-1), inset -2px 0 var(--primary-2), 1px 0 #555555, 2px 0 #888888; +} + +progress:indeterminate::-moz-progress-bar { + width: 50%; + border-left: 1px solid #000; + animation: 1s infinite linear indeterminate; +} + +progress:not([value])::-webkit-progress-value { + width: 50%; + border-left: 1px solid #000; + animation: 1s infinite linear indeterminate; +} + +@keyframes indeterminate { + from { + margin-left: -50%; + } + to { + margin-left: 100%; + } +} +.title-bar { + background: #ddd; + color: #777; + display: flex; + gap: 5px; +} +.title-bar .title-bar-text { + display: flex; + flex-grow: 1; + align-items: center; + gap: 4px; + font-weight: bold; + font-size: 12px; + cursor: default; +} +.title-bar .title-bar-text::before, .title-bar .title-bar-text::after { + display: block; + content: ""; + flex-grow: 1; + height: 12px; +} + +.window, +.dialog { + padding: 2px 4px 4px; + border: 1px solid #555; + background: #ddd; +} + +.window.active, +.window:focus-within, +.dialog.active, +.dialog:focus-within, +.menu { + background: var(--window-frame-bg); + border: 1px solid #000000; + box-shadow: 1px 1px 0 #000000, inset 1px 0 0 #fff, inset 0 1px 0 #fff, inset -1px 0 0 #999, inset 0 -1px 0 #999; +} + +.window.active > .title-bar, +.window:focus-within > .title-bar, +.dialog.active > .title-bar, +.dialog:focus-within > .title-bar, +.menu > .title-bar, +.title-bar.active { + background: var(--window-frame-bg); + color: #000; +} +.window.active > .title-bar > .title-bar-text::before, .window.active > .title-bar > .title-bar-text::after, +.window:focus-within > .title-bar > .title-bar-text::before, +.window:focus-within > .title-bar > .title-bar-text::after, +.dialog.active > .title-bar > .title-bar-text::before, +.dialog.active > .title-bar > .title-bar-text::after, +.dialog:focus-within > .title-bar > .title-bar-text::before, +.dialog:focus-within > .title-bar > .title-bar-text::after, +.menu > .title-bar > .title-bar-text::before, +.menu > .title-bar > .title-bar-text::after, +.title-bar.active > .title-bar-text::before, +.title-bar.active > .title-bar-text::after { + background: repeating-linear-gradient(0deg, #fff, #fff 1px, #777 1px, #777 2px); +} + +.title-bar-buttons { + display: flex; + gap: 5px; + visibility: hidden; +} + +.window.active > .title-bar > .title-bar-buttons, +.window:focus-within > .title-bar > .title-bar-buttons, +.dialog.active > .title-bar > .title-bar-buttons, +.dialog:focus-within > .title-bar > .title-bar-buttons, +.title-bar.active > .title-bar-buttons { + visibility: visible; +} + +.title-bar-buttons:first-child { + margin-left: -1px; +} + +.title-bar-buttons:last-child { + margin-right: -1px; +} + +.title-bar-buttons button { + width: 11px; + height: 11px; + min-width: 0; + border-radius: 0; + border: 1px solid #222; + margin: 1px; + padding: 0; + background: linear-gradient(to bottom right, #999, #fff); + box-shadow: -1px -1px 0 #888, 1px 1px 0 #fff, inset 1px 1px 0 #ccc, inset -1px -1px 0 #888; +} + +.title-bar-buttons button:active { + background: linear-gradient(to bottom right, #444, #bbb); + box-shadow: -1px -1px 0 #888, 1px 1px 0 #fff, inset -1px -1px 0 #999; +} + +.window-body { + color: #555; + border: 1px solid #555; + background: #eee; +} + +.window.active > .window-body, +.window:focus-within > .window-body { + color: #000; + background: var(--window-bg); + border: 1px solid #000; + box-shadow: 1px 0 0 #fff, 0 1px 0 #fff, -1px 0 0 #999, 0 -1px 0 #999; +} + +[role=menubar] { + color: #000; + background-color: #ddd; +} +[role=menubar] > li > ul { + left: 0; + top: 100%; + min-width: 200px; +} + +[role=menu] { + color: #000; + background-color: #ddd; + border: 1px solid #000; + box-shadow: inset 1px 1px #fff, inset -1px -1px #888, 1px 1px #000; +} +[role=menu] li { + padding: 1px 3px 1px 21px; +} +[role=menu] li[data-shortcut]::after { + float: right; + content: attr(data-shortcut); + display: block; + margin-left: 4px; +} +[role=menu] li > ul { + left: 100%; + margin: 0; + top: -1px; + min-width: 200px; +} +[role=menu] li[role=separator] { + border-top: 1px solid #888; + border-bottom: 1px solid #fff; + height: 0; + padding: 0; + margin: 2px 0; +} +[role=menu] li[role=checkbox][aria-checked=true]::before, [role=menu] li[role=radio][aria-checked=true]::before { + content: ""; + position: absolute; + left: 3px; + top: 50%; + margin-top: -4px; + display: block; + width: 9px; + height: 8px; + background: currentColor; + -webkit-mask-image: url(checkmark.svg); + -webkit-mask-repeat: no-repeat; + -webkit-mask-size: 7px 7px; + mask-image: url(checkmark.svg); + mask-repeat: no-repeat; + mask-size: 9px 8px; +} +[role=menu] li[aria-haspopup=true]::after { + content: ""; + display: block; + position: absolute; + right: 8px; + top: 0; + bottom: 0; + width: 6px; + background: currentColor; + -webkit-mask-image: url(menu-right.svg); + -webkit-mask-size: 6px 11px; + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center center; + mask-image: url(menu-right.svg); + mask-size: 6px 11px; + mask-repeat: no-repeat; + mask-position: center center; +} + +.menu > [role=menu] { + border: 0; + border-top: 1px solid #555; +} + +[role=menu] li:focus, +[role=menubar] li:focus { + outline: none; +} + +[role=menubar] > li { + padding: 1px 8px; +} + +[role=menu] li:focus-within, +[role=menubar] li:focus-within { + background: var(--primary-2); + box-shadow: inset 1px 0 var(--primary-3), inset -1px 0 #008; + color: #fff; +} + +[role=menu] li[aria-disabled], +[role=menubar] li[aria-disabled] { + color: #777; +} + +.tabs > menu { + display: flex; + gap: 28px; + overflow: hidden; + padding: 0 14px 0 24px; +} + +.tabs > menu > button { + border: 0; + box-shadow: none; + border-radius: 0; + display: flex; + padding: 0; + min-width: 0; + height: 23px; + position: relative; + background: #CCCCCC; + height: 23px; + box-shadow: inset 0 1px #000, inset 0 2px #ccc, inset 0 3px #ddd; +} +.tabs > menu > button:focus { + outline: none; +} +.tabs > menu > button:focus-visible { + text-shadow: 0 0 2px var(--primary-3); +} + +.tabs > menu > button::before { + display: block; + content: ""; + background: url(tab.svg); + background-position: top left; + background-repeat: no-repeat; + width: 14px; + height: 23px; + margin-left: -14px; +} + +.tabs > menu > button::after { + display: block; + content: ""; + background: url(tab.svg); + background-position: top right; + background-repeat: no-repeat; + width: 14px; + height: 23px; + margin-right: -14px; +} + +.tabs > menu > button:not([aria-selected=true]):active { + background: #666; + box-shadow: inset 0 1px #000, inset 0 2px #444, inset 0 3px #555; +} + +.tabs > menu > button:not([aria-selected=true]):active::before, +.tabs > menu > button:not([aria-selected=true]):active::after { + background-image: url(tab-active.svg); +} + +.tabs > menu > button[aria-selected=true] { + color: #000; + background: #eee; + box-shadow: inset 0 1px #000, inset 0 2px #ccc, inset 0 3px #fff; + z-index: 2; + height: 25px; +} +.tabs > menu > button[aria-selected=true]::before, .tabs > menu > button[aria-selected=true]::after { + background-image: url(tab-selected.svg); + height: 25px; +} + +.tabs > [role=tabpanel] { + position: relative; + z-index: 1; + margin-top: -4px; + background: #eee; + border: 1px solid #000; + box-shadow: inset 1px 1px 0 #ccc, inset 2px 2px 0 #fff, inset -1px -1px 0 #999, inset -2px -2px 0 #bbb; + /*box-shadow: inset 1px 0 0 #ccc, inset 2px 0 0 #fff, inset -1px -1px 0 #999, inset -2px -2px 0 #bbb;*/ +} + +.list { + background: #fff; + color: #000; + border: 1px solid #000; + box-shadow: -1px -1px #888, 1px 1px #fff; + overflow: auto; + cursor: default; +} +.list > [role=option] { + padding: 2px 6px 2px 6px; + white-space: nowrap; +} +.list > [role=option][aria-selected=true] { + background-color: var(--selection-bg); + color: var(--selection-fg); +} +.list > [role=option]:focus { + outline: none; +} +.list:focus-within { + box-shadow: none; + outline: 2px solid var(--primary-3); + border-radius: 1px; +} + +.icon-grid { + display: grid; + grid-template-columns: repeat(auto-fit, 128px); + flex-direction: row; + flex-wrap: wrap; +} +.icon-grid > .icon { + flex: 0 0 128px; + display: flex; + flex-direction: column; + align-items: center; + margin: 4px; +} +.icon-grid > .icon > .icon-label { + margin-top: 5px; + text-align: center; + padding: 1px 1px; +} +.icon-grid > .icon:focus { + outline: none; +} +.icon-grid > .icon:focus > .icon-label { + outline: 1px dotted #000; + outline-offset: -1px; +} +.icon-grid > .icon[aria-selected=true] > .icon-label { + background-color: #000; + color: #fff; +} +.icon-grid > .icon[aria-selected=true]:focus > .icon-label { + outline: 1px dotted #fff; +} +.icon-grid:focus-within > .icon[aria-selected=true] > .icon-label { + background-color: #000; + color: #fff; +} + +.detailed { + width: 100%; + border-spacing: 0; +} +.detailed th { + text-align: left; + font-weight: normal; + background-color: #ccc; + color: #000; + border: 1px solid #333; + border-top-color: #666; + border-left-color: #666; + box-shadow: inset 1px 1px #fff, inset -1px -1px #888; + padding: 2px 6px 2px 6px; + white-space: nowrap; +} +.detailed tr { + outline: none; + background: #eee; +} +.detailed tr:focus { + outline: none; +} +.detailed tr[aria-selected=true] { + background-color: var(--selection-bg); + color: var(--selection-fg); +} +.detailed .icon[aria-selected=true] .icon-label { + background-color: #000; + color: #fff; +} +.detailed td { + padding: 2px 6px 2px 6px; + white-space: nowrap; + border-bottom: 1px solid #fff; +} +.detailed td.icon { + display: flex; +} +.detailed .icon { + display: inline-flex; + flex-direction: row; + align-items: center; + outline: none; +} +.detailed .icon > img { + width: 16px; + height: 16px; + margin-right: 1px; + margin-left: -4px; +} +.detailed .icon > .icon-label { + text-align: center; + padding: 1px 3px; +} +.detailed .icon:focus .icon-label { + outline: none; +} +.detailed .icon[aria-selected=true] > .icon-label { + background-color: #000; + color: #fff; +} +.detailed thead { + position: sticky; + top: 0; +} diff --git a/app/frontend/stylesheets/snippets/admin_tools.scss b/app/frontend/stylesheets/snippets/admin_tools.scss new file mode 100644 index 0000000..5354f3d --- /dev/null +++ b/app/frontend/stylesheets/snippets/admin_tools.scss @@ -0,0 +1,41 @@ +$admin-red: red; +$dev-green: #14a514; +$mdv-blue: #1e90ff; +$tool-font-size: 10px; +$tool-border-width: 1.5px; +$tool-padding: 2px; +$tool-padding-top: 14px; +$tool-label-position-top: 0; +$tool-label-position-left: 3px; + +@mixin admin-tool($label, $color) { + &::before { + content: $label; + color: $color; + position: absolute; + font-size: $tool-font-size; + top: $tool-label-position-top; + left: $tool-label-position-left; + } + + position: relative; + border: $tool-border-width dashed $color; + padding: $tool-padding; + padding-top: $tool-padding-top; +} + +.super-admin-tool { + @include admin-tool("super admin", $admin-red); +} + +.mdv-tool { + @include admin-tool("manual document verifier", $mdv-blue); +} + +.dev-tool { + @include admin-tool("development mode", $dev-green); +} + +.program-manager-tool { + @include admin-tool("program manager", white); +} \ No newline at end of file diff --git a/app/frontend/stylesheets/snippets/banners.scss b/app/frontend/stylesheets/snippets/banners.scss new file mode 100644 index 0000000..ba97757 --- /dev/null +++ b/app/frontend/stylesheets/snippets/banners.scss @@ -0,0 +1,71 @@ +@mixin banner-base { + border: 1px solid; + border-radius: 8px; + padding: .75rem 1rem; + margin-bottom: 1rem; + font-weight: 500; + vertical-align: center; + + & > svg { + height: 1.25rem; + width: 1.25rem; + margin-right: 0.5rem; + } +} + +@mixin banner-theme($bg-color, $border-color, $fg-color, $list-fg-color) { + background: $bg-color; + border-color: $border-color; + color: $fg-color; + + & > ul { + color: $list-fg-color; + & > li { + color: $list-fg-color; + } + } +} + +@mixin banner-warning { + @include banner-theme(var(--warning-bg), var(--warning-border), var(--warning-fg-strong), var(--warning-fg)); +} + +@mixin banner-danger { + @include banner-theme(var(--error-bg), var(--error-border), var(--error-fg-strong), var(--error-fg)); +} + +@mixin banner-info { + @include banner-theme(var(--info-bg), var(--info-border), var(--info-fg-strong), var(--info-fg)); +} + +@mixin banner-success { + @include banner-theme(var(--success-bg), var(--success-border), var(--success-fg-strong), var(--success-fg)); +} + +@mixin banner-purple { + @include banner-theme(var(--purple-bg), var(--purple-border), var(--purple-fg-strong), var(--purple-fg)); +} + +.banner { + @include banner-base; + + &.warning { + @include banner-warning; + } + + &.danger { + @include banner-danger; + } + + &.info { + @include banner-info; + } + + &.success { + @include banner-success; + } + + &.purple { + @include banner-purple; + } +} \ No newline at end of file diff --git a/app/frontend/stylesheets/snippets/borders.scss b/app/frontend/stylesheets/snippets/borders.scss new file mode 100644 index 0000000..b035ab5 --- /dev/null +++ b/app/frontend/stylesheets/snippets/borders.scss @@ -0,0 +1,18 @@ +.environment { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + z-index: 9999; + box-sizing: border-box; + + &.staging { + border: 5px dashed #ff00c8; + } + + &.development { + border: 5px dashed #00FF00; + } +} \ No newline at end of file diff --git a/app/frontend/stylesheets/snippets/brand.scss b/app/frontend/stylesheets/snippets/brand.scss new file mode 100644 index 0000000..72a5824 --- /dev/null +++ b/app/frontend/stylesheets/snippets/brand.scss @@ -0,0 +1,15 @@ +.brand { + &>h1 { + display: inline; + margin-left: 1rem; + vertical-align: middle; + font-size: 29px; + } + + & img { + width: 50px; + display: inline; + } + + margin-bottom: 1rem; +} \ No newline at end of file diff --git a/app/frontend/stylesheets/snippets/footer.scss b/app/frontend/stylesheets/snippets/footer.scss new file mode 100644 index 0000000..c47e4cd --- /dev/null +++ b/app/frontend/stylesheets/snippets/footer.scss @@ -0,0 +1,83 @@ +@import '@picocss/pico/css/pico.colors.css'; + +.app-footer { + margin-top: 3rem; + padding: 1.5rem 0; + border-top: 1px solid var(--pico-muted-border-color); + font-size: 0.875rem; + color: var(--pico-muted-color); + + .footer-content { + display: flex; + justify-content: space-between; + align-items: baseline; + flex-wrap: wrap; + gap: 1rem; + } + + .footer-main { + .app-name { + font-weight: 600; + color: var(--pico-color); + margin: 0; + } + } + + .footer-version { + text-align: right; + + .version-info { + margin-bottom: 0; + + .version-link { + color: var(--pico-primary); + text-decoration: none; + font-weight: 500; + + &:hover { + text-decoration: underline; + } + } + + .version-text { + font-weight: 500; + color: var(--pico-color); + } + } + } + + .environment-badge { + display: inline-block; + padding: 0.2rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.05em; + + &.development { + background-color: var(--pico-color-amber-300); + color: var(--pico-color-amber-950); + } + + &.staging { + background-color: var(--pico-color-purple-500); + color: white; + } + + &.production { + background-color: var(--pico-color-green-600); + color: white; + } + } + + @media (max-width: 768px) { + .footer-content { + flex-direction: column; + align-items: flex-start; + } + + .footer-version { + text-align: left; + } + } +} \ No newline at end of file diff --git a/app/frontend/stylesheets/snippets/forms.scss b/app/frontend/stylesheets/snippets/forms.scss new file mode 100644 index 0000000..218b1fe --- /dev/null +++ b/app/frontend/stylesheets/snippets/forms.scss @@ -0,0 +1,19 @@ +.form-one-line { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-bottom: 1rem; + + label { + margin-left: 0.5rem; + } +} + +[type="submit"].small { + width: fit-content !important; +} + +.inline-buttons { + display: flex; + gap: 1rem; +} \ No newline at end of file diff --git a/app/frontend/stylesheets/snippets/lightswitch.scss b/app/frontend/stylesheets/snippets/lightswitch.scss new file mode 100644 index 0000000..1e550f9 --- /dev/null +++ b/app/frontend/stylesheets/snippets/lightswitch.scss @@ -0,0 +1,39 @@ +.lightswitch-btn { + position: absolute; + top: 1rem; + right: 1rem; + background: var(--bg-secondary, #f8f9fa); + border: 1px solid var(--border-color, #dee2e6); + width: 3rem; + height: 3rem; + padding: 0; + cursor: pointer; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + z-index: 1000; + opacity: 0; + transition: opacity 0.15s ease-in-out; + + &:active { + transform: scale(0.95); + } + + .lightswitch-icon { + font-size: 1.2rem; + display: block; + transition: transform 0.2s ease; + } + + &.theme-loaded { + opacity: 1; + } +} + +[data-theme="dark"] .lightswitch-btn { + background: var(--bg-secondary, #374151); + border-color: var(--border-color, #4b5563); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + + &:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + } +} diff --git a/app/frontend/stylesheets/snippets/tooltips.scss b/app/frontend/stylesheets/snippets/tooltips.scss new file mode 100644 index 0000000..ce10940 --- /dev/null +++ b/app/frontend/stylesheets/snippets/tooltips.scss @@ -0,0 +1,84 @@ +// SHAMELESSLY lifted from HCB + +@use "../colors"; + +.tooltipped { + position: relative; +} + +@media (min-width: 56em) { + .tooltipped { + &:after { + background-color: #ccc; + box-shadow: + 0 0 2px 0 rgba(0, 0, 0, 0.0625), + 0 4px 8px 0 rgba(0, 0, 0, 0.125); + color: var(--window-fg); + content: attr(aria-label); + font-size: 0.875rem; + font-weight: 500; + height: min-content; + letter-spacing: 0; + line-height: 1.375; + max-width: 16rem; + min-height: 1.25rem; + opacity: 0; + padding: 0.15rem 0.55rem; + pointer-events: none; + position: absolute; + right: 100%; + text-align: center; + transform: translateY(50%); + transition: 0.125s all ease-in-out; + width: max-content; + z-index: 1; + + &.tooltipped--lg { + max-width: 24rem; + } + } + + &:hover:after, + &:active:after, + &:focus:after { + opacity: 1; + z-index: 9; + backdrop-filter: blur(2px); + } + } + + .tooltipped--e:after { + left: 100%; + bottom: 50%; + right: 0; + margin-left: 0.5rem; + transform: translateY(50%); + } + + .tooltipped--w:after { + right: 100%; + bottom: 50%; + margin-right: 0.5rem; + transform: translateY(50%); + } + + .tooltipped--n:after { + right: 50%; + bottom: 100%; + margin-bottom: 0.5rem; + transform: translateX(50%); + } + + .tooltipped--s:after { + right: 50%; + top: 100%; + margin-top: 0.5rem; + transform: translateX(50%); + } + + .tooltipped--xl { + &:after { + max-width: none; + } + } +} \ No newline at end of file diff --git a/app/helpers/addresses_helper.rb b/app/helpers/addresses_helper.rb new file mode 100644 index 0000000..5f4dc13 --- /dev/null +++ b/app/helpers/addresses_helper.rb @@ -0,0 +1,2 @@ +module AddressesHelper +end diff --git a/app/helpers/api/v1/application_helper.rb b/app/helpers/api/v1/application_helper.rb new file mode 100644 index 0000000..454c735 --- /dev/null +++ b/app/helpers/api/v1/application_helper.rb @@ -0,0 +1,6 @@ +module API::V1::ApplicationHelper + def scope(scope, &) + return unless current_scopes.include?(scope) + yield + end +end diff --git a/app/helpers/api/v1/identities_helper.rb b/app/helpers/api/v1/identities_helper.rb new file mode 100644 index 0000000..d2325d5 --- /dev/null +++ b/app/helpers/api/v1/identities_helper.rb @@ -0,0 +1,2 @@ +module API::V1::IdentitiesHelper +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb new file mode 100644 index 0000000..c1553fa --- /dev/null +++ b/app/helpers/application_helper.rb @@ -0,0 +1,25 @@ +module ApplicationHelper + def format_duration(seconds) + return "0 seconds" if seconds.nil? || seconds == 0 + + hours = seconds / 3600 + minutes = (seconds % 3600) / 60 + seconds = seconds % 60 + + parts = [] + parts << "#{hours} #{"hour".pluralize(hours)}" if hours > 0 + parts << "#{minutes} #{"minute".pluralize(minutes)}" if minutes > 0 + parts << "#{seconds} #{"second".pluralize(seconds)}" if seconds > 0 || parts.empty? + + parts.join(", ") + end + + def copy_to_clipboard(clipboard_value, tooltip_direction: "n", **options, &block) + # If block is not given, use clipboard_value as the rendered content + block ||= ->(_) { clipboard_value } + return yield if options.delete(:if) == false + + css_classes = "pointer tooltipped tooltipped--#{tooltip_direction} #{options.delete(:class)}" + tag.span "data-copy-to-clipboard": clipboard_value, class: css_classes, "aria-label": options.delete(:label) || "click to copy...", **options, &block + end +end diff --git a/app/helpers/backend/application_helper.rb b/app/helpers/backend/application_helper.rb new file mode 100644 index 0000000..f6c73fc --- /dev/null +++ b/app/helpers/backend/application_helper.rb @@ -0,0 +1,30 @@ +module Backend::ApplicationHelper + def render_checkbox(value) + content_tag(:span, style: "color: var(--checkbox-#{value ? "true" : "false"})") { value ? "☑" : "☒" } + end + + def super_admin_tool(class_name: "", element: "div", **options, &block) + return unless current_user&.super_admin? + concat content_tag(element, class: "super-admin-tool #{class_name}", **options, &block) + end + + def break_glass_tool(class_name: "", element: "div", **options, &block) + return unless current_user&.can_break_glass? || current_user&.super_admin? + concat content_tag(element, class: "break-glass-tool #{class_name}", **options, &block) + end + + def program_manager_tool(class_name: "", element: "div", **options, &block) + return unless current_user&.program_manager? || current_user&.super_admin? + concat content_tag(element, class: "program-manager-tool #{class_name}", **options, &block) + end + + def mdv_tool(class_name: "", element: "div", **options, &block) + return unless current_user&.manual_document_verifier? || current_user&.super_admin? + concat content_tag(element, class: "mdv-tool #{class_name}", **options, &block) + end + + def dev_tool(class_name: "", element: "div", **options, &block) + return unless Rails.env.development? + concat content_tag(element, class: "dev-tool #{class_name}", **options, &block) + end +end diff --git a/app/helpers/backend/audit_logs_helper.rb b/app/helpers/backend/audit_logs_helper.rb new file mode 100644 index 0000000..207e50b --- /dev/null +++ b/app/helpers/backend/audit_logs_helper.rb @@ -0,0 +1,2 @@ +module Backend::AuditLogsHelper +end diff --git a/app/helpers/backend/identities_helper.rb b/app/helpers/backend/identities_helper.rb new file mode 100644 index 0000000..7c1c01b --- /dev/null +++ b/app/helpers/backend/identities_helper.rb @@ -0,0 +1,2 @@ +module Backend::IdentitiesHelper +end diff --git a/app/helpers/backend/sessions_helper.rb b/app/helpers/backend/sessions_helper.rb new file mode 100644 index 0000000..4d5dfda --- /dev/null +++ b/app/helpers/backend/sessions_helper.rb @@ -0,0 +1,2 @@ +module Backend::SessionsHelper +end diff --git a/app/helpers/backend/users_helper.rb b/app/helpers/backend/users_helper.rb new file mode 100644 index 0000000..f18ddeb --- /dev/null +++ b/app/helpers/backend/users_helper.rb @@ -0,0 +1,2 @@ +module Backend::UsersHelper +end diff --git a/app/helpers/credentials_helper.rb b/app/helpers/credentials_helper.rb new file mode 100644 index 0000000..c224b23 --- /dev/null +++ b/app/helpers/credentials_helper.rb @@ -0,0 +1,2 @@ +module CredentialsHelper +end diff --git a/app/helpers/onboarding_helper.rb b/app/helpers/onboarding_helper.rb new file mode 100644 index 0000000..c01463d --- /dev/null +++ b/app/helpers/onboarding_helper.rb @@ -0,0 +1,2 @@ +module OnboardingHelper +end diff --git a/app/helpers/static_pages_helper.rb b/app/helpers/static_pages_helper.rb new file mode 100644 index 0000000..2d63e79 --- /dev/null +++ b/app/helpers/static_pages_helper.rb @@ -0,0 +1,2 @@ +module StaticPagesHelper +end diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb new file mode 100644 index 0000000..d394c3d --- /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/identity/notice_resemblances_job.rb b/app/jobs/identity/notice_resemblances_job.rb new file mode 100644 index 0000000..fe66d22 --- /dev/null +++ b/app/jobs/identity/notice_resemblances_job.rb @@ -0,0 +1,7 @@ +class Identity::NoticeResemblancesJob < ApplicationJob + queue_as :default + + def perform(identity) + ResemblanceNoticerEngine.run(identity) + end +end diff --git a/app/jobs/slack/notify_guardians_job.rb b/app/jobs/slack/notify_guardians_job.rb new file mode 100644 index 0000000..7f6cdd5 --- /dev/null +++ b/app/jobs/slack/notify_guardians_job.rb @@ -0,0 +1,53 @@ +class Slack::NotifyGuardiansJob < ApplicationJob + queue_as :default + include Rails.application.routes.url_helpers + + PING_LINE = "hey !" + + def perform(identity, without_ping: false) + reason_line = if identity.verification_status == "ineligible" + "their ID had the following issue: #{identity.verification_status_reason} – #{identity.verification_status_reason_details || "(unspecified)"}" + else + "nothing was wrong with their ID, they're just >18 years old." + end + slack_id = identity.slack_id || SlackService.find_by_email(identity.primary_email) + slack_id_line = if slack_id.present? + "<@#{slack_id}> (#{slack_id})" + else + "unknown...?" + end + message = <<~EOM.strip + #{PING_LINE unless without_ping} + there's someone that needs to be deactivated: + *name*: #{identity.first_name} #{identity.last_name} + *email*: #{identity.primary_email} + *slack*: #{slack_id_line} + #{reason_line} + thanks! + EOM + + verf = identity.latest_verification + + context_line = "*ref:* <#{backend_identity_url(identity)}|#{identity.public_id}> / <#{backend_verification_url(verf)}|#{verf.public_id}>" + HTTP.post(Rails.application.credentials.slack.adult_webhook, body: { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": message + } + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": context_line + } + ] + } + ] + }.to_json) + end +end diff --git a/app/jobs/verification/check_discrepancies_job.rb b/app/jobs/verification/check_discrepancies_job.rb new file mode 100644 index 0000000..55ad7dd --- /dev/null +++ b/app/jobs/verification/check_discrepancies_job.rb @@ -0,0 +1,7 @@ +class Verification::CheckDiscrepanciesJob < ApplicationJob + queue_as :default + + def perform(verification) + PapersPleaseEngine.run(verification) + end +end diff --git a/app/jobs/verification/expire_draft_aadhaar_verifications_job.rb b/app/jobs/verification/expire_draft_aadhaar_verifications_job.rb new file mode 100644 index 0000000..3a1e3c8 --- /dev/null +++ b/app/jobs/verification/expire_draft_aadhaar_verifications_job.rb @@ -0,0 +1,16 @@ +class Verification::ExpireDraftAadhaarVerificationsJob < ApplicationJob + def perform + expired_verifications = Verification::AadhaarVerification + .where(status: "draft") + .where("created_at < ?", 10.minutes.ago) + + expired_count = 0 + + expired_verifications.find_each do |verification| + verification.mark_as_rejected!("service_unavailable", "Verification expired after 10 minutes") + expired_count += 1 + end + + Rails.logger.info "Expired #{expired_count} draft Aadhaar verifications" + end +end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb new file mode 100644 index 0000000..4063651 --- /dev/null +++ b/app/mailers/application_mailer.rb @@ -0,0 +1,8 @@ +class ApplicationMailer < ActionMailer::Base + default from: "identity@hackclub.com" + layout "mailer" + + def send_it! + mail(to: @recipient, template_path: "mailers", template_name: "blank_mailer") + end +end diff --git a/app/mailers/identity_mailer.rb b/app/mailers/identity_mailer.rb new file mode 100644 index 0000000..b9e14fe --- /dev/null +++ b/app/mailers/identity_mailer.rb @@ -0,0 +1,29 @@ +class IdentityMailer < ApplicationMailer + def login_code(login_code) + @TRANSACTIONAL_ID = "cmbgs1y0p0c872j0in3n3knjj" + + @login_code = login_code + identity = login_code.identity + @recipient = identity.primary_email + + @datavariables = { + login_url: verify_sessions_url(token: login_code.token), + first_name: identity.first_name + } + + send_it! + end + + def approved_but_ysws_ineligible(identity) + @TRANSACTIONAL_ID = "cmbyoymlh0qfpy10i8ixgxj9d" + + @identity = identity + @recipient = identity.primary_email + + @datavariables = { + first_name: identity.first_name + } + + send_it! + end +end diff --git a/app/mailers/verification_mailer.rb b/app/mailers/verification_mailer.rb new file mode 100644 index 0000000..2f8c9f5 --- /dev/null +++ b/app/mailers/verification_mailer.rb @@ -0,0 +1,70 @@ +class VerificationMailer < ApplicationMailer + def approved(verification) + @TRANSACTIONAL_ID = "cmbgujk6r05rpwu0ip60klxna" + + @verification = verification + @identity = verification.identity + @recipient = @identity.primary_email + + @datavariables = { + first_name: @identity.first_name + } + + send_it! + end + + def rejected_amicably(verification) + @TRANSACTIONAL_ID = "cmbguquvi07mowh0idvygxnia" + + @verification = verification + @identity = verification.identity + @recipient = @identity.primary_email + + reason_line = @verification.try(:rejection_reason_name)&.downcase || @verification.rejection_reason.humanize.downcase + reason_line += " (#{@verification.rejection_reason_details})" if @verification.rejection_reason_details.present? + + if @verification.rejection_reason == "under_11" + reason_line += ". You can resubmit your application once you turn 11 years old" + end + + @datavariables = { + first_name: @identity.first_name, + reason_line:, + resubmit_url: document_onboarding_url + } + + send_it! + end + + def rejected_permanently(verification) + @TRANSACTIONAL_ID = "cmbgv0dcb03s5zx0ieso1prer" + + @verification = verification + @identity = verification.identity + @recipient = @identity.primary_email + + reason_line = @verification.try(:rejection_reason_name)&.downcase || @verification.rejection_reason.humanize.downcase + reason_line += " (#{@verification.rejection_reason_details})" if @verification.rejection_reason_details.present? + + @datavariables = { + first_name: @identity.first_name, + reason_line: + } + + send_it! + end + + def created(verification) + @TRANSACTIONAL_ID = "cmbiea17f0agt5p0i9ry4ca0n" + + @verification = verification + @identity = verification.identity + @recipient = @identity.primary_email + + @datavariables = { + first_name: @identity.first_name + } + + send_it! + end +end diff --git a/app/models/address.rb b/app/models/address.rb new file mode 100644 index 0000000..9bf9bd2 --- /dev/null +++ b/app/models/address.rb @@ -0,0 +1,56 @@ +# == Schema Information +# +# Table name: addresses +# +# id :bigint not null, primary key +# city :string +# country :integer +# first_name :string +# last_name :string +# line_1 :string +# line_2 :string +# postal_code :string +# state :string +# created_at :datetime not null +# updated_at :datetime not null +# identity_id :bigint not null +# +# Indexes +# +# index_addresses_on_identity_id (identity_id) +# +# Foreign Keys +# +# fk_rails_... (identity_id => identities.id) +# +class Address < ApplicationRecord + include PublicIdentifiable + has_paper_trail + set_public_id_prefix "addr" + + belongs_to :identity + + include CountryEnumable + has_country_enum + + GREMLINS = [ + "\u200E", # LEFT-TO-RIGHT MARK + "\u200B" # ZERO WIDTH SPACE + ].join + + def self.strip_gremlins(str) = str&.delete(GREMLINS)&.presence + + validates_presence_of :first_name, :line_1, :city, :state, :postal_code, :country + + before_validation :strip_gremlins_from_fields + + private def strip_gremlins_from_fields + self.first_name = Address.strip_gremlins(first_name) + self.last_name = Address.strip_gremlins(last_name) + self.line_1 = Address.strip_gremlins(line_1) + self.line_2 = Address.strip_gremlins(line_2) + self.city = Address.strip_gremlins(city) + self.state = Address.strip_gremlins(state) + self.postal_code = Address.strip_gremlins(postal_code) + end +end diff --git a/app/models/application_record.rb b/app/models/application_record.rb new file mode 100644 index 0000000..b63caeb --- /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/backend.rb b/app/models/backend.rb new file mode 100644 index 0000000..7d63350 --- /dev/null +++ b/app/models/backend.rb @@ -0,0 +1,5 @@ +module Backend + def self.table_name_prefix + "backend_" + end +end diff --git a/app/models/backend/organizer_position.rb b/app/models/backend/organizer_position.rb new file mode 100644 index 0000000..ee98ce1 --- /dev/null +++ b/app/models/backend/organizer_position.rb @@ -0,0 +1,29 @@ +# == Schema Information +# +# Table name: backend_organizer_positions +# +# id :bigint not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# backend_user_id :bigint not null +# program_id :bigint not null +# +# Indexes +# +# index_backend_organizer_positions_on_backend_user_id (backend_user_id) +# index_backend_organizer_positions_on_program_id (program_id) +# +# Foreign Keys +# +# fk_rails_... (backend_user_id => backend_users.id) +# fk_rails_... (program_id => oauth_applications.id) +# +class Backend::OrganizerPosition < ApplicationRecord + belongs_to :program, class_name: "Program", foreign_key: :program_id + belongs_to :backend_user, class_name: "Backend::User" + + # Ensure a backend user can only have one organizer position per program + validates :backend_user_id, uniqueness: { scope: :program_id } + validates :program_id, presence: true + validates :backend_user_id, presence: true +end diff --git a/app/models/backend/user.rb b/app/models/backend/user.rb new file mode 100644 index 0000000..ae6ce93 --- /dev/null +++ b/app/models/backend/user.rb @@ -0,0 +1,136 @@ +# == Schema Information +# +# Table name: backend_users +# +# id :bigint not null, primary key +# active :boolean +# all_fields_access :boolean +# can_break_glass :boolean +# human_endorser :boolean +# icon_url :string +# manual_document_verifier :boolean +# program_manager :boolean +# super_admin :boolean +# username :string +# created_at :datetime not null +# updated_at :datetime not null +# credential_id :string +# slack_id :string +# +# Indexes +# +# index_backend_users_on_slack_id (slack_id) +# +class Backend::User < ApplicationRecord + has_paper_trail + + # Organizer positions - programs this backend user organizes + has_many :organizer_positions, class_name: "Backend::OrganizerPosition", foreign_key: "backend_user_id", dependent: :destroy + has_many :organized_programs, through: :organizer_positions, source: :program, class_name: "Program" + + def self.authorize_url(redirect_uri) + params = { + client_id: ENV["SLACK_CLIENT_ID"], + redirect_uri: redirect_uri, + state: SecureRandom.hex(24), + user_scope: "users.profile:read,users:read,users:read.email" + } + + URI.parse("https://slack.com/oauth/v2/authorize?#{params.to_query}") + end + + def self.from_slack_token(code, redirect_uri) + # Exchange code for token + response = HTTP.post("https://slack.com/api/oauth.v2.access", form: { + client_id: ENV["SLACK_CLIENT_ID"], + client_secret: ENV["SLACK_CLIENT_SECRET"], + code: code, + redirect_uri: redirect_uri + }) + + data = JSON.parse(response.body.to_s) + + return nil unless data["ok"] + + # Get users info + user_response = HTTP.auth("Bearer #{data["authed_user"]["access_token"]}") + .get("https://slack.com/api/users.info?user=#{data["authed_user"]["id"]}") + + user_data = JSON.parse(user_response.body.to_s) + + return nil unless user_data["ok"] + + slack_id = data.dig("authed_user", "id") + + user = find_by(slack_id:) + + unless user + Honeybadger.notify("User #{slack_id} tried to sign into the backend without an account") + return nil + end + + unless user.active? + Honeybadger.notify("User #{slack_id} tried to sign into the backend while inactive") + return nil + end + + user.username ||= user_data.dig("user", "profile", "display_name_normalized") + user.username ||= user_data.dig("user", "profile", "real_name_normalized") + user.username ||= user_data.dig("user", "profile", "username") + user.icon_url = user_data.dig("user", "profile", "image_192") || user_data.dig("user", "profile", "image_72") + # Store the OAuth data + user.save! + user + end + + def activate! + update!(active: true) + end + + def deactivate! + update!(active: false) + end + + def pretty_roles + return "Super admin" if super_admin? + roles = [] + roles << "Program manager" if program_manager? + roles << "Document verifier" if manual_document_verifier? + roles << "Endorser" if human_endorser? + roles << "All fields" if all_fields_access? + roles.join(", ") + end + + # Handle organized program IDs for forms + def organized_program_ids + organized_programs.pluck(:id) + end + + def organized_program_ids=(program_ids) + @pending_program_ids = Array(program_ids).reject(&:blank?) + + # If the user is already persisted, update associations immediately + if persisted? + update_organized_programs + end + end + + # Callback to handle pending program IDs after save + after_save :update_organized_programs, if: -> { @pending_program_ids } + + private + + def update_organized_programs + return unless @pending_program_ids + + # Clear existing organizer positions + organizer_positions.destroy_all + + # Create new organizer positions for selected programs + @pending_program_ids.each do |program_id| + organizer_positions.create!(program_id: program_id) + end + + @pending_program_ids = nil + end +end diff --git a/app/models/break_glass_record.rb b/app/models/break_glass_record.rb new file mode 100644 index 0000000..ab34990 --- /dev/null +++ b/app/models/break_glass_record.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: break_glass_records +# +# id :bigint not null, primary key +# accessed_at :datetime not null +# automatic :boolean default(FALSE) +# break_glassable_type :string not null +# reason :text not null +# created_at :datetime not null +# updated_at :datetime not null +# backend_user_id :bigint not null +# break_glassable_id :bigint not null +# +# Indexes +# +# idx_on_backend_user_id_break_glassable_id_accessed__e06f302c56 (backend_user_id,break_glassable_id,accessed_at) +# idx_on_break_glassable_id_break_glassable_type_14e1e3ce71 (break_glassable_id,break_glassable_type) +# index_break_glass_records_on_backend_user_id (backend_user_id) +# index_break_glass_records_on_break_glassable_id (break_glassable_id) +# +# Foreign Keys +# +# fk_rails_... (backend_user_id => backend_users.id) +# +class BreakGlassRecord < ApplicationRecord + include PublicActivity::Model + tracked owner: ->(controller, model) { controller&.user_for_public_activity }, only: [ :create ] + + belongs_to :backend_user, class_name: "Backend::User" + belongs_to :break_glassable, polymorphic: true + + validates :reason, presence: true + validates :accessed_at, presence: true + + scope :for_user_and_document, ->(user, document) { where(backend_user: user, break_glassable: document) } + scope :recent, -> { where(accessed_at: 24.hours.ago..) } +end diff --git a/app/models/concerns/.keep b/app/models/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/models/concerns/country_enumable.rb b/app/models/concerns/country_enumable.rb new file mode 100644 index 0000000..ee48418 --- /dev/null +++ b/app/models/concerns/country_enumable.rb @@ -0,0 +1,280 @@ +# frozen_string_literal: true + +module CountryEnumable + extend ActiveSupport::Concern + UNSHIFTED_COUNTRIES = [ + [ "US", "United States" ], + [ "CA", "Canada" ] + ] + + included do + def self.countries_for_select + countries = self.countries.keys.map do |alpha2| + [ alpha2, ISO3166::Country[alpha2].common_name ] + end.sort_by { |c| I18n.transliterate(c.last) } + countries.unshift(*UNSHIFTED_COUNTRIES).uniq! + end + end + + class_methods do + def has_country_enum(field: :country) + enum field, self.country_enum_list, prefix: field + end + + private + + def country_enum_list + { + AD: 6, + AE: 235, + AF: 1, + AG: 10, + AI: 8, + AL: 3, + AM: 12, + AO: 7, + AQ: 9, + AR: 11, + AS: 5, + AT: 15, + AU: 14, + AW: 13, + AX: 2, + AZ: 16, + BA: 29, + BB: 20, + BD: 19, + BE: 22, + BF: 36, + BG: 35, + BH: 18, + BI: 37, + BJ: 24, + BL: 186, + BM: 25, + BN: 34, + BO: 27, + BQ: 28, + BR: 32, + BS: 17, + BT: 26, + BV: 31, + BW: 30, + BY: 21, + BZ: 23, + CA: 41, + CC: 48, + CD: 52, + CF: 43, + CG: 51, + CH: 217, + CI: 55, + CK: 53, + CL: 45, + CM: 40, + CN: 46, + CO: 49, + CR: 54, + CU: 57, + CV: 38, + CW: 58, + CX: 47, + CY: 59, + CZ: 60, + DE: 84, + DJ: 62, + DK: 61, + DM: 63, + DO: 64, + DZ: 4, + EC: 65, + EE: 70, + EG: 66, + EH: 246, + ER: 69, + ES: 210, + ET: 72, + FI: 76, + FJ: 75, + FK: 73, + FM: 145, + FO: 74, + FR: 77, + GA: 81, + GB: 236, + GD: 89, + GE: 83, + GF: 78, + GG: 93, + GH: 85, + GI: 86, + GL: 88, + GM: 82, + GN: 94, + GP: 90, + GQ: 68, + GR: 87, + GS: 208, + GT: 92, + GU: 91, + GW: 95, + GY: 96, + HK: 101, + HM: 98, + HN: 100, + HR: 56, + HT: 97, + HU: 102, + ID: 105, + IE: 108, + IL: 110, + IM: 109, + IN: 104, + IO: 33, + IQ: 107, + IR: 106, + IS: 103, + IT: 111, + JE: 114, + JM: 112, + JO: 115, + JP: 113, + KE: 117, + KG: 122, + KH: 39, + KI: 118, + KM: 50, + KN: 188, + KP: 119, + KR: 120, + KW: 121, + KY: 42, + KZ: 116, + LA: 123, + LB: 125, + LC: 189, + LI: 129, + LK: 211, + LR: 127, + LS: 126, + LT: 130, + LU: 131, + LV: 124, + LY: 128, + MA: 151, + MC: 147, + MD: 146, + ME: 149, + MF: 190, + MG: 133, + MH: 139, + MK: 165, + ML: 137, + MM: 153, + MN: 148, + MO: 132, + MP: 166, + MQ: 140, + MR: 141, + MS: 150, + MT: 138, + MU: 142, + MV: 136, + MW: 134, + MX: 144, + MY: 135, + MZ: 152, + NA: 154, + NC: 158, + NE: 161, + NF: 164, + NG: 162, + NI: 160, + NL: 157, + NO: 167, + NP: 156, + NR: 155, + NU: 163, + NZ: 159, + OM: 168, + PA: 172, + PE: 175, + PF: 79, + PG: 173, + PH: 176, + PK: 169, + PL: 178, + PM: 191, + PN: 177, + PR: 180, + PS: 171, + PT: 179, + PW: 170, + PY: 174, + QA: 181, + RE: 182, + RO: 183, + RS: 198, + RU: 184, + RW: 185, + SA: 196, + SB: 205, + SC: 199, + SD: 212, + SE: 216, + SG: 201, + SH: 187, + SI: 204, + SJ: 214, + SK: 203, + SL: 200, + SM: 194, + SN: 197, + SO: 206, + SR: 213, + SS: 209, + ST: 195, + SV: 67, + SX: 202, + SY: 218, + SZ: 71, + TC: 231, + TD: 44, + TF: 80, + TG: 224, + TH: 222, + TJ: 220, + TK: 225, + TL: 223, + TM: 230, + TN: 228, + TO: 226, + TR: 229, + TT: 227, + TV: 232, + TW: 219, + TZ: 221, + UA: 234, + UG: 233, + UM: 237, + US: 215, + UY: 238, + UZ: 239, + VA: 99, + VC: 192, + VE: 241, + VG: 243, + VI: 244, + VN: 242, + VU: 240, + WF: 245, + WS: 193, + YE: 247, + YT: 143, + ZA: 207, + ZM: 248, + ZW: 249 + } + end + end +end diff --git a/app/models/concerns/public_identifiable.rb b/app/models/concerns/public_identifiable.rb new file mode 100644 index 0000000..0bd4262 --- /dev/null +++ b/app/models/concerns/public_identifiable.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +# (@msw) Stripe-like public IDs that don't require adding a column to the database. +module PublicIdentifiable + extend ActiveSupport::Concern + + included do + include Hashid::Rails + class_attribute :public_id_prefix + end + + def public_id + "#{self.public_id_prefix}!#{hashid}" + end + + module ClassMethods + def set_public_id_prefix(prefix) + self.public_id_prefix = prefix.to_s.downcase + end + + def find_by_public_id(id) + return nil unless id.is_a? String + + prefix = id.split("!").first.to_s.downcase + hash = id.split("!").last + return nil unless prefix == self.get_public_id_prefix + + # ex. 'org_h1izp' + find_by_hashid(hash) + end + + def find_by_public_id!(id) + obj = find_by_public_id id + raise ActiveRecord::RecordNotFound.new(nil, self.name) if obj.nil? + + obj + end + + def get_public_id_prefix + return self.public_id_prefix.to_s.downcase if self.public_id_prefix.present? + + raise NotImplementedError, "The #{self.class.name} model includes PublicIdentifiable module, but set_public_id_prefix hasn't been called." + end + end +end diff --git a/app/models/identity.rb b/app/models/identity.rb new file mode 100644 index 0000000..aab1b3b --- /dev/null +++ b/app/models/identity.rb @@ -0,0 +1,296 @@ +# == Schema Information +# +# Table name: identities +# +# id :bigint not null, primary key +# aadhaar_number_bidx :string +# aadhaar_number_ciphertext :text +# birthday :date +# came_in_through_adult_program :boolean default(FALSE) +# country :integer +# deleted_at :datetime +# first_name :string +# hq_override :boolean default(FALSE) +# last_name :string +# legal_first_name :string +# legal_last_name :string +# permabanned :boolean default(FALSE) +# phone_number :string +# primary_email :string +# ysws_eligible :boolean +# created_at :datetime not null +# updated_at :datetime not null +# primary_address_id :bigint +# slack_id :string +# +# Indexes +# +# index_identities_on_aadhaar_number_bidx (aadhaar_number_bidx) UNIQUE +# index_identities_on_deleted_at (deleted_at) +# index_identities_on_primary_address_id (primary_address_id) +# index_identities_on_slack_id (slack_id) +# +# Foreign Keys +# +# fk_rails_... (primary_address_id => addresses.id) +# +class Identity < ApplicationRecord + has_paper_trail + acts_as_paranoid + include PublicActivity::Model + + tracked owner: ->(controller, model) { controller&.user_for_public_activity }, only: [ :create, :admin_update ] + + include CountryEnumable + + include PublicIdentifiable + set_public_id_prefix "ident" + + has_country_enum + + has_many :documents, class_name: "Identity::Document" + has_many :verifications, class_name: "Verification" + has_many :document_verifications, class_name: "Verification::DocumentVerification", dependent: :destroy + has_many :aadhaar_verifications, class_name: "Verification::AadhaarVerification" + has_many :vouch_verifications, class_name: "Verification::VouchVerification", dependent: :destroy + has_many :login_codes, class_name: "Identity::LoginCode" + has_many :addresses, class_name: "Address" + belongs_to :primary_address, class_name: "Address", optional: true + + has_many :access_tokens, -> { where(revoked_at: nil) }, class_name: "Doorkeeper::AccessToken", foreign_key: :resource_owner_id + has_many :programs, through: :access_tokens, source: :application + + has_many :resemblances, class_name: "Identity::Resemblance" + has_many :break_glass_records, as: :break_glassable, dependent: :destroy + + has_many :all_access_tokens, class_name: "Doorkeeper::AccessToken", foreign_key: :resource_owner_id + has_many :all_programs, through: :all_access_tokens, source: :application + + validates :first_name, :last_name, :country, :primary_email, :birthday, presence: true + validates :phone_number, presence: true, on: :create + validates :primary_email, uniqueness: true + validates :primary_email, 'valid_email_2/email': { mx: true, disposable: true } + + validates :slack_id, uniqueness: true, allow_blank: true + validates :aadhaar_number, uniqueness: true, allow_blank: true + validates :aadhaar_number, format: { with: /\A\d{12}\z/, message: "must be 12 digits" }, if: -> { aadhaar_number.present? } + + scope :search, ->(term) { + return all if term.blank? + + sanitized_term = "%#{term}%" + where( + "first_name ILIKE ? OR last_name ILIKE ? OR primary_email ILIKE ? OR slack_id ILIKE ?", + sanitized_term, sanitized_term, sanitized_term, sanitized_term + ) + } + + scope :with_fatal_rejections, -> { + joins(:verifications).where(verifications: { fatal: true, ignored_at: nil }) + } + + scope :verified_but_ysws_ineligible, -> { + joins(:verifications).where(verifications: { status: "approved", ignored_at: nil }).where(ysws_eligible: false) + } + + validate :birthday_must_be_at_least_six_years_ago + + has_encrypted :aadhaar_number + blind_index :aadhaar_number + + validate :legal_names_must_be_complete + + before_commit :copy_legal_name_if_needed, on: :create + + def self.slack_authorize_url(redirect_uri) + params = { + client_id: ENV["SLACK_CLIENT_ID"], + redirect_uri: redirect_uri, + state: SecureRandom.hex(24), + user_scope: "users.profile:read,users:read,users:read.email" + } + + URI.parse("https://slack.com/oauth/v2/authorize?#{params.to_query}") + end + + def self.link_slack_account(code, redirect_uri, current_identity) + response = HTTP.post("https://slack.com/api/oauth.v2.access", form: { + client_id: ENV["SLACK_CLIENT_ID"], + client_secret: ENV["SLACK_CLIENT_SECRET"], + code: code, + redirect_uri: redirect_uri + }) + + data = JSON.parse(response.body.to_s) + + return { success: false, error: "Failed to exchange OAuth code" } unless data["ok"] + + # Get user info + user_response = HTTP.auth("Bearer #{data["authed_user"]["access_token"]}") + .get("https://slack.com/api/users.info?user=#{data["authed_user"]["id"]}") + + user_data = JSON.parse(user_response.body.to_s) + + return { success: false, error: "Failed to get Slack user information" } unless user_data["ok"] + + slack_id = data.dig("authed_user", "id") + + existing_identity = find_by(slack_id: slack_id) + if existing_identity && existing_identity != current_identity + return { success: false, error: "This Slack account is already linked to another identity" } + end + + current_identity.update!(slack_id: slack_id) + + { success: true, slack_id: slack_id } + end + + def slack_linked? = slack_id.present? + + def onboarding_step + return :basic_info unless persisted? + + unless verifications.where(status: %w[approved pending]).any? + if country == "IN" && Flipper.enabled?(:authbridge_aadhaar_2025_07_10, self) + return :aadhaar + else + return :document + end + end + + return :address unless primary_address_id.present? + + :submitted + end + + def onboarding_complete? = onboarding_step == :submitted + + def needs_documents? = country != "IN" && onboarding_step == :document + + def needs_aadhaar? = country == "IN" && Flipper.enabled?(:authbridge_aadhaar_2025_07_10, self) && onboarding_step == :aadhaar + + def latest_verification = verifications.not_ignored.order(created_at: :desc).first + + # EWWWW + def verification_status + return "ineligible" if permabanned + + verfs = verifications.not_ignored + return "needs_submission" if verfs.empty? + + verification_statuses = verfs.pluck(:status) + + return "verified" if verification_statuses.include?("approved") + return "pending" if verification_statuses.include?("pending") + + rejected_verifications = verfs.where(status: "rejected") + + has_fatal_rejection = rejected_verifications.any?(&:fatal_rejection?) + + has_fatal_rejection ? "ineligible" : "needs_submission" + end + + def verification_status_reason + return nil unless latest_verification&.rejected? + + latest_verification.rejection_reason + end + + def verification_status_reason_details + return nil unless latest_verification&.rejected? + + latest_verification.rejection_reason_details + end + + def needs_resubmission? + # Only show rejection details and resubmission prompts if: + # 1. There are rejected verifications with retryable reasons + # 2. AND there are no pending verifications (user hasn't resubmitted yet) + verifications.not_ignored.retryable_rejections.any? && + !verifications.not_ignored.pending.any? + end + + def rejected_verifications_needing_resubmission + return Verification.none unless needs_resubmission? + + verifications.not_ignored.retryable_rejections + end + + def in_resubmission_flow? + # Show resubmission context if there are rejected verifications with retryable reasons + # This is used in the document form to show context about previous rejections + verification_status == "pending" && + verifications.not_ignored.retryable_rejections.any? + end + + def rejected_verifications_for_context + verifications.not_ignored.retryable_rejections + end + + # TODO: this is schnasty + def onboarding_redirect_path + return Rails.application.routes.url_helpers.basic_info_onboarding_path unless persisted? + + if country == "IN" && Flipper.enabled?(:authbridge_aadhaar_2025_07_10, self) + return Rails.application.routes.url_helpers.aadhaar_onboarding_path if needs_aadhaar_upload? + return Rails.application.routes.url_helpers.aadhaar_step_2_onboarding_path unless aadhaar_verifications.pending.any? + else + return Rails.application.routes.url_helpers.document_onboarding_path if needs_document_upload? + end + + return Rails.application.routes.url_helpers.address_onboarding_path unless primary_address_id.present? + + Rails.application.routes.url_helpers.submitted_onboarding_path + end + + def needs_document_upload? + return false if country == "IN" && Flipper.enabled?(:authbridge_aadhaar_2025_07_10, self) + return false if verification_status == "ineligible" + return true unless verifications.not_ignored.where(status: %w[approved pending]).any? + return false if verification_status == "verified" + needs_resubmission? + end + + def needs_aadhaar_upload? + return false unless country == "IN" + return false if verification_status == "ineligible" + return true unless verifications.not_ignored.where(status: %w[approved pending draft]).any? + return false if verification_status == "verified" + needs_resubmission? + end + + def under_11? = age <= 11 + + def age = (Date.today - birthday).days.in_years + + def suggested_aadhaar_password + name = "#{legal_first_name}#{legal_last_name}".presence || "#{first_name}#{last_name}" + "#{name.gsub(" ", "")[...4].upcase}#{birthday.year}" + end + + alias_method :to_param, :public_id + + private + + def copy_legal_name_if_needed + self.legal_first_name = first_name if legal_first_name.blank? + self.legal_last_name = last_name if legal_last_name.blank? + end + + def legal_names_must_be_complete + if legal_first_name.present? && legal_last_name.blank? + errors.add(:legal_last_name, "must be present when legal first name is provided") + elsif legal_last_name.present? && legal_first_name.blank? + errors.add(:legal_first_name, "must be present when legal last name is provided") + end + end + + def birthday_must_be_at_least_six_years_ago + return unless birthday.present? + + six_years_ago = Date.current - 6.years + if birthday > six_years_ago + errors.add(:base, "Are you sure about that birthday?") + end + end +end diff --git a/app/models/identity/aadhaar_record.rb b/app/models/identity/aadhaar_record.rb new file mode 100644 index 0000000..a8153fc --- /dev/null +++ b/app/models/identity/aadhaar_record.rb @@ -0,0 +1,40 @@ +# == Schema Information +# +# Table name: identity_aadhaar_records +# +# id :bigint not null, primary key +# date_of_birth :date +# deleted_at :datetime +# name :string +# raw_json_response :text +# created_at :datetime not null +# updated_at :datetime not null +# identity_id :bigint not null +# +# Indexes +# +# index_identity_aadhaar_records_on_identity_id (identity_id) +# +# Foreign Keys +# +# fk_rails_... (identity_id => identities.id) +# +class Identity::AadhaarRecord < ApplicationRecord + acts_as_paranoid + + belongs_to :identity + + has_one :verification, class_name: "Verification::AadhaarVerification", foreign_key: "aadhaar_record_id", dependent: :destroy + + encrypts :raw_json_response + + validates :raw_json_response, presence: true + validates :date_of_birth, presence: true + validates :name, presence: true + + has_many :break_glass_records, as: :break_glassable, dependent: :destroy + + def doc_json + JSON.parse(raw_json_response.strip, symbolize_names: true) + end +end diff --git a/app/models/identity/document.rb b/app/models/identity/document.rb new file mode 100644 index 0000000..34face5 --- /dev/null +++ b/app/models/identity/document.rb @@ -0,0 +1,106 @@ +# == Schema Information +# +# Table name: identity_documents +# +# id :bigint not null, primary key +# deleted_at :datetime +# document_type :integer +# created_at :datetime not null +# updated_at :datetime not null +# identity_id :bigint not null +# +# Indexes +# +# index_identity_documents_on_deleted_at (deleted_at) +# index_identity_documents_on_identity_id (identity_id) +# +# Foreign Keys +# +# fk_rails_... (identity_id => identities.id) +# +class Identity::Document < ApplicationRecord + acts_as_paranoid + + belongs_to :identity + has_one :verification, class_name: "Verification::DocumentVerification", foreign_key: "identity_document_id", dependent: :destroy + has_many_attached :files + has_many :break_glass_records, as: :break_glassable, class_name: "BreakGlassRecord", dependent: :destroy + + TRANSCRIPT_COUNTRIES = %w[US AU CA SG] + + enum :document_type, { + government_id: 0, + transcript: 1 + } + + FRIENDLY_NAMES = { + government_id: "Government-issued ID", + transcript: "Transcript & Student ID" + } + + validates :document_type, presence: true + validates :files, presence: true, on: :create + validate :correct_number_of_files, on: :create + validate :file_size_and_type + + def self.selectable_types_for_country(country) + if TRANSCRIPT_COUNTRIES.include?(country) + %i[transcript government_id] + else + %i[government_id] + end + end + + def self.collection_select_options_for_country(country) + selectable_types_for_country(country).map { |type| [ FRIENDLY_NAMES[type], type ] } + end + + def current_verification + verification + end + + def verification_status + current_verification&.status || "pending" + end + + def verified? + verification_status == "approved" + end + + def rejected? + verification_status == "rejected" + end + + def pending_verification? + verification_status == "pending" + end + + private + + def correct_number_of_files + return unless files.attached? + + required_count = transcript? ? 2 : 1 + actual_count = files.count + + if actual_count != required_count + errors.add(:files, "must include exactly #{required_count} file#{"s" if required_count > 1}") + end + end + + def file_size_and_type + return unless files.attached? + + files.each do |file| + # Check file size (max 10MB) + if file.byte_size > 10.megabytes + errors.add(:files, "#{file.filename} is too large (maximum is 10MB)") + end + + # Check file type + unless file.content_type.in?(%w[image/jpeg image/png image/jpg image/heic image/heif application/pdf]) + errors.add(:files, "#{file.filename} must be a JPEG, PNG, HEIC, or PDF file") + end + end + end +end diff --git a/app/models/identity/login_code.rb b/app/models/identity/login_code.rb new file mode 100644 index 0000000..a8cab8e --- /dev/null +++ b/app/models/identity/login_code.rb @@ -0,0 +1,73 @@ +# == Schema Information +# +# Table name: identity_login_codes +# +# id :bigint not null, primary key +# expires_at :datetime +# return_url :string +# token_bidx :string +# token_ciphertext :text +# used_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# identity_id :bigint not null +# +# Indexes +# +# index_identity_login_codes_on_identity_id (identity_id) +# +# Foreign Keys +# +# fk_rails_... (identity_id => identities.id) +# +class Identity::LoginCode < ApplicationRecord + belongs_to :identity + + validates :token, presence: true, uniqueness: true + validates :expires_at, presence: true + + before_validation :generate_token, on: :create + before_validation :set_expiration, on: :create + + scope :valid, -> { where("expires_at > ? AND used_at IS NULL", Time.current) } + + has_encrypted :token + blind_index :token + + def mark_used! + update!(used_at: Time.current) + end + + def to_param + token + end + + def self.generate(identity, return_url: nil) + # Expire any existing unused codes for this identity + identity.login_codes.valid.update_all(used_at: Time.current) + + create!(identity: identity, return_url: return_url) + end + + def active? + expires_at > Time.current && used_at.nil? + end + + def expired? + expires_at <= Time.current + end + + def used? + used_at.present? + end + + private + + def generate_token + self.token ||= "login.#{SecureRandom.urlsafe_base64(32)}" + end + + def set_expiration + self.expires_at ||= 30.minutes.from_now + end +end diff --git a/app/models/identity/resemblance.rb b/app/models/identity/resemblance.rb new file mode 100644 index 0000000..c6140dc --- /dev/null +++ b/app/models/identity/resemblance.rb @@ -0,0 +1,31 @@ +# == Schema Information +# +# Table name: identity_resemblances +# +# id :bigint not null, primary key +# type :string +# created_at :datetime not null +# updated_at :datetime not null +# document_id :bigint +# identity_id :bigint not null +# past_document_id :bigint +# past_identity_id :bigint not null +# +# Indexes +# +# index_identity_resemblances_on_document_id (document_id) +# index_identity_resemblances_on_identity_id (identity_id) +# index_identity_resemblances_on_past_document_id (past_document_id) +# index_identity_resemblances_on_past_identity_id (past_identity_id) +# +# Foreign Keys +# +# fk_rails_... (document_id => identity_documents.id) +# fk_rails_... (identity_id => identities.id) +# fk_rails_... (past_document_id => identity_documents.id) +# fk_rails_... (past_identity_id => identities.id) +# +class Identity::Resemblance < ApplicationRecord + belongs_to :identity + belongs_to :past_identity, class_name: "Identity" +end diff --git a/app/models/identity/resemblance/email_subaddress_resemblance.rb b/app/models/identity/resemblance/email_subaddress_resemblance.rb new file mode 100644 index 0000000..2293f5c --- /dev/null +++ b/app/models/identity/resemblance/email_subaddress_resemblance.rb @@ -0,0 +1,29 @@ +# == Schema Information +# +# Table name: identity_resemblances +# +# id :bigint not null, primary key +# type :string +# created_at :datetime not null +# updated_at :datetime not null +# document_id :bigint +# identity_id :bigint not null +# past_document_id :bigint +# past_identity_id :bigint not null +# +# Indexes +# +# index_identity_resemblances_on_document_id (document_id) +# index_identity_resemblances_on_identity_id (identity_id) +# index_identity_resemblances_on_past_document_id (past_document_id) +# index_identity_resemblances_on_past_identity_id (past_identity_id) +# +# Foreign Keys +# +# fk_rails_... (document_id => identity_documents.id) +# fk_rails_... (identity_id => identities.id) +# fk_rails_... (past_document_id => identity_documents.id) +# fk_rails_... (past_identity_id => identities.id) +# +class Identity::Resemblance::EmailSubaddressResemblance < Identity::Resemblance +end diff --git a/app/models/identity/resemblance/name_resemblance.rb b/app/models/identity/resemblance/name_resemblance.rb new file mode 100644 index 0000000..0fb4fef --- /dev/null +++ b/app/models/identity/resemblance/name_resemblance.rb @@ -0,0 +1,29 @@ +# == Schema Information +# +# Table name: identity_resemblances +# +# id :bigint not null, primary key +# type :string +# created_at :datetime not null +# updated_at :datetime not null +# document_id :bigint +# identity_id :bigint not null +# past_document_id :bigint +# past_identity_id :bigint not null +# +# Indexes +# +# index_identity_resemblances_on_document_id (document_id) +# index_identity_resemblances_on_identity_id (identity_id) +# index_identity_resemblances_on_past_document_id (past_document_id) +# index_identity_resemblances_on_past_identity_id (past_identity_id) +# +# Foreign Keys +# +# fk_rails_... (document_id => identity_documents.id) +# fk_rails_... (identity_id => identities.id) +# fk_rails_... (past_document_id => identity_documents.id) +# fk_rails_... (past_identity_id => identities.id) +# +class Identity::Resemblance::NameResemblance < Identity::Resemblance +end diff --git a/app/models/identity/resemblance/reused_document_resemblance.rb b/app/models/identity/resemblance/reused_document_resemblance.rb new file mode 100644 index 0000000..9451df3 --- /dev/null +++ b/app/models/identity/resemblance/reused_document_resemblance.rb @@ -0,0 +1,31 @@ +# == Schema Information +# +# Table name: identity_resemblances +# +# id :bigint not null, primary key +# type :string +# created_at :datetime not null +# updated_at :datetime not null +# document_id :bigint +# identity_id :bigint not null +# past_document_id :bigint +# past_identity_id :bigint not null +# +# Indexes +# +# index_identity_resemblances_on_document_id (document_id) +# index_identity_resemblances_on_identity_id (identity_id) +# index_identity_resemblances_on_past_document_id (past_document_id) +# index_identity_resemblances_on_past_identity_id (past_identity_id) +# +# Foreign Keys +# +# fk_rails_... (document_id => identity_documents.id) +# fk_rails_... (identity_id => identities.id) +# fk_rails_... (past_document_id => identity_documents.id) +# fk_rails_... (past_identity_id => identities.id) +# +class Identity::Resemblance::ReusedDocumentResemblance < Identity::Resemblance + belongs_to :document, class_name: "Identity::Document" + belongs_to :past_document, class_name: "Identity::Document" +end diff --git a/app/models/identity_program.rb b/app/models/identity_program.rb new file mode 100644 index 0000000..6968e24 --- /dev/null +++ b/app/models/identity_program.rb @@ -0,0 +1,27 @@ +# == Schema Information +# +# Table name: identity_programs +# +# id :bigint not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# identity_id :bigint not null +# program_id :bigint not null +# +# Indexes +# +# index_identity_programs_on_identity_id (identity_id) +# index_identity_programs_on_identity_id_and_program_id (identity_id,program_id) UNIQUE +# index_identity_programs_on_program_id (program_id) +# +# Foreign Keys +# +# fk_rails_... (identity_id => identities.id) +# fk_rails_... (program_id => programs.id) +# +class IdentityProgram < ApplicationRecord + belongs_to :identity + belongs_to :program + + validates :identity_id, uniqueness: { scope: :program_id } +end diff --git a/app/models/oauth_token.rb b/app/models/oauth_token.rb new file mode 100644 index 0000000..555eb87 --- /dev/null +++ b/app/models/oauth_token.rb @@ -0,0 +1,59 @@ +# == Schema Information +# +# Table name: oauth_access_tokens +# +# id :bigint not null, primary key +# expires_in :integer +# previous_refresh_token :string default(""), not null +# refresh_token :string +# resource_owner_type :string +# revoked_at :datetime +# scopes :string +# token_bidx :string +# token_ciphertext :text +# created_at :datetime not null +# application_id :bigint not null +# resource_owner_id :bigint +# +# Indexes +# +# index_oauth_access_tokens_on_application_id (application_id) +# index_oauth_access_tokens_on_refresh_token (refresh_token) UNIQUE +# index_oauth_access_tokens_on_resource_owner_id (resource_owner_id) +# index_oauth_access_tokens_on_token_bidx (token_bidx) UNIQUE +# polymorphic_owner_oauth_access_tokens (resource_owner_id,resource_owner_type) +# +# Foreign Keys +# +# fk_rails_... (application_id => oauth_applications.id) +# fk_rails_... (resource_owner_id => identities.id) +# +class OAuthToken < ApplicationRecord + include ::Doorkeeper::Orm::ActiveRecord::Mixins::AccessToken + + PREFIX = "idntk." + SIZE = 32 + + scope :not_expired, -> { where(expires_in: nil).or(where("(oauth_access_tokens.created_at + make_interval(secs => expires_in)) >= ?", Time.now)) } + scope :not_revoked, -> { where(revoked_at: nil).or(where(revoked_at: Time.now..)) } + + scope :accessible, -> { not_expired.and(not_revoked) } + + has_encrypted :token + blind_index :token + + belongs_to :resource_owner, class_name: "Identity" + + def generate_token + self.token = self.class.generate + end + + def active? + !revoked_at? && (expires_in.nil? || expires_in > 0) + end + + def self.generate(options = {}) + token_size = options.delete(:size) || SIZE + PREFIX + SecureRandom.urlsafe_base64(token_size) + end +end diff --git a/app/models/program.rb b/app/models/program.rb new file mode 100644 index 0000000..1a34864 --- /dev/null +++ b/app/models/program.rb @@ -0,0 +1,93 @@ +# == Schema Information +# +# Table name: oauth_applications +# +# id :bigint not null, primary key +# active :boolean default(TRUE) +# confidential :boolean default(TRUE), not null +# name :string not null +# program_key_bidx :string +# program_key_ciphertext :text +# redirect_uri :text not null +# scopes :string default(""), not null +# secret :string not null +# uid :string not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_oauth_applications_on_program_key_bidx (program_key_bidx) UNIQUE +# index_oauth_applications_on_uid (uid) UNIQUE +# +class Program < ApplicationRecord + self.table_name = "oauth_applications" + + include ::Doorkeeper::Orm::ActiveRecord::Mixins::Application + + AVAILABLE_SCOPES = [ + { name: "basic_info", description: "See basic information about you (email, name, verification status)" }, + { name: "legal_name", description: "See your legal name" }, + { name: "address", description: "View your mailing address(es)" }, + { name: "set_slack_id", description: "associate Slack IDs with identities" } + ].freeze + + has_many :access_grants, class_name: "Doorkeeper::AccessGrant", foreign_key: :application_id, dependent: :delete_all + has_many :identities, through: :access_grants, source: :resource_owner, source_type: "Identity" + + has_many :organizer_positions, class_name: "Backend::OrganizerPosition", foreign_key: :program_id, dependent: :destroy + has_many :organizers, through: :organizer_positions, source: :backend_user, class_name: "Backend::User" + + validates :name, presence: true + validates :uid, presence: true, uniqueness: true + validates :secret, presence: true + validates :redirect_uri, presence: true + validates :scopes, presence: true + + before_validation :generate_uid, on: :create + before_validation :generate_secret, on: :create + before_validation :generate_program_key, on: :create + + has_encrypted :program_key + blind_index :program_key + + def oauth_application = self + + # i forget why this is like this: + alias_method :application_id, :id + + def description = nil + def description? = false + + def description=(value) + end + + # + + def scopes_array + return [] if scopes.blank? + scopes.split(" ").reject(&:blank?) + end + + def scopes_array=(array) + self.scopes = Doorkeeper::OAuth::Scopes.from_array(Array(array).reject(&:blank?)).to_s + end + + def has_scope?(scope_name) = scopes.include?(scope_name.to_s) + + def authorized_for_identity?(identity) = authorized_tokens.exists?(resource_owner: identity) + + private + + def generate_uid + self.uid = SecureRandom.hex(16) if uid.blank? + end + + def generate_secret + self.secret = SecureRandom.hex(32) if secret.blank? + end + + def generate_program_key + self.program_key = "prgmk." + SecureRandom.hex(32) if program_key.blank? + end +end diff --git a/app/models/verification.rb b/app/models/verification.rb new file mode 100644 index 0000000..599ecba --- /dev/null +++ b/app/models/verification.rb @@ -0,0 +1,83 @@ +# == Schema Information +# +# Table name: verifications +# +# id :bigint not null, primary key +# aadhaar_link :string +# approved_at :datetime +# deleted_at :datetime +# fatal :boolean default(FALSE), not null +# ignored_at :datetime +# ignored_reason :string +# internal_rejection_comment :text +# issues :string default([]), is an Array +# pending_at :datetime +# rejected_at :datetime +# rejection_reason :string +# rejection_reason_details :string +# status :string not null +# type :string +# created_at :datetime not null +# updated_at :datetime not null +# aadhaar_external_transaction_id :string +# aadhaar_hc_transaction_id :string +# aadhaar_record_id :bigint +# identity_document_id :bigint +# identity_id :bigint not null +# +# Indexes +# +# index_verifications_on_aadhaar_record_id (aadhaar_record_id) +# index_verifications_on_deleted_at (deleted_at) +# index_verifications_on_fatal (fatal) +# index_verifications_on_identity_document_id (identity_document_id) +# index_verifications_on_identity_id (identity_id) +# index_verifications_on_type (type) +# +# Foreign Keys +# +# fk_rails_... (aadhaar_record_id => identity_aadhaar_records.id) +# fk_rails_... (identity_document_id => identity_documents.id) +# fk_rails_... (identity_id => identities.id) +# +class Verification < ApplicationRecord + acts_as_paranoid + + include AASM + include PublicActivity::Model + + has_paper_trail + + tracked owner: ->(controller, model) { controller&.user_for_public_activity }, only: [ :create, :ignored ] + + include PublicIdentifiable + set_public_id_prefix "verif" + + belongs_to :identity + belongs_to :identity_document, class_name: "Identity::Document", optional: true + + scope :rejected, -> { where(status: "rejected") } + scope :pending, -> { where(status: "pending") } + scope :approved, -> { where(status: "approved") } + scope :fatal_rejections, -> { rejected.where(fatal: true) } + scope :retryable_rejections, -> { rejected.where(fatal: false) } + scope :not_ignored, -> { where(ignored_at: nil) } + + def fatal_rejection? = rejected? && fatal? + def retryable_rejection? = rejected? && !fatal? + + alias_method :to_param, :public_id + + private + + def fatal_rejection_reason?(reason) + return false if reason.blank? + + reason = reason.to_s.downcase + + %w[ + duplicate + fraud + ].include?(reason) + end +end diff --git a/app/models/verification/aadhaar_verification.rb b/app/models/verification/aadhaar_verification.rb new file mode 100644 index 0000000..fb717c7 --- /dev/null +++ b/app/models/verification/aadhaar_verification.rb @@ -0,0 +1,159 @@ +# == Schema Information +# +# Table name: verifications +# +# id :bigint not null, primary key +# aadhaar_link :string +# approved_at :datetime +# deleted_at :datetime +# fatal :boolean default(FALSE), not null +# ignored_at :datetime +# ignored_reason :string +# internal_rejection_comment :text +# issues :string default([]), is an Array +# pending_at :datetime +# rejected_at :datetime +# rejection_reason :string +# rejection_reason_details :string +# status :string not null +# type :string +# created_at :datetime not null +# updated_at :datetime not null +# aadhaar_external_transaction_id :string +# aadhaar_hc_transaction_id :string +# aadhaar_record_id :bigint +# identity_document_id :bigint +# identity_id :bigint not null +# +# Indexes +# +# index_verifications_on_aadhaar_record_id (aadhaar_record_id) +# index_verifications_on_deleted_at (deleted_at) +# index_verifications_on_fatal (fatal) +# index_verifications_on_identity_document_id (identity_document_id) +# index_verifications_on_identity_id (identity_id) +# index_verifications_on_type (type) +# +# Foreign Keys +# +# fk_rails_... (aadhaar_record_id => identity_aadhaar_records.id) +# fk_rails_... (identity_document_id => identity_documents.id) +# fk_rails_... (identity_id => identities.id) +# +class Verification::AadhaarVerification < Verification + def document_type = "Aadhaar" + + belongs_to :aadhaar_record, class_name: "Identity::AadhaarRecord", foreign_key: "aadhaar_record_id", optional: true + + validates_presence_of :aadhaar_hc_transaction_id + before_validation :generate_local_transaction_id, on: :create + validates :rejection_reason, presence: true, if: :rejected? + validate :rejection_reason_details_present_when_reason_other + + aasm column: :status, timestamps: true, whiny_transitions: true do + state :draft, initial: true + state :pending + state :approved + state :rejected + + event :mark_pending do + transitions from: :draft, to: :pending + + after do + Verification::CheckDiscrepanciesJob.perform_later(self) + end + end + + event :approve do + transitions from: :pending, to: :approved + end + + event :mark_as_rejected do + transitions from: [ :draft, :pending ], to: :rejected + + before do |reason, details = nil| + self.rejection_reason = reason + self.rejection_reason_details = details + + self.fatal = fatal_rejection_reason?(reason) + end + + after do + if fatal_rejection? + VerificationMailer.rejected_permanently(self).deliver_later + Slack::NotifyGuardiansJob.perform_later(@verification.identity) + else + VerificationMailer.rejected_amicably(self).deliver_later + end + end + end + end + + def generate_link!(callback_url:, redirect_url:) + raise "this verification already has a link!" if aadhaar_link.present? + + data = AadhaarService.instance.generate_step_1_link( + callback_url:, redirect_url:, + trans_id: aadhaar_hc_transaction_id, + ) + + raise "error!: #{data[:msg]}" unless data[:status] == 1 + + update!( + aadhaar_link: data[:data][:url], + aadhaar_external_transaction_id: data[:ts_trans_id] + ) + + create_activity("create_link") + end + + enum :rejection_reason, { + # Retry-able issues + invalid_format: "invalid_format", + service_unavailable: "service_unavailable", + under_11: "under_11", + other: "other", + # Fatal issues + info_mismatch: "info_mismatch", + duplicate: "duplicate" + } + + # Define retry-able vs fatal rejection reasons + RETRYABLE_REJECTION_REASONS = %w[invalid_format service_unavailable under_11 other].freeze + FATAL_REJECTION_REASONS = %w[info_mismatch duplicate].freeze + + # Friendly names for rejection reasons + REJECTION_REASON_NAMES = { + # Retry-able issues + "invalid_format" => "Invalid Aadhaar format", + "service_unavailable" => "Aadhaar verification service unavailable", + "under_11" => "Submitter is under 11 years old", + "other" => "Other fixable issue", + # Fatal issues + "info_mismatch" => "Aadhaar information doesn't match profile", + "duplicate" => "This Aadhaar number is already registered" + }.freeze + + def rejection_reason_name = REJECTION_REASON_NAMES[rejection_reason] || rejection_reason + + private + + # Override to include Aadhaar-specific fatal rejection reasons + def fatal_rejection_reason?(reason) + return false if reason.blank? + + reason_str = reason.to_s + + super(reason) || FATAL_REJECTION_REASONS.include?(reason_str) + end + + def rejection_reason_details_present_when_reason_other + if rejection_reason == "other" && rejection_reason_details.blank? + errors.add(:rejection_reason_details, "must be provided when rejection reason is 'other'") + end + end + + def generate_local_transaction_id + self.aadhaar_hc_transaction_id = "HC!#{SecureRandom.uuid}" + end +end diff --git a/app/models/verification/document_verification.rb b/app/models/verification/document_verification.rb new file mode 100644 index 0000000..d837183 --- /dev/null +++ b/app/models/verification/document_verification.rb @@ -0,0 +1,146 @@ +# == Schema Information +# +# Table name: verifications +# +# id :bigint not null, primary key +# aadhaar_link :string +# approved_at :datetime +# deleted_at :datetime +# fatal :boolean default(FALSE), not null +# ignored_at :datetime +# ignored_reason :string +# internal_rejection_comment :text +# issues :string default([]), is an Array +# pending_at :datetime +# rejected_at :datetime +# rejection_reason :string +# rejection_reason_details :string +# status :string not null +# type :string +# created_at :datetime not null +# updated_at :datetime not null +# aadhaar_external_transaction_id :string +# aadhaar_hc_transaction_id :string +# aadhaar_record_id :bigint +# identity_document_id :bigint +# identity_id :bigint not null +# +# Indexes +# +# index_verifications_on_aadhaar_record_id (aadhaar_record_id) +# index_verifications_on_deleted_at (deleted_at) +# index_verifications_on_fatal (fatal) +# index_verifications_on_identity_document_id (identity_document_id) +# index_verifications_on_identity_id (identity_id) +# index_verifications_on_type (type) +# +# Foreign Keys +# +# fk_rails_... (aadhaar_record_id => identity_aadhaar_records.id) +# fk_rails_... (identity_document_id => identity_documents.id) +# fk_rails_... (identity_id => identities.id) +# +class Verification::DocumentVerification < Verification + def document_type + return nil unless identity_document + + Identity::Document::FRIENDLY_NAMES[identity_document.document_type.to_sym] + end + + belongs_to :identity_document, class_name: "Identity::Document" + + # This is the main verification type for document-based verifications + # All existing verification functionality lives here + + aasm column: :status, timestamps: true, whiny_transitions: true do + state :pending, initial: true + state :approved + state :rejected + + event :approve do + transitions from: :pending, to: :approved + end + + event :mark_as_rejected do + transitions from: :pending, to: :rejected + + before do |reason, details = nil| + self.rejection_reason = reason + self.rejection_reason_details = details + + # Set fatal flag for inherently fatal rejection reasons + self.fatal = fatal_rejection_reason?(reason) + end + + after do + if fatal_rejection? + VerificationMailer.rejected_permanently(self).deliver_later + Slack::NotifyGuardiansJob.perform_later(self.identity) + else + VerificationMailer.rejected_amicably(self).deliver_later + end + end + end + end + + # Override to make identity_document required for document verifications + + # Delegate document_type to the associated identity_document + + + enum :rejection_reason, { + # Retry-able issues + poor_quality: "poor_quality", + not_readable: "not_readable", + wrong_type: "wrong_type", + expired: "expired", + under_11: "under_11", + other: "other", + # Fatal issues + info_mismatch: "info_mismatch", + altered: "altered", + duplicate: "duplicate" + } + + # Define retry-able vs fatal rejection reasons + RETRYABLE_REJECTION_REASONS = %w[poor_quality not_readable wrong_type expired under_11 other].freeze + FATAL_REJECTION_REASONS = %w[info_mismatch altered duplicate].freeze + + # Friendly names for rejection reasons + REJECTION_REASON_NAMES = { + # Retry-able issues + "poor_quality" => "Poor image quality", + "not_readable" => "Document not readable", + "wrong_type" => "Wrong document type", + "expired" => "Expired document", + "under_11" => "Submitter is under 11 years old", + "other" => "Other fixable issue", + # Fatal issues + "info_mismatch" => "Information doesn't match profile", + "altered" => "Document appears altered/fraudulent", + "duplicate" => "This identity is a duplicate of another identity" + }.freeze + + validates :rejection_reason, presence: true, if: :rejected? + validate :rejection_reason_details_present_when_reason_other + + def rejection_reason_name = REJECTION_REASON_NAMES[rejection_reason] || rejection_reason + + private + + # Override to include document-specific fatal rejection reasons + def fatal_rejection_reason?(reason) + return false if reason.blank? + + reason_str = reason.to_s + + # Include base class fatal reasons plus document-specific ones + super(reason) || FATAL_REJECTION_REASONS.include?(reason_str) + end + + def rejection_reason_details_present_when_reason_other + if rejection_reason == "other" && rejection_reason_details.blank? + errors.add(:rejection_reason_details, "must be provided when rejection reason details is 'other'") + end + end +end diff --git a/app/models/verification/vouch_verification.rb b/app/models/verification/vouch_verification.rb new file mode 100644 index 0000000..327a0b9 --- /dev/null +++ b/app/models/verification/vouch_verification.rb @@ -0,0 +1,74 @@ +# == Schema Information +# +# Table name: verifications +# +# id :bigint not null, primary key +# aadhaar_link :string +# approved_at :datetime +# deleted_at :datetime +# fatal :boolean default(FALSE), not null +# ignored_at :datetime +# ignored_reason :string +# internal_rejection_comment :text +# issues :string default([]), is an Array +# pending_at :datetime +# rejected_at :datetime +# rejection_reason :string +# rejection_reason_details :string +# status :string not null +# type :string +# created_at :datetime not null +# updated_at :datetime not null +# aadhaar_external_transaction_id :string +# aadhaar_hc_transaction_id :string +# aadhaar_record_id :bigint +# identity_document_id :bigint +# identity_id :bigint not null +# +# Indexes +# +# index_verifications_on_aadhaar_record_id (aadhaar_record_id) +# index_verifications_on_deleted_at (deleted_at) +# index_verifications_on_fatal (fatal) +# index_verifications_on_identity_document_id (identity_document_id) +# index_verifications_on_identity_id (identity_id) +# index_verifications_on_type (type) +# +# Foreign Keys +# +# fk_rails_... (aadhaar_record_id => identity_aadhaar_records.id) +# fk_rails_... (identity_document_id => identity_documents.id) +# fk_rails_... (identity_id => identities.id) +# +class Verification::VouchVerification < Verification + def document_type = "Vouch" + + has_one_attached :evidence + has_many :break_glass_records, as: :break_glassable, class_name: "BreakGlassRecord", dependent: :destroy + + validates :evidence, presence: true + + + aasm column: :status, timestamps: true, whiny_transitions: true do + state :approved, initial: true + + event :approve do + transitions from: :pending, to: :approved + end + end + + def pending? = false + def rejected? = false + + def rejection_reason_name = rejection_reason + + private + + def fatal_rejection_reason?(reason) = false + + def rejection_reason_details_present_when_reason_other + if rejection_reason == "other" && rejection_reason_details.blank? + errors.add(:rejection_reason_details, "must be provided when rejection reason is 'other'") + end + end +end diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb new file mode 100644 index 0000000..5af1cdd --- /dev/null +++ b/app/policies/application_policy.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class ApplicationPolicy + attr_reader :user, :record + + def initialize(user, record) + @user = user + @record = record + end + + def index? = false + + def show? = false + + def create? = false + + def new? = create? + + def update? = false + + def edit? = update? + + def destroy? = false + + def user_is_manual_document_verifier? + user.present? && (user.manual_document_verifier? || user.super_admin?) + end + + class Scope + def initialize(user, scope) + @user = user + @scope = scope + end + + def resolve + raise NoMethodError, "You must define #resolve in #{self.class}" + end + + private + + attr_reader :user, :scope + end +end diff --git a/app/policies/backend/user_policy.rb b/app/policies/backend/user_policy.rb new file mode 100644 index 0000000..61d4565 --- /dev/null +++ b/app/policies/backend/user_policy.rb @@ -0,0 +1,13 @@ +class Backend::UserPolicy < ApplicationPolicy + def index? = user&.present? + + def show? = user&.present? + + def create? = user&.super_admin? + + def update? = user&.super_admin? + + def deactivate? = user&.super_admin? + + alias_method :activate?, :deactivate? +end diff --git a/app/policies/break_glass_record_policy.rb b/app/policies/break_glass_record_policy.rb new file mode 100644 index 0000000..373ea14 --- /dev/null +++ b/app/policies/break_glass_record_policy.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class BreakGlassRecordPolicy < ApplicationPolicy + def create? = user.present? && user.can_break_glass? +end diff --git a/app/policies/identity/document_policy.rb b/app/policies/identity/document_policy.rb new file mode 100644 index 0000000..d50b1bb --- /dev/null +++ b/app/policies/identity/document_policy.rb @@ -0,0 +1,10 @@ +class Identity::DocumentPolicy < ApplicationPolicy + def index? = user_is_manual_document_verifier? + + def show? = user_is_manual_document_verifier? + + def verify? = user_is_manual_document_verifier? + + alias_method :approve?, :verify? + alias_method :reject?, :verify? +end diff --git a/app/policies/identity_policy.rb b/app/policies/identity_policy.rb new file mode 100644 index 0000000..961c211 --- /dev/null +++ b/app/policies/identity_policy.rb @@ -0,0 +1,24 @@ +class IdentityPolicy < ApplicationPolicy + def index? = user.present? + + def show? = user.present? + + def update? = user.present? && (user.can_break_glass? || user.super_admin?) + + alias_method :clear_slack_id?, :update? + + class Scope < ApplicationPolicy::Scope + def resolve + if user.super_admin? || user.manual_document_verifier? + scope.all + elsif user.organized_programs.any? + program_ids = user.organized_programs.pluck(:id) + scope.joins(:access_tokens) + .where(oauth_access_tokens: { application_id: program_ids }) + .distinct + else + scope.none + end + end + end +end diff --git a/app/policies/program_policy.rb b/app/policies/program_policy.rb new file mode 100644 index 0000000..d10bd58 --- /dev/null +++ b/app/policies/program_policy.rb @@ -0,0 +1,41 @@ +class ProgramPolicy < ApplicationPolicy + def index? = user_is_program_manager? || user_has_assigned_programs? + + def show? = user_is_program_manager? || user_has_access_to_program? + + def create? = user_is_program_manager? + + def update? = user_is_program_manager? || user_has_access_to_program? + + def destroy? = user_is_program_manager? + + def update_basic_fields? = user_has_access_to_program? + + def update_scopes? = user_is_program_manager? + + class Scope < Scope + def resolve + if user.program_manager? || user.super_admin? + # Program managers and super admins can see all programs + scope.all + else + # Regular users can only see programs they are assigned to + scope.joins(:organizer_positions).where(backend_organizer_positions: { backend_user_id: user.id }) + end + end + end + + private + + def user_is_program_manager? + user.present? && (user.program_manager? || user.super_admin?) + end + + def user_has_assigned_programs? + user.present? && user.organized_programs.any? + end + + def user_has_access_to_program? + user_is_program_manager? || (user.present? && user.organized_programs.include?(record)) + end +end diff --git a/app/policies/verification/aadhaar_verification_policy.rb b/app/policies/verification/aadhaar_verification_policy.rb new file mode 100644 index 0000000..35ee611 --- /dev/null +++ b/app/policies/verification/aadhaar_verification_policy.rb @@ -0,0 +1,2 @@ +class Verification::AadhaarVerificationPolicy < VerificationPolicy +end diff --git a/app/policies/verification/document_verification_policy.rb b/app/policies/verification/document_verification_policy.rb new file mode 100644 index 0000000..28816c4 --- /dev/null +++ b/app/policies/verification/document_verification_policy.rb @@ -0,0 +1,2 @@ +class Verification::DocumentVerificationPolicy < VerificationPolicy +end diff --git a/app/policies/verification/vouch_verification_policy.rb b/app/policies/verification/vouch_verification_policy.rb new file mode 100644 index 0000000..f76dbf6 --- /dev/null +++ b/app/policies/verification/vouch_verification_policy.rb @@ -0,0 +1,3 @@ +class Verification::VouchVerificationPolicy < VerificationPolicy + def create? = user.super_admin? +end diff --git a/app/policies/verification_policy.rb b/app/policies/verification_policy.rb new file mode 100644 index 0000000..c3fd475 --- /dev/null +++ b/app/policies/verification_policy.rb @@ -0,0 +1,13 @@ +class VerificationPolicy < ApplicationPolicy + def index? = user_is_manual_document_verifier? + + def pending? = user_is_manual_document_verifier? + + def show? = user_is_manual_document_verifier? + + def approve? = user_is_manual_document_verifier? + + def reject? = user_is_manual_document_verifier? + + def ignore? = user&.super_admin? +end diff --git a/app/services/aadhaar_service.rb b/app/services/aadhaar_service.rb new file mode 100644 index 0000000..ad62e3a --- /dev/null +++ b/app/services/aadhaar_service.rb @@ -0,0 +1,7 @@ +module AadhaarService + class << self + def instance + @instance ||= (Rails.env.production? ? AadhaarService::Production : AadhaarService::Mock).new + end + end +end diff --git a/app/services/aadhaar_service/mock.rb b/app/services/aadhaar_service/mock.rb new file mode 100644 index 0000000..446c9d5 --- /dev/null +++ b/app/services/aadhaar_service/mock.rb @@ -0,0 +1,15 @@ +module AadhaarService + class Mock + def generate_step_1_link(callback_url:, redirect_url:, trans_id:) + sleep Random.random_number(2..7) + { + status: 1, + msg: "youuuuuuu", + ts_trans_id: "mrow_mrrp_external_of_#{trans_id}", + data: { + url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + } + } + end + end +end diff --git a/app/services/aadhaar_service/production.rb b/app/services/aadhaar_service/production.rb new file mode 100644 index 0000000..478aae1 --- /dev/null +++ b/app/services/aadhaar_service/production.rb @@ -0,0 +1,19 @@ +module AadhaarService + class AadhaarError < StandardError; end + class FaradayErrorWithResponse < Faraday::Middleware + def call(env) + @app.call(env) + rescue Faraday::Error => e + response_body = e.response&.dig(:body) || "No response body" + raise AadhaarError, "#{e.message}. Response: #{response_body}" + end + end + + class Production + # this is stubbed out because exposing the implementation details of how we communicated with our aadhaar provider + # opens up a route through which a malicious actor could cost us a lot of money. + + # if you think that's the dumbest thing you've ever heard, i'm absolutely with you. + # there is a reason we stopped using them, but this fact still remains... + end +end diff --git a/app/services/papers_please_engine.rb b/app/services/papers_please_engine.rb new file mode 100644 index 0000000..2347e09 --- /dev/null +++ b/app/services/papers_please_engine.rb @@ -0,0 +1,18 @@ +module PapersPleaseEngine + def self.run(verification) + tactics = case verification + when Verification::DocumentVerification + [] # maybe someday OCR documents & check for discrepancies? + when Verification::AadhaarVerification + [ AadhaarScrutinizer ] + end + + issues = tactics.flat_map do |tactic| + tactic.new(verification).run + end + + if issues.any? + verification.update(issues:) + end + end +end diff --git a/app/services/papers_please_engine/aadhaar_scrutinizer.rb b/app/services/papers_please_engine/aadhaar_scrutinizer.rb new file mode 100644 index 0000000..f742351 --- /dev/null +++ b/app/services/papers_please_engine/aadhaar_scrutinizer.rb @@ -0,0 +1,40 @@ +module PapersPleaseEngine + class AadhaarScrutinizer < Base + def run + identity = verification.identity + identity_first_name = identity.legal_first_name.presence || identity.first_name + identity_last_name = identity.legal_last_name.presence || identity.last_name + identity_date_of_birth = identity.birthday + identity_aadhaar_number = identity.aadhaar_number + + aadhaar_record = verification.aadhaar_record + + issues = [] + + split = aadhaar_record.name.split(" ") + aadhaar_first_name = split.first + aadhaar_last_name = split.last + + identity_name = "#{identity_first_name} #{identity_last_name}".downcase + aadhaar_name = "#{aadhaar_first_name} #{aadhaar_last_name}".downcase + + if identity_name != aadhaar_name + issues << if MiniLevenshtein.edit_distance(identity_name, aadhaar_name) > 4 + "Name doesn't seem to match" + else + "Name doesn't match exactly (this is probably fine)" + end + end + + if identity_date_of_birth != aadhaar_record.date_of_birth + issues << "Date of birth doesn't match" + end + + if identity_aadhaar_number[-4..] != aadhaar_record.doc_json.dig(:data, :aadhar_number)[-4..] + issues << "entered Aadhaar number might not match?" + end + + issues + end + end +end diff --git a/app/services/papers_please_engine/base.rb b/app/services/papers_please_engine/base.rb new file mode 100644 index 0000000..2c415f3 --- /dev/null +++ b/app/services/papers_please_engine/base.rb @@ -0,0 +1,13 @@ +module PapersPleaseEngine + class Base + attr_reader :verification + + def initialize(verification) + @verification = verification + end + + def run + raise NotImplementedError, "Subclasses must implement the run method" + end + end +end diff --git a/app/services/resemblance_noticer_engine.rb b/app/services/resemblance_noticer_engine.rb new file mode 100644 index 0000000..0e4127c --- /dev/null +++ b/app/services/resemblance_noticer_engine.rb @@ -0,0 +1,11 @@ +module ResemblanceNoticerEngine + TACTICS = [ NameSimilarity, DuplicateDocuments, EmailSubaddressing ] + + def self.run(identity) + results = TACTICS.flat_map do |tactic| + tactic.new(identity).run + end + + results.each &:save! + end +end diff --git a/app/services/resemblance_noticer_engine/base.rb b/app/services/resemblance_noticer_engine/base.rb new file mode 100644 index 0000000..3a0b683 --- /dev/null +++ b/app/services/resemblance_noticer_engine/base.rb @@ -0,0 +1,13 @@ +module ResemblanceNoticerEngine + class Base + attr_reader :identity + + def initialize(identity) + @identity = identity + end + + def run + raise NotImplementedError, "Subclasses must implement the run method" + end + end +end diff --git a/app/services/resemblance_noticer_engine/duplicate_documents.rb b/app/services/resemblance_noticer_engine/duplicate_documents.rb new file mode 100644 index 0000000..1d29277 --- /dev/null +++ b/app/services/resemblance_noticer_engine/duplicate_documents.rb @@ -0,0 +1,22 @@ +module ResemblanceNoticerEngine + class DuplicateDocuments < Base + def run + checksums = identity.documents.joins(files_attachments: :blob).pluck("active_storage_blobs.checksum") + return [] if checksums.empty? + + Identity::Document.joins(files_attachments: :blob) + .joins(:identity) + .where(active_storage_blobs: { checksum: checksums }) + .where.not(identity: identity) + .includes(:identity, files_attachments: :blob) + .map do |duplicate_doc| + Identity::Resemblance::ReusedDocumentResemblance.new( + identity: identity, + past_identity: duplicate_doc.identity, + document: duplicate_doc, + past_document: duplicate_doc, + ) + end + end + end +end diff --git a/app/services/resemblance_noticer_engine/email_subaddressing.rb b/app/services/resemblance_noticer_engine/email_subaddressing.rb new file mode 100644 index 0000000..14e4182 --- /dev/null +++ b/app/services/resemblance_noticer_engine/email_subaddressing.rb @@ -0,0 +1,44 @@ +module ResemblanceNoticerEngine + class EmailSubaddressing < Base + def run + return [] if identity.primary_email.blank? + + base_email = extract_base_email(identity.primary_email) + + # i'm still not convinced i understand why this SQL works... + # theoretically it turns nora+1@hackclub.com and n.o.ra@hackclub.com into nora@hackclub.com? + normalized_email_sql = <<~SQL.squish + CONCAT( + REPLACE(SPLIT_PART(SPLIT_PART(primary_email, '@', 1), '+', 1), '.', ''), + '@', + SPLIT_PART(primary_email, '@', 2) + ) + SQL + + similar_identities = Identity.where.not(id: identity.id) + .where("#{normalized_email_sql} = ?", base_email) + .where.not(primary_email: identity.primary_email) # not this one lol! + + similar_identities.map do |similar_identity| + other_base_email = extract_base_email(similar_identity.primary_email) + next unless other_base_email == base_email + + Identity::Resemblance::EmailSubaddressResemblance.new( + identity: identity, + past_identity: similar_identity, + ) + end.compact + end + + private + + def extract_base_email(email) + local_part, domain = email.split("@", 2) + + base_local_part = local_part.split("+").first + base_local_part = base_local_part.gsub(".", "") + + "#{base_local_part}@#{domain}" + end + end +end diff --git a/app/services/resemblance_noticer_engine/name_similarity.rb b/app/services/resemblance_noticer_engine/name_similarity.rb new file mode 100644 index 0000000..b2e1217 --- /dev/null +++ b/app/services/resemblance_noticer_engine/name_similarity.rb @@ -0,0 +1,34 @@ +module ResemblanceNoticerEngine + class NameSimilarity < Base + def run + # for now, just exact matches. + # TODO: levenshtein or smth in the future? + + # Check all combinations of first_name/legal_first_name and last_name/legal_last_name + query = Identity.none + + # Collect all possible first name and last name values from the identity (case insensitive) + first_names = [ identity.first_name, identity.legal_first_name ].compact_blank.map(&:downcase).uniq + last_names = [ identity.last_name, identity.legal_last_name ].compact_blank.map(&:downcase).uniq + + # i feel like this could be better... + first_names.each do |fname| + last_names.each do |lname| + query = query.or( + Identity.where("LOWER(first_name) = ? AND LOWER(last_name) = ?", fname, lname) + .or(Identity.where("LOWER(legal_first_name) = ? AND LOWER(legal_last_name) = ?", fname, lname)) + .or(Identity.where("LOWER(first_name) = ? AND LOWER(legal_last_name) = ?", fname, lname)) + .or(Identity.where("LOWER(legal_first_name) = ? AND LOWER(last_name) = ?", fname, lname)) + ) + end + end + + query.where.not(id: identity.id).map do |similar_identity| + Identity::Resemblance::NameResemblance.new( + identity: identity, + past_identity: similar_identity, + ) + end + end + end +end diff --git a/app/services/slack_service.rb b/app/services/slack_service.rb new file mode 100644 index 0000000..57b8aa0 --- /dev/null +++ b/app/services/slack_service.rb @@ -0,0 +1,7 @@ +module SlackService + class << self + def client = @client ||= Slack::Web::Client.new + + def find_by_email(email) = client.users_lookupByEmail(email:).dig("user", "id") rescue nil + end +end diff --git a/app/views/aadhaar/digilocker_link.html.erb b/app/views/aadhaar/digilocker_link.html.erb new file mode 100644 index 0000000..3a0f0a7 --- /dev/null +++ b/app/views/aadhaar/digilocker_link.html.erb @@ -0,0 +1 @@ +<%= link_to "continue!", digilocker_redirect_aadhaar_path, target: "_blank", role: "button" %> diff --git a/app/views/addresses/_form.html.erb b/app/views/addresses/_form.html.erb new file mode 100644 index 0000000..d50a5bf --- /dev/null +++ b/app/views/addresses/_form.html.erb @@ -0,0 +1,39 @@ +<%= form_with model: address, url: local_assigns[:url], local: true do |f| %> + <% if local_assigns[:from_program] %> + <%= f.hidden_field :from_program, value: true %> + <% end %> + <% if address.errors.any? %> +

+ <% end %> + +
+ Address Details +
+ <%= f.text_field :first_name, placeholder: "First name", required: true %> + <%= f.text_field :last_name, placeholder: "Last name", required: true %> +
+ <%= f.text_field :line_1, placeholder: "Address line 1", required: true %> + <%= f.text_field :line_2, placeholder: "Address line 2 (optional)" %> +
+ <%= f.text_field :city, placeholder: "City", required: true %> + <%= f.text_field :state, placeholder: "State/Province", required: true %> +
+ <%= f.text_field :postal_code, placeholder: "Postal code", required: true %> + <%= f.label :country, "Country" %> + <%= f.collection_select :country, Address.countries_for_select, + :first, :last, + { include_blank: "Select a country" }, + { required: true } %> +
+ +
+ <%= f.submit local_assigns[:submit] || "Save Address" %> +
+<% end %> diff --git a/app/views/addresses/edit.html.erb b/app/views/addresses/edit.html.erb new file mode 100644 index 0000000..e39bcf8 --- /dev/null +++ b/app/views/addresses/edit.html.erb @@ -0,0 +1,6 @@ +

Edit Address

+ +<%= render 'form', address: @address %> + +<%= link_to "Show", @address %> | +<%= link_to "Back", addresses_path %> diff --git a/app/views/addresses/index.html.erb b/app/views/addresses/index.html.erb new file mode 100644 index 0000000..9c98a69 --- /dev/null +++ b/app/views/addresses/index.html.erb @@ -0,0 +1,38 @@ +

your addresses

+<%= render Components::HomeButton.new %>
+
+<%= link_to "add a new address", new_address_path, class: "primary" %> +<% if @addresses.any? %> +
+ <% @addresses.each do |address| %> +
+
+

<%= address.first_name %> <%= address.last_name %> + <% if current_identity.primary_address == address %> + 🏠 + <% end %> +

+

+ <%= address.line_1 %>
+ <% if address.line_2.present? %> + <%= address.line_2 %>
+ <% end %> + <%= address.city %>, <%= address.state %> <%= address.postal_code %>
+ <%= address.country %> +

+
+
+ <%= button_to "edit", edit_address_path(address), method: :get, style: "display: inline;" %> + <%= button_to "delete", address, method: :delete, + data: { confirm: "Are you sure?" }, style: "display: inline;" %> + <% unless current_identity.primary_address == address %> + <%= button_to "make primary", address_path(address, make_primary: true), + method: :patch, style: "display: inline;" %> + <% end %> +
+
+ <% end %> +
+<% else %> +

You haven't added any addresses yet.

+<% end %> diff --git a/app/views/addresses/new.html.erb b/app/views/addresses/new.html.erb new file mode 100644 index 0000000..7d4ada7 --- /dev/null +++ b/app/views/addresses/new.html.erb @@ -0,0 +1,3 @@ +

add your address!

+<%= render 'form', address: @address %> +<%= link_to "Back", addresses_path %> diff --git a/app/views/addresses/program_create_address.html.erb b/app/views/addresses/program_create_address.html.erb new file mode 100644 index 0000000..475cf87 --- /dev/null +++ b/app/views/addresses/program_create_address.html.erb @@ -0,0 +1,3 @@ +

we'll need your address...

+(please use a real address, we're going to send you something here!)

+<%= render 'form', address: @address, submit: "continue", from_program: true %> diff --git a/app/views/addresses/show.html.erb b/app/views/addresses/show.html.erb new file mode 100644 index 0000000..80efdff --- /dev/null +++ b/app/views/addresses/show.html.erb @@ -0,0 +1,2 @@ +

Addresses#show

+

Find me in app/views/addresses/show.html.erb

diff --git a/app/views/api/v1/addresses/_address.jb b/app/views/api/v1/addresses/_address.jb new file mode 100644 index 0000000..b0aacba --- /dev/null +++ b/app/views/api/v1/addresses/_address.jb @@ -0,0 +1,12 @@ +{ + id: address.public_id, + first_name: address.first_name, + last_name: address.last_name, + line_1: address.line_1, + line_2: address.line_2, + city: address.city, + state: address.state, + postal_code: address.postal_code, + country: address.country, + primary: address.id == address&.identity&.primary_address&.id +}.compact_blank diff --git a/app/views/api/v1/identities/_identity.jb b/app/views/api/v1/identities/_identity.jb new file mode 100644 index 0000000..d457259 --- /dev/null +++ b/app/views/api/v1/identities/_identity.jb @@ -0,0 +1,31 @@ +ident = { + id: identity.public_id, + ysws_eligible: identity.ysws_eligible, + verification_status: identity.verification_status, + verification_status_reason: identity.verification_status_reason, + rejection_reason: identity.verification_status_reason, + rejection_reason_details: identity.verification_status_reason_details, + birthday: identity.birthday +} + +scope "basic_info" do + ident[:first_name] = identity.first_name + ident[:last_name] = identity.last_name + ident[:primary_email] = identity.primary_email + ident[:slack_id] = identity.slack_id + ident[:primary_email] = identity.primary_email + ident[:phone_number] = identity.phone_number +end + +scope "legal_name" do + ident[:legal_first_name] = identity.legal_first_name + ident[:legal_last_name] = identity.legal_last_name +end + +scope "address" do + ident[:addresses] = identity.addresses.map do |address| + render address + end +end + +ident.compact_blank diff --git a/app/views/api/v1/identities/index.jb b/app/views/api/v1/identities/index.jb new file mode 100644 index 0000000..27611bf --- /dev/null +++ b/app/views/api/v1/identities/index.jb @@ -0,0 +1,3 @@ +{ + identities: @identities.map { |identity| render(identity) } +} diff --git a/app/views/api/v1/identities/me.jb b/app/views/api/v1/identities/me.jb new file mode 100644 index 0000000..c7bfc12 --- /dev/null +++ b/app/views/api/v1/identities/me.jb @@ -0,0 +1,4 @@ +{ + identity: render(@identity), + scopes: current_scopes +} diff --git a/app/views/api/v1/identities/show.jb b/app/views/api/v1/identities/show.jb new file mode 100644 index 0000000..1b3d525 --- /dev/null +++ b/app/views/api/v1/identities/show.jb @@ -0,0 +1,3 @@ +{ + identity: render(@identity) +} diff --git a/app/views/backend/audit_logs/index.html.erb b/app/views/backend/audit_logs/index.html.erb new file mode 100644 index 0000000..342777c --- /dev/null +++ b/app/views/backend/audit_logs/index.html.erb @@ -0,0 +1,12 @@ +<%= render Components::Window.new("Audit Logs", close_url: backend_root_path, max_width: 1000) do %> + Filter: +
+ <% if params[:admin_actions_only] %> + <%= link_to "Show all actions", backend_audit_logs_path, method: :get, class: "button" %> + <% else %> + <%= link_to "Show only admin actions", backend_audit_logs_path(admin_actions_only: true), class: "button" %> + <% end %> +
+ <%= render Components::PublicActivity::Container.new(@activities) %> + <%= paginate @activities %> +<% end %> diff --git a/app/views/backend/dashboard/show.html.erb b/app/views/backend/dashboard/show.html.erb new file mode 100644 index 0000000..64e858f --- /dev/null +++ b/app/views/backend/dashboard/show.html.erb @@ -0,0 +1,114 @@ +<%= render Components::Window.new("Dashboard - Identity Vault (#{Rails.env.upcase})", close_url: backend_root_path, max_width: 1000) do %> +
+
+

Verification Dashboard

+ <%= form_tag backend_dashboard_path, method: :get, style: "margin: 0;" do %> + <%= select_tag :time_period, + options_for_select([ + ["Today", "today"], + ["This Month", "this_month"], + ["All Time", "all_time"] + ], @time_period), + onchange: "this.form.submit()" %> + <% end %> +
+ +
+
+
<%= @stats[:total] %>
+
Total Submissions
+
+
+
<%= @stats[:approved] %>
+
Approved
+
+
+
<%= @stats[:rejected] %>
+
Rejected
+
+
+
<%= @stats[:pending] %>
+
Pending
+
+
+
+ <%= distance_of_time_in_words 0, @stats[:average_hangtime] %> +
+
Avg. Pending Time
+
+
+ +
+ +
+
🏆 Leaderboard
+ <% if @leaderboard.any? %> +
+ + + + + + + + + + <% @leaderboard.each_with_index do |entry, index| %> + <% + bg_color = case index + when 0 then "#fef3c7" # Gold background + when 1 then "#f3f4f6" # Silver background + else "transparent" + end + text_color = index < 2 ? "#ca8a04" : "#374151" + %> + + + + + + <% end %> + +
RankUserProcessed
+ <%= index == 0 ? "👑 #{index + 1}" : index + 1 %> + + <%= render entry[:user] %><%= entry[:processed_count] %>
+
+ <% else %> +

No processing activity yet

+ <% end %> +
+ +
+
📊 Rejection Reasons
+ <% if @rejection_breakdown.any? %> +
+ + + + + + + + + + <% total_rejected = @stats[:rejected] %> + <% @rejection_breakdown.each do |reason, data| %> + <% color = data[:fatal] ? "#dc2626" : "#f59e0b" %> + <% bg_color = data[:fatal] ? "#fef2f2" : "#fffbeb" %> + + + + + + <% end %> + +
ReasonCount%
<%= reason %><%= data[:count] %><%= total_rejected > 0 ? "#{(data[:count].to_f / total_rejected * 100).round(1)}%" : "0%" %>
+
+ <% else %> +

No rejections yet

+ <% end %> +
+
+
+<% end %> diff --git a/app/views/backend/identities/_identity.html.erb b/app/views/backend/identities/_identity.html.erb new file mode 100644 index 0000000..aa36a1a --- /dev/null +++ b/app/views/backend/identities/_identity.html.erb @@ -0,0 +1 @@ +<%= render Components::UserMention.new(identity) %> diff --git a/app/views/backend/identities/edit.html.erb b/app/views/backend/identities/edit.html.erb new file mode 100644 index 0000000..17bdac7 --- /dev/null +++ b/app/views/backend/identities/edit.html.erb @@ -0,0 +1,87 @@ +<%= render Components::Window.new("Edit Identity: #{@identity.first_name} #{@identity.last_name}", close_url: backend_identity_path(@identity), max_width: 600) do %> +
+ <% if @identity.errors.any? %> +
+

<%= pluralize(@identity.errors.count, "error") %> prohibited this identity from being saved:

+
    + <% @identity.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+ <% end %> + <%= form_with model: [@identity], url: backend_identity_path(@identity), method: :patch, local: true do |f| %> +
+ Identity Information +
+ <%= f.label :first_name %> + <%= f.text_field :first_name %> +
+
+ <%= f.label :last_name %> + <%= f.text_field :last_name %> +
+
+ <%= f.label :legal_first_name, "Legal First Name" %> + <%= f.text_field :legal_first_name %> +
+
+ <%= f.label :legal_last_name, "Legal Last Name" %> + <%= f.text_field :legal_last_name %> +
+
+ <%= f.label :primary_email, "Email" %> + <%= f.email_field :primary_email %> +
+
+ <%= f.label :phone_number, "Phone Number" %> + <%= f.telephone_field :phone_number %> +
+
+ <%= f.label :birthday, "Date of Birth" %> + <%= f.date_field :birthday %> +
+
+ <%= f.label :country, "Country" %> + <%= f.select :country, options_for_select(Identity.countries_for_select.map {|code, name| [name, code]}, @identity.country), { prompt: "Select a country" } %> +
+
+ <%= f.label :hq_override, "HQ Override" %> + <%= f.check_box :hq_override %> + Check this to override normal eligibility rules +
+
+ <%= f.label :ysws_eligible, "YSWS Eligible" %> + <%= f.check_box :ysws_eligible %> + Check if eligible for YSWS +
+ <% super_admin_tool do %> +
+ <%= f.label :permabanned, "Permabanned" %> + <%= f.check_box :permabanned %> + Check to permanently ban this identity (makes ineligible) +
+ <% end %> +
+ <%= label_tag :reason, "Reason for Change (required)" %> + <%= text_area_tag :reason, params[:reason], placeholder: "Please provide a detailed reason for these changes. This will be logged in the audit trail.", rows: 4 %> +
+ This reason will be recorded in the audit log and is required for all identity changes. +
+
+ <%= link_to "Cancel", backend_identity_path(@identity), class: "button" %> + <%= f.submit "Update Identity", class: "button" %> +
+
+ <% end %> +
+

⚠️ Break Glass Access Required

+

+ This functionality requires break glass permissions. All changes will be permanently logged in the audit trail with your user information and the provided reason. +

+
+ <% if @identity.slack_id.present? || true %> + <%= button_to "Clear Slack ID", clear_slack_id_backend_identity_path(@identity) %> + <% end %> +
+<% end %> diff --git a/app/views/backend/identities/index.html.erb b/app/views/backend/identities/index.html.erb new file mode 100644 index 0000000..a9f5ab9 --- /dev/null +++ b/app/views/backend/identities/index.html.erb @@ -0,0 +1,50 @@ +<%= render Components::Window.new("Identities", close_url: backend_root_path, max_width: 1000) do %> +
+ + <%= form_with url: backend_identities_path, method: :get, local: true, class: "search-form" do |f| %> +
+
+ <%= f.search_field :search, value: params[:search], placeholder: "Search identities...", class: "form-control" %> +
+
+ <%= f.submit "Search", class: "btn btn-primary" %> + <% if params[:search].present? %> + <%= link_to "Clear", backend_identities_path, class: "btn btn-secondary" %> + <% end %> +
+
+ <% end %> + + <% if params[:search].present? %> +

+ Found <%= pluralize(@identities.total_count, 'identity') %> matching "<%= params[:search] %>" +

+ <% end %> + + + + + + + + + + + + <% @identities.each do |identity| %> + + + + + + + <% end %> + +
NameEmailVerification StatusYSWS Eligible?
<%= render identity %><%= identity.primary_email %><%= identity.verification_status %><%= identity.ysws_eligible.nil? ? "?" : render_checkbox(identity.ysws_eligible) %>
+ + +
+ <%= paginate @identities, params: { search: params[:search] } %> +
+
+<% end %> diff --git a/app/views/backend/identities/new_vouch.html.erb b/app/views/backend/identities/new_vouch.html.erb new file mode 100644 index 0000000..b6fa507 --- /dev/null +++ b/app/views/backend/identities/new_vouch.html.erb @@ -0,0 +1,11 @@ +<%= render Components::Window.new("Create Vouch Verification for #{@identity.first_name} #{@identity.last_name}", close_url: backend_identity_path(@identity)) do %> +
+ <%= form_with model: [@identity, @vouch], url: create_vouch_backend_identity_path(@identity), method: :post, local: true do |f| %> +
+ <%= f.label :evidence, "Upload Evidence Picture" %> + <%= f.file_field :evidence, required: true %> +
+ <%= f.submit "Create Vouch" %> + <% end %> +
+<% end %> diff --git a/app/views/backend/identities/show.html.erb b/app/views/backend/identities/show.html.erb new file mode 100644 index 0000000..aa1a79a --- /dev/null +++ b/app/views/backend/identities/show.html.erb @@ -0,0 +1,225 @@ +<%= render Components::Window.new("Identity: #{@identity.first_name} #{@identity.last_name}", close_url: backend_identities_path, max_width: 1000) do %> + <% break_glass_tool do %> +
+ <%= link_to "Edit Identity", edit_backend_identity_path(@identity), class: "button primary", style: "background-color: #dc2626; border-color: #dc2626;" %> +
+ <% end %> +
+ +
+

Identity Information

+ <% if @identity.permabanned %> + + <% end %> + <%= render Components::Identity.new(@identity, show_legal_name: @available_scopes.include?("legal_name")) %> +
+ + <% if @available_scopes.include?("address") %> +
+

Addresses

+ <% if @addresses.any? %> + + + + + + + + + + + + + <% @addresses.each do |address| %> + + + + + + + + + <% end %> + +
NameAddressCity, StatePostal CodeCountryPrimary
+ <%= address.first_name %> <%= address.last_name %> + + <%= address.line_1 %> + <% if address.line_2.present? %> +
+ <%= address.line_2 %> + <% end %> +
<%= address.city %>, <%= address.state %><%= address.postal_code %><%= address.country %> + <% if @identity.primary_address == address %> + Yes + <% else %> + — + <% end %> +
+ <% else %> +
+

No addresses on file.

+
+ <% end %> +
+ <% end %> + +
+

Verifications

+ <% super_admin_tool do %> + <%= link_to "Create Vouch Verification", new_vouch_backend_identity_path(@identity), class: "button" %> + <% end %> + <% if @verifications.any? %> + + + + + + + + + + + + <% @verifications.each do |verification| %> + + <% case verification %> + <% when Verification::AadhaarVerification %> + + + + + + <% when Verification::DocumentVerification %> + + + + + + <% when Verification::VouchVerification %> + + + + + + <% end %> + + <% end %> + +
Document TypeStatusSubmittedRejection ReasonActions
+ Aadhaar + <% if verification.ignored_at.present? %> +
+ (Ignored) + <% end %> +
+ <%= verification.status.humanize %> + + <%= verification.pending_at&.strftime("%b %d, %Y") || "not yet" %> + + <% if verification.ignored_at.present? %> + Ignored: <%= verification.ignored_reason %> + <% elsif verification.rejection_reason.present? %> + <%= verification.rejection_reason_name %> + <% else %> + — + <% end %> + + <%= link_to "View", backend_verification_path(verification), class: "link" %> + + <%= Identity::Document::FRIENDLY_NAMES[verification.identity_document.document_type.to_sym] %> + <% if verification.ignored_at.present? %> +
+ (Ignored) + <% end %> +
+ + <%= verification.status.humanize %> + + <%= verification.created_at.strftime("%b %d, %Y") %> + <% if verification.ignored_at.present? %> + Ignored: <%= verification.ignored_reason %> + <% elsif verification.rejection_reason.present? %> + <%= verification.rejection_reason_name %> + <% else %> + — + <% end %> + + <%= link_to "View", backend_verification_path(verification), class: "link" %> + + Vouch + <% if verification.ignored_at.present? %> +
+ (Ignored) + <% end %> +
+ + <%= verification.status.humanize %> + + <%= verification.created_at.strftime("%b %d, %Y") %> + <% if verification.ignored_at.present? %> + Ignored: <%= verification.ignored_reason %> + <% elsif verification.rejection_reason.present? %> + <%= verification.rejection_reason_name %> + <% else %> + — + <% end %> + + <%= link_to "View", backend_verification_path(verification), class: "link" %> +
+ <% else %> +
+

No verifications submitted yet.

+
+ <% end %> +
+ +
+

Programs

+ <% if @all_programs.any? %> + + + + + + + + + <% @all_programs.each do |program| %> + + + + + <% end %> + +
Program NameScopes
+ <%= link_to program.name, backend_program_path(program), class: "link" %> + + <% if program.scopes.present? %> + <%= program.scopes %> + <% else %> + No scopes + <% end %> +
+ <% else %> +
+

No programs accessed yet.

+
+ <% end %> +
+ +
+

Audit Log

+ <% if @activities.any? %> + <%= render Components::PublicActivity::Container.new(@activities) %> + <% else %> +
+

No audit log entries yet.

+
+ <% end %> +
+
+ <%= render Components::Inspector.new(@identity) %> +<% end %> diff --git a/app/views/backend/identity/resemblance/email_subaddress_resemblances/_email_subaddress_resemblance.html.erb b/app/views/backend/identity/resemblance/email_subaddress_resemblances/_email_subaddress_resemblance.html.erb new file mode 100644 index 0000000..5e7f712 --- /dev/null +++ b/app/views/backend/identity/resemblance/email_subaddress_resemblances/_email_subaddress_resemblance.html.erb @@ -0,0 +1,5 @@ +This email address may be a subaddressed version of an existing identity's email address: +
    +
  • This identity: <%= email_subaddress_resemblance.identity.primary_email %>
  • +
  • Previous identity: <%= email_subaddress_resemblance.past_identity.primary_email %>
  • +
diff --git a/app/views/backend/identity/resemblance/name_resemblances/_name_resemblance.html.erb b/app/views/backend/identity/resemblance/name_resemblances/_name_resemblance.html.erb new file mode 100644 index 0000000..2a33622 --- /dev/null +++ b/app/views/backend/identity/resemblance/name_resemblances/_name_resemblance.html.erb @@ -0,0 +1 @@ +These identities have similar names: diff --git a/app/views/backend/identity/resemblance/reused_document_resemblances/_reused_document_resemblance.html.erb b/app/views/backend/identity/resemblance/reused_document_resemblances/_reused_document_resemblance.html.erb new file mode 100644 index 0000000..2a264e3 --- /dev/null +++ b/app/views/backend/identity/resemblance/reused_document_resemblances/_reused_document_resemblance.html.erb @@ -0,0 +1 @@ +This exact document has already been used for a different identity <%= link_to "here", backend_verification_path(reused_document_resemblance.document.verification), target: "_blank" %>: diff --git a/app/views/backend/programs/_program.html.erb b/app/views/backend/programs/_program.html.erb new file mode 100644 index 0000000..b0ff8a0 --- /dev/null +++ b/app/views/backend/programs/_program.html.erb @@ -0,0 +1,6 @@ + + 🥐 + <%= link_to backend_program_path(program), class: "identity-link", target: "_blank" do %> + <%= program.name %> + <% end %> + diff --git a/app/views/backend/programs/edit.html.erb b/app/views/backend/programs/edit.html.erb new file mode 100644 index 0000000..7b310bc --- /dev/null +++ b/app/views/backend/programs/edit.html.erb @@ -0,0 +1,5 @@ +<%= render Components::Window.new("Edit Program: #{@program.name}", close_url: backend_program_path(@program), max_width: 500) do %> +
+ <%= render Backend::Programs::Form.new @program %> +
+<% end %> diff --git a/app/views/backend/programs/index.html.erb b/app/views/backend/programs/index.html.erb new file mode 100644 index 0000000..4e9f7d1 --- /dev/null +++ b/app/views/backend/programs/index.html.erb @@ -0,0 +1,54 @@ +<%= render Components::Window.new("Programs", close_url: backend_root_path, max_width: 800) do %> +
+
+ + + + + + + + + + + + + <% @programs.each do |program| %> + + + + + + + + + <% end %> + +
Program NameOAuth ApplicationScopesUsersActive?Actions
+
+ <%= program.name %> + <% if program.description.present? %> +
+ <%= truncate(program.description, length: 60) %> + <% end %> +
+
+ + ID: <%= program.uid %>
+ Name: <%= program.name %> +
+
+ <% if program.scopes.present? %> + <%= program.scopes %> + <% else %> + No scopes + <% end %> + <%= program.identities.distinct.count %><%= render_checkbox(program.active?) %> + <%= link_to "View", backend_program_path(program), class: "link" %> +
+
+ <%= link_to "New Program", new_backend_program_path, class: "button" %> +
+
+
+<% end %> diff --git a/app/views/backend/programs/new.html.erb b/app/views/backend/programs/new.html.erb new file mode 100644 index 0000000..4c28ba2 --- /dev/null +++ b/app/views/backend/programs/new.html.erb @@ -0,0 +1,5 @@ +<%= render Components::Window.new("New Program", close_url: backend_programs_path, max_width: 500) do %> +
+ <%= render Backend::Programs::Form.new @program %> +
+<% end %> diff --git a/app/views/backend/programs/show.html.erb b/app/views/backend/programs/show.html.erb new file mode 100644 index 0000000..4fad2bf --- /dev/null +++ b/app/views/backend/programs/show.html.erb @@ -0,0 +1,66 @@ +<%= render Components::Window.new("Program: #{@program.name}", close_url: backend_programs_path, max_width: 600) do %> +
+
+
+

<%= @program.name %>

+ <% if @program.description.present? %> +

<%= @program.description %>

+ <% end %> +
+
+ Status: + <%= @program.active? ? "Active" : "Inactive" %> +
+
+ OAuth Scopes: + <% if @program.scopes.present? %> +
+ <%= @program.scopes %> + <% else %> + No scopes defined + <% end %> +
+
+ Users Enrolled: <%= @identities_count %> +
+
+
+
+ Client ID: + + Client Secret: + + Program global API key: + + Redirect URIs: + <% if @program.redirect_uri.present? %> + + <% @program.redirect_uri.split.each do |uri| %> + + + + + <% end %> +
+ <%= uri %> + + <%= link_to "auth link", oauth_authorization_path(client_id: @program.uid, redirect_uri: uri, response_type: 'code', scope: @program.scopes), class: 'btn btn-success', target: '_blank' %> +
+
+ (right click to copy auth URLs) +
+ <% else %> + <%= t('.not_defined') %> + <% end %> +
+
+
+ <%= link_to "Edit Program", edit_backend_program_path(@program), class: "button" %> + <%= link_to "View OAuth App", oauth_application_path(@program), class: "link", target: "_blank" %> + <%= link_to "Delete Program", backend_program_path(@program), method: :delete, + confirm: "Are you sure? This will delete the program and all associated data.", + class: "link", style: "color: red;" %> +
+
+
+<% end %> diff --git a/app/views/backend/static_pages/index.html.erb b/app/views/backend/static_pages/index.html.erb new file mode 100644 index 0000000..ef95da3 --- /dev/null +++ b/app/views/backend/static_pages/index.html.erb @@ -0,0 +1,43 @@ +<%= render Components::Window.new("Identity Vault (#{Rails.env.upcase})") do %> +
+ Would you like to... +
    + <% super_admin_tool do %> +
  • + <%= link_to "manage users", backend_users_path, { class: 'link' } %>? +
  • +
  • + do a <%= link_to "good job", backend_good_job_path, { class: 'link' } %> today? +
  • +
  • + <%= link_to "audit", backend_audits1984_path, { class: 'link' } %> console sessions? +
  • +
  • + flip some <%= link_to "feature flags", backend_flipper_path, { class: 'link' } %>? +
  • + <% end %> + <% program_manager_tool do %> +
  • + <%= link_to "manage programs", backend_programs_path, { class: 'link' } %>? +
  • + <% end %> + <% mdv_tool do %> +
  • + <%= link_to "review pending verifications", pending_backend_verifications_path, { class: 'link' } %> + <% if @pending_verifications_count && @pending_verifications_count > 0 %> + (<%= @pending_verifications_count %> pending) + <% end %>? +
  • + <% end %> +
  • + view <%= link_to "audit logs", backend_audit_logs_path, { class: 'link' } %>? +
  • +
  • + view <%= link_to "identities", backend_identities_path, { class: 'link' } %>? +
  • +
  • + view <%= link_to "dashboard", backend_dashboard_path, { class: 'link' } %>? +
  • +
+
+<% end %> diff --git a/app/views/backend/static_pages/login.html.erb b/app/views/backend/static_pages/login.html.erb new file mode 100644 index 0000000..a6659ee --- /dev/null +++ b/app/views/backend/static_pages/login.html.erb @@ -0,0 +1,34 @@ +
+
+
+ Identity Vault [ + <% case Rails.env %> + <% when "development" %> + DEV + <% when "staging" %> + STAGING + <% else %> + PROD + <% end %>] +
+
+
+
+ Abandon hope, all ye who enter here... +
+
+ <%= link_to backend_slack_auth_path, class: "button w-fit" do %> + Sign in with Slack? + <% end %> + <% dev_tool do %> + <%= form_with url: backend_fake_slack_callback_for_dev_path, method: :post do |f| %> + <%= f.text_field :slack_id %> + <%= f.submit "fake it til' you make it" %> + <% end %> + <% end %> +
+
+ Running commit <%= ENV["SOURCE_COMMIT"]&.[](0..7) || "...dunno?" %> +
+
+
diff --git a/app/views/backend/static_pages/session_dump.html.erb b/app/views/backend/static_pages/session_dump.html.erb new file mode 100644 index 0000000..05f1502 --- /dev/null +++ b/app/views/backend/static_pages/session_dump.html.erb @@ -0,0 +1,3 @@ +<%= render Components::Window.new("Session dump", close_url: backend_root_path) do %> + <%== ap session %> +<% end %> diff --git a/app/views/backend/users/_user.html.erb b/app/views/backend/users/_user.html.erb new file mode 100644 index 0000000..eefd227 --- /dev/null +++ b/app/views/backend/users/_user.html.erb @@ -0,0 +1 @@ +<%= render Components::UserMention.new(user) %> diff --git a/app/views/backend/users/edit.html.erb b/app/views/backend/users/edit.html.erb new file mode 100644 index 0000000..7b290ea --- /dev/null +++ b/app/views/backend/users/edit.html.erb @@ -0,0 +1,5 @@ +<%= render Components::Window.new("Edit user: #{@user.username}", close_url: backend_users_path, max_width: 500) do %> +
+ <%= render Backend::Users::Form.new @user %> +
+<% end %> diff --git a/app/views/backend/users/index.html.erb b/app/views/backend/users/index.html.erb new file mode 100644 index 0000000..4b8ec99 --- /dev/null +++ b/app/views/backend/users/index.html.erb @@ -0,0 +1,33 @@ +<%= render Components::Window.new("Users", close_url: backend_root_path, max_width: 600) do %> +
+
+ + + + + + + + + + + <% @users.each do |user| %> + + + + + + + <% end %> + +
UserRolesActive?View
+ <%= render user %> + <%= user.pretty_roles %><%= render_checkbox(user.active?) %><%= link_to "go!", user, class: "link" %>
+
+
+
+ <%= link_to new_backend_user_path, class: "button w-fit" do %> + + create user + <% end %> +
+<% end %> diff --git a/app/views/backend/users/new.html.erb b/app/views/backend/users/new.html.erb new file mode 100644 index 0000000..de0d9ef --- /dev/null +++ b/app/views/backend/users/new.html.erb @@ -0,0 +1,5 @@ +<%= render Components::Window.new("New User", close_url: backend_users_path, max_width: 500) do %> +
+ <%= render Backend::Users::Form.new @user %> +
+<% end %> diff --git a/app/views/backend/users/show.html.erb b/app/views/backend/users/show.html.erb new file mode 100644 index 0000000..2cc2dba --- /dev/null +++ b/app/views/backend/users/show.html.erb @@ -0,0 +1,23 @@ +<%= render Components::Window.new("User: #{@user.username}", close_url: backend_users_path) do %> +
+ <%= render Components::UserMention.new(@user) %> + Roles: <%= @user.pretty_roles %> +
+ Organized Programs: + <% if @user.organized_programs.any? %> + <%= @user.organized_programs.map(&:name).join(", ") %> + <% else %> + None + <% end %> + <% super_admin_tool do %> + <%= link_to "edit this user", edit_backend_user_path(@user), class: "link" %> + <% if @user.active? %> + <%= button_to "deactivate this user", {action: :deactivate} %> + (this will stop them from logging in) + <% else %> + <%= button_to "activate this user", {action: :activate} %> + (this will allow them to log in again) + <% end %> + <% end %> +
+<% end %> diff --git a/app/views/backend/verifications/index.html.erb b/app/views/backend/verifications/index.html.erb new file mode 100644 index 0000000..e738562 --- /dev/null +++ b/app/views/backend/verifications/index.html.erb @@ -0,0 +1,71 @@ +<%= render Components::Window.new("All Verifications", close_url: backend_root_path, max_width: 1200) do %> +
+
+

All Verifications

+
+ <%= link_to "Pending Verifications", pending_backend_verifications_path, class: "button" %> +
+
+ <% if @recent_verifications.any? %> +
+ + + + + + + + + + + + + <% @recent_verifications.each do |verification| %> + + + + + + + + + <% end %> + +
IdentityDocument TypeStatusDateReasonActions
+ <%= verification.identity.first_name %> <%= verification.identity.last_name %> +
+ <%= verification.identity.primary_email %> +
+ <%= verification.document_type %> + <% if verification.ignored_at.present? %> +
(Ignored) + <% end %> +
+ + <%= verification.status.humanize %> + + + <%= verification.updated_at.strftime("%m/%d/%Y %I:%M %p") %> + + <% if verification.ignored_at.present? %> + Ignored: <%= verification.ignored_reason %> + <% else %> + <%= verification.rejection_reason_name %> + <% end %> + + <%= link_to "View", backend_verification_path(verification), class: "button button-secondary" %> +
+
+
+ <%= paginate @recent_verifications %> +
+ <% else %> +
+

No verifications found

+

+ <%= link_to "Check pending verifications", pending_backend_verifications_path, class: "button" %> +

+
+ <% end %> +
+<% end %> diff --git a/app/views/backend/verifications/pending.html.erb b/app/views/backend/verifications/pending.html.erb new file mode 100644 index 0000000..6bee2e1 --- /dev/null +++ b/app/views/backend/verifications/pending.html.erb @@ -0,0 +1,52 @@ +<%= render Components::Window.new("Pending Document Verifications", close_url: backend_root_path, max_width: 1000) do %> +
+
+

Pending Verification (<%= @pending_verifications.total_count %>)

+
+ <%= link_to "All Verifications", backend_verifications_path, class: "button button-secondary" %> +
+
+ <% if @average_hangtime.present? %> +

Average hangtime: <%= distance_of_time_in_words(0, @average_hangtime) %>

+ <% end %> + <% if @pending_verifications.any? %> +
+ <% @pending_verifications.each do |verification| %> +
+
+
+

+ <%= verification.document_type %> +

+

+ <%= verification.identity.first_name %> <%= verification.identity.last_name %> + (<%= verification.identity.primary_email %>) +

+

+ Country: <%= verification.identity.country %> | + Uploaded: <%= time_ago_in_words(verification.pending_at || verification.created_at) %> ago +

+
+ + Pending + +
+
+ <%= link_to "Review", backend_verification_path(verification), class: "button" %> +
+
+ <% end %> +
+
+ <%= paginate @pending_verifications %> +
+ <% else %> +
+

No documents pending verification

+

+ <%= link_to "View all verifications", backend_verifications_path, class: "button button-secondary" %> +

+
+ <% end %> +
+<% end %> diff --git a/app/views/backend/verifications/show.html.erb b/app/views/backend/verifications/show.html.erb new file mode 100644 index 0000000..2988c4c --- /dev/null +++ b/app/views/backend/verifications/show.html.erb @@ -0,0 +1,236 @@ +<%= render Components::Window.new("Document Review", close_url: pending_backend_verifications_path, max_width: 1200) do %> +
+
+ + <%= render Components::IdentityReview::BasicDetails.new(@verification.identity) %> + + <% case @verification %> + <% when Verification::DocumentVerification %> + <%= render Components::IdentityReview::DocumentInfo.new(@verification) %> + <% when Verification::AadhaarVerification %> + <%= render Components::IdentityReview::AadhaarInfo.new(@verification) %> + <% end %> +
+ <% if @verification.identity.resemblances.any? %> + + <% end %> + <% if @verification.issues.any? %> + + <% end %> + +
+ <% if @verification.is_a?(Verification::VouchVerification) %> +

This user has been manually vouched for on <%= @verification.created_at.strftime("%B %d, %Y at %I:%M %p") %>

+ <% if @verification.evidence.attached? %> +

Evidence

+ Vouch evidence + <% else %> +

No evidence attached.

+ <% end %> + <% else %> + <%= render Components::BreakTheGlass.new(@relevant_object, auto_break_glass: @verification.pending? ? "to review validity" : nil) do %> + <% case @relevant_object %> + <% when Identity::Document %> + <%= render Components::IdentityReview::DocumentFiles.new(@relevant_object) %> + <% when Identity::AadhaarRecord %> + <%= render Components::IdentityReview::AadhaarFull.new(@relevant_object) %> + <% end %> + <% end %> + <% end %> +
+ + <% if @verification.pending? %> +
+

Verification Decision

+
+ +
+

✅ Approve (YSWS Eligible)

+

Document approved and identity is YSWS eligible.

+ <%= form_with url: approve_backend_verification_path(@verification), method: :patch, local: true do |form| %> + <%= form.hidden_field :ysws_eligible, value: true %> + <% mdv_tool do %> + <%= form.submit "Approve (YSWS Eligible)", + class: "button", + style: "width: 100%;", + data: { confirm: "Are you sure you want to approve this document and mark as YSWS eligible?" } %> + <% if @verification.identity.birthday < 19.years.ago %> +

+ ⚠️ Warning: <%= @verification.identity.birthday %> is more than 19 years ago. +

+ <% end %> + <% end %> + <% end %> +
+ +
+

✅ Approve (YSWS Ineligible)

+

Document approved but identity is not YSWS eligible.

+ <%= form_with url: approve_backend_verification_path(@verification), method: :patch, local: true do |form| %> + <%= form.hidden_field :ysws_eligible, value: false %> + <% mdv_tool do %> + <%= form.submit "Approve (YSWS Ineligible)", + class: "button", + style: "width: 100%;", + data: { confirm: "Are you sure you want to approve this document and mark as YSWS ineligible?" } %> + <% end %> + <% end %> +
+ +
+

❌ Reject Document

+ <%= form_with url: reject_backend_verification_path(@verification), method: :patch, local: true do |form| %> + <% if @verification.identity.under_11? %> +
+ ⚠️ Warning: The submitted identity is under 11 years old (age <%= @verification.identity.age.round(2) %>). Consider selecting "Submitter is under 11 years old" as the rejection reason. +
+ <% end %> + <%= form.label :rejection_reason, "Rejection Reason:" %> + <% if @verification.is_a?(Verification::AadhaarVerification) %> + <% + default_selection = if @verification.identity.under_11? + "under_11" + elsif @verification.identity.resemblances.any? + "duplicate" + else + nil + end + %> + <%= form.select :rejection_reason, + grouped_options_for_select([ + [ + "Retry-able Issues (user can resubmit)", + Verification::AadhaarVerification::RETRYABLE_REJECTION_REASONS.map { |reason| [Verification::AadhaarVerification::REJECTION_REASON_NAMES[reason], reason] } + ], + [ + "Fatal Issues (makes identity ineligible)", + Verification::AadhaarVerification::FATAL_REJECTION_REASONS.map { |reason| [Verification::AadhaarVerification::REJECTION_REASON_NAMES[reason], reason] } + ] + ], default_selection), + { include_blank: "Select a reason", + required: true, style: "width: 100%; margin-bottom: 1rem;" } %> + <% elsif @verification.is_a?(Verification::DocumentVerification) %> + <% + default_selection = if @verification.identity.under_11? + "under_11" + elsif @verification.identity.resemblances.any? + "duplicate" + else + nil + end + %> + <%= form.select :rejection_reason, + grouped_options_for_select([ + [ + "Retry-able Issues (user can resubmit)", + Verification::DocumentVerification::RETRYABLE_REJECTION_REASONS.map { |reason| [Verification::DocumentVerification::REJECTION_REASON_NAMES[reason], reason] } + ], + [ + "Fatal Issues (makes identity ineligible)", + Verification::DocumentVerification::FATAL_REJECTION_REASONS.map { |reason| [Verification::DocumentVerification::REJECTION_REASON_NAMES[reason], reason] } + ] + ], default_selection), + { include_blank: "Select a reason", + required: true, style: "width: 100%; margin-bottom: 1rem;" } %> + <% elsif @verification.is_a?(Verification::VouchVerification) %> +

✅ This document has been automatically approved.

+ <% end %> + <%= form.label :rejection_reason_details, "Additional Details (optional):" %> + <%= form.text_area :rejection_reason_details, + rows: 3, + placeholder: "Provide specific feedback to help the user resubmit correctly...", + style: "width: 100%; margin-bottom: 1rem;" %> + <%= form.label :internal_rejection_comment, "Internal Rejection Comment (optional):" %> + <%= form.text_area :internal_rejection_comment, + rows: 2, + placeholder: "Internal notes for staff only - not visible to user...", + style: "width: 100%; margin-bottom: 1rem;" %> + <% mdv_tool do %> + <%= form.submit "Reject Document", + class: "button", + style: "width: 100%;", + data: { confirm: "Are you sure you want to reject this document?" } %> + <% end %> + <% end %> +
+
+

+ Current YSWS Status: + <% if @verification.identity.ysws_eligible.nil? %> + Not Reviewed + <% elsif @verification.identity.ysws_eligible %> + Eligible + <% else %> + Ineligible + <% end %> +

+
+ <% else %> +
+

Document Already Processed

+

This document has already been <%= @verification.status %>.

+ <% if @verification.rejection_reason.present? %> +
+

Rejection Reason:

+

<%= @verification.rejection_reason_name %>

+ <% if @verification.rejection_reason_details.present? %> +

Details:

+

<%= @verification.rejection_reason_details %>

+ <% end %> + <% if @verification.internal_rejection_comment.present? %> +

Internal Rejection Comment:

+

<%= @verification.internal_rejection_comment %>

+ <% end %> +
+ <% end %> +
+ <% end %> + + <% if @verification.ignored_at.blank? %> + <% super_admin_tool do %> +
+

⚠️ Super Admin: Ignore Verification

+

+ This will permanently ignore this verification and exclude it from identity status calculations. This action cannot be undone. +

+ <%= form_with url: ignore_backend_verification_path(@verification), method: :patch, local: true do |f| %> +
+ <%= f.label :reason, "Reason for ignoring (required):" %> + <%= f.text_area :reason, placeholder: "Please provide a detailed reason for ignoring this verification. This will be logged in the audit trail.", rows: 4 %> +
+ <%= f.submit "Ignore Verification", class: "button", data: { confirm: "Are you sure you want to ignore this verification? This action cannot be undone." } %> + <% end %> +
+ <% end %> + <% elsif @verification.ignored_at.present? %> +
+

🚫 Verification Ignored

+

+ Ignored on: <%= @verification.ignored_at.strftime("%B %d, %Y at %I:%M %p") %>
+ Reason: <%= @verification.ignored_reason %> +

+
+ <% end %> +
+
+

Audit Log

+ <%= render Components::PublicActivity::Container.new(@activities) %> +
+ <%= render Components::Inspector.new(@verification) %> + <%= render Components::Inspector.new(@relevant_object) %> +<% end %> diff --git a/app/views/base.rb b/app/views/base.rb new file mode 100644 index 0000000..0e4f1b3 --- /dev/null +++ b/app/views/base.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Views::Base < Components::Base + # The `Views::Base` is an abstract class for all your views. + + # By default, it inherits from `Components::Base`, but you + # can change that to `Phlex::HTML` if you want to keep views and + # components independent. +end diff --git a/app/views/doorkeeper/applications/_delete_form.html.erb b/app/views/doorkeeper/applications/_delete_form.html.erb new file mode 100644 index 0000000..654fb2a --- /dev/null +++ b/app/views/doorkeeper/applications/_delete_form.html.erb @@ -0,0 +1,6 @@ +<%- submit_btn_css ||= 'btn btn-link' %> +<%= form_tag oauth_application_path(application), method: :delete do %> + <%= submit_tag t('doorkeeper.applications.buttons.destroy'), + onclick: "return confirm('#{ t('doorkeeper.applications.confirmations.destroy') }')", + class: submit_btn_css %> +<% end %> diff --git a/app/views/doorkeeper/applications/_form.html.erb b/app/views/doorkeeper/applications/_form.html.erb new file mode 100644 index 0000000..de86503 --- /dev/null +++ b/app/views/doorkeeper/applications/_form.html.erb @@ -0,0 +1,59 @@ +<%= form_for application, url: doorkeeper_submit_path(application), as: :doorkeeper_application, html: { role: 'form' } do |f| %> + <% if application.errors.any? %> +

<%= t('doorkeeper.applications.form.error') %>

+ <% end %> + +
+ <%= f.label :name, class: 'col-sm-2 col-form-label font-weight-bold' %> +
+ <%= f.text_field :name, class: "form-control #{ 'is-invalid' if application.errors[:name].present? }", required: true %> + <%= doorkeeper_errors_for application, :name %> +
+
+ +
+ <%= f.label :redirect_uri, class: 'col-sm-2 col-form-label font-weight-bold' %> +
+ <%= f.text_area :redirect_uri, class: "form-control #{ 'is-invalid' if application.errors[:redirect_uri].present? }" %> + <%= doorkeeper_errors_for application, :redirect_uri %> + + <%= t('doorkeeper.applications.help.redirect_uri') %> + + + <% if Doorkeeper.configuration.allow_blank_redirect_uri?(application) %> + + <%= t('doorkeeper.applications.help.blank_redirect_uri') %> + + <% end %> +
+
+ +
+ <%= f.label :confidential, class: 'col-sm-2 form-check-label font-weight-bold' %> +
+ <%= f.check_box :confidential, class: "checkbox #{ 'is-invalid' if application.errors[:confidential].present? }" %> + <%= doorkeeper_errors_for application, :confidential %> + + <%= t('doorkeeper.applications.help.confidential') %> + +
+
+ +
+ <%= f.label :scopes, class: 'col-sm-2 col-form-label font-weight-bold' %> +
+ <%= f.text_field :scopes, class: "form-control #{ 'has-error' if application.errors[:scopes].present? }" %> + <%= doorkeeper_errors_for application, :scopes %> + + <%= t('doorkeeper.applications.help.scopes') %> + +
+
+ +
+
+ <%= f.submit t('doorkeeper.applications.buttons.submit'), class: 'btn btn-primary' %> + <%= link_to t('doorkeeper.applications.buttons.cancel'), oauth_applications_path, class: 'btn btn-secondary' %> +
+
+<% end %> diff --git a/app/views/doorkeeper/applications/edit.html.erb b/app/views/doorkeeper/applications/edit.html.erb new file mode 100644 index 0000000..737186b --- /dev/null +++ b/app/views/doorkeeper/applications/edit.html.erb @@ -0,0 +1,5 @@ +
+

<%= t('.title') %>

+
+ +<%= render 'form', application: @application %> diff --git a/app/views/doorkeeper/applications/index.html.erb b/app/views/doorkeeper/applications/index.html.erb new file mode 100644 index 0000000..4b6449e --- /dev/null +++ b/app/views/doorkeeper/applications/index.html.erb @@ -0,0 +1,33 @@ +
+

<%= t('.title') %>

+
+

<%= link_to t('.new'), new_oauth_application_path, class: 'btn btn-success' %>

+ + + + + + + + + + + + <% @applications.each do |application| %> + + + + + + + <% end %> + +
<%= t('.name') %><%= t('.callback_url') %><%= t('.confidential') %><%= t('.actions') %>
+ <%= link_to application.name, oauth_application_path(application) %> + + <%= simple_format(application.redirect_uri) %> + + <%= application.confidential? ? t('doorkeeper.applications.index.confidentiality.yes') : t('doorkeeper.applications.index.confidentiality.no') %> + + <%= link_to t('doorkeeper.applications.buttons.edit'), edit_oauth_application_path(application), class: 'btn btn-link' %> +
diff --git a/app/views/doorkeeper/applications/new.html.erb b/app/views/doorkeeper/applications/new.html.erb new file mode 100644 index 0000000..737186b --- /dev/null +++ b/app/views/doorkeeper/applications/new.html.erb @@ -0,0 +1,5 @@ +
+

<%= t('.title') %>

+
+ +<%= render 'form', application: @application %> diff --git a/app/views/doorkeeper/applications/show.html.erb b/app/views/doorkeeper/applications/show.html.erb new file mode 100644 index 0000000..edf72ac --- /dev/null +++ b/app/views/doorkeeper/applications/show.html.erb @@ -0,0 +1,53 @@ +
+

<%= t('.title', name: @application.name) %>

+
+
+
+

<%= t('.application_id') %>:

+

<%= @application.uid %>

+

<%= t('.secret') %>:

+

+ + <% secret = flash[:application_secret].presence || @application.plaintext_secret %> + <% if secret.blank? && Doorkeeper.config.application_secret_hashed? %> + <%= t('.secret_hashed') %> + <% else %> + <%= secret %> + <% end %> + +

+

<%= t('.scopes') %>:

+

+ + <% if @application.scopes.present? %> + <%= @application.scopes %> + <% else %> + <%= t('.not_defined') %> + <% end %> + +

+

<%= t('.confidential') %>:

+

<%= @application.confidential? %>

+

<%= t('.callback_urls') %>:

+ <% if @application.redirect_uri.present? %> + + <% @application.redirect_uri.split.each do |uri| %> + + + + + <% end %> +
+ <%= uri %> + + <%= link_to t('doorkeeper.applications.buttons.authorize'), oauth_authorization_path(client_id: @application.uid, redirect_uri: uri, response_type: 'code', scope: @application.scopes), class: 'btn btn-success', target: '_blank' %> +
+ <% else %> + <%= t('.not_defined') %> + <% end %> +
+
+

<%= t('.actions') %>

+

<%= link_to t('doorkeeper.applications.buttons.edit'), edit_oauth_application_path(@application), class: 'btn btn-primary' %>

+
+
diff --git a/app/views/doorkeeper/authorizations/error.html.erb b/app/views/doorkeeper/authorizations/error.html.erb new file mode 100644 index 0000000..5196102 --- /dev/null +++ b/app/views/doorkeeper/authorizations/error.html.erb @@ -0,0 +1,8 @@ +
+

Something isn't right about this OAuth request...

+
+
+
+    <%= (local_assigns[:error_response] ? error_response : @pre_auth.error_response).body[:error_description] %>
+  
+
diff --git a/app/views/doorkeeper/authorizations/form_post.html.erb b/app/views/doorkeeper/authorizations/form_post.html.erb new file mode 100644 index 0000000..179a784 --- /dev/null +++ b/app/views/doorkeeper/authorizations/form_post.html.erb @@ -0,0 +1,15 @@ + + +<%= form_tag @pre_auth.redirect_uri, method: :post, name: :redirect_form, authenticity_token: false do %> + <% auth.body.compact.each do |key, value| %> + <%= hidden_field_tag key, value %> + <% end %> +<% end %> + + diff --git a/app/views/doorkeeper/authorizations/new.html.erb b/app/views/doorkeeper/authorizations/new.html.erb new file mode 100644 index 0000000..dda9ff0 --- /dev/null +++ b/app/views/doorkeeper/authorizations/new.html.erb @@ -0,0 +1,64 @@ +<%# this should be in a controller, but i'd rather not get into doorkeeper's guts right now %> +<% first_time = current_identity.access_tokens.none? %> +
+ <% if first_time %> + <%= render "shared/verification_status", identity: current_identity %> + +

<%= session.dig(:stashed_data, "splash_message") || "Continue to #{@pre_auth.client.name}..." %>

+ <%= form_tag oauth_authorization_path, method: :post do %> + <%= hidden_field_tag :client_id, @pre_auth.client.uid, id: nil %> + <%= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri, id: nil %> + <%= hidden_field_tag :state, @pre_auth.state, id: nil %> + <%= hidden_field_tag :response_type, @pre_auth.response_type, id: nil %> + <%= hidden_field_tag :response_mode, @pre_auth.response_mode, id: nil %> + <%= hidden_field_tag :scope, @pre_auth.scope, id: nil %> + <%= hidden_field_tag :code_challenge, @pre_auth.code_challenge, id: nil %> + <%= hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method, id: nil %> + <%= submit_tag session.dig(:stashed_data, "splash_continue") || "go! →", class: "btn btn-success btn-lg btn-block" %> + <% end %> + <% else %> +

+ <%= session.dig(:stashed_data, "splash_message") || "Continue to #{@pre_auth.client.name}..." %> +

+
+

+ <%= raw t('.prompt', client_name: content_tag(:strong, class: 'text-info') { @pre_auth.client.name }) %> +

+ <% if @pre_auth.scopes.count > 0 %> +
+

<%= t('.able_to') %>:

+
    + <% @pre_auth.scopes.each do |scope| %> +
  • + <% case scope %> + <% when "basic_info" %> + See basic information about you +
      +
    • email: <%= current_identity.primary_email %>
    • +
    • name: <%= current_identity.first_name %> <%= current_identity.last_name %>
    • +
    • verification status: <%= current_identity.verification_status %>
    • +
    + <% else %> + <%= t scope, scope: [:doorkeeper, :scopes] %> + <% end %> +
  • + <% end %> +
+
+ <% end %> +
+ <%= form_tag oauth_authorization_path, method: :post do %> + <%= hidden_field_tag :client_id, @pre_auth.client.uid, id: nil %> + <%= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri, id: nil %> + <%= hidden_field_tag :state, @pre_auth.state, id: nil %> + <%= hidden_field_tag :response_type, @pre_auth.response_type, id: nil %> + <%= hidden_field_tag :response_mode, @pre_auth.response_mode, id: nil %> + <%= hidden_field_tag :scope, @pre_auth.scope, id: nil %> + <%= hidden_field_tag :code_challenge, @pre_auth.code_challenge, id: nil %> + <%= hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method, id: nil %> + <%= submit_tag session.dig(:stashed_data, "splash_continue") || "authorize →", class: "btn btn-success btn-lg btn-block" %> + <% end %> +
+
+
+<% end %> diff --git a/app/views/doorkeeper/authorizations/show.html.erb b/app/views/doorkeeper/authorizations/show.html.erb new file mode 100644 index 0000000..f4d6610 --- /dev/null +++ b/app/views/doorkeeper/authorizations/show.html.erb @@ -0,0 +1,7 @@ + + +
+ <%= params[:code] %> +
diff --git a/app/views/doorkeeper/authorized_applications/_delete_form.html.erb b/app/views/doorkeeper/authorized_applications/_delete_form.html.erb new file mode 100644 index 0000000..512e8ec --- /dev/null +++ b/app/views/doorkeeper/authorized_applications/_delete_form.html.erb @@ -0,0 +1,4 @@ +<%- submit_btn_css ||= 'btn btn-link' %> +<%= form_tag oauth_authorized_application_path(application), method: :delete do %> + <%= submit_tag t('doorkeeper.authorized_applications.buttons.revoke'), onclick: "return confirm('#{ t('doorkeeper.authorized_applications.confirmations.revoke') }')", class: submit_btn_css %> +<% end %> diff --git a/app/views/doorkeeper/authorized_applications/index.html.erb b/app/views/doorkeeper/authorized_applications/index.html.erb new file mode 100644 index 0000000..19900e3 --- /dev/null +++ b/app/views/doorkeeper/authorized_applications/index.html.erb @@ -0,0 +1,24 @@ + +<%= render Components::HomeButton.new %> +
+
+ + + + + + + + + + <% @applications.each do |application| %> + + + + + <% end %> + +
ProgramGranted access at
<%= application.name %><%= application.created_at.strftime(t('doorkeeper.authorized_applications.index.date_format')) %>
+
diff --git a/app/views/forms/application_form.rb b/app/views/forms/application_form.rb new file mode 100644 index 0000000..2e77a54 --- /dev/null +++ b/app/views/forms/application_form.rb @@ -0,0 +1,50 @@ +class ApplicationForm < Superform::Rails::Form + include Phlex::Rails::Helpers::Pluralize + include Phlex::Rails::Helpers::CheckBoxTag + register_value_helper :program_manager_tool + register_value_helper :super_admin_tool + register_value_helper :mdv_tool + register_value_helper :dev_tool + + def labeled(component, label) + render label(class: "grid-input-1", for: component.dom.id) { label } + span(class: "grid-input-2") { render component } + end + + def check_box(field, description = nil) + div class: "flex-column" do + div class: "checkbox-row" do + render field.checkbox + render field.label + end + i { safe(description) } if description + end + end + + def row(component) + div do + render component.field.label(style: "display: block;") + render component + end + end + + def around_template(&) + super do + error_messages + yield + end + end + + def error_messages + if model.errors.any? + div(style: "color: red;") do + h2 { "#{pluralize model.errors.count, "error"} prohibited this post from being saved:" } + ul do + model.errors.each do |error| + li { error.full_message } + end + end + end + end + end +end diff --git a/app/views/forms/backend/programs/form.rb b/app/views/forms/backend/programs/form.rb new file mode 100644 index 0000000..e0ab4a7 --- /dev/null +++ b/app/views/forms/backend/programs/form.rb @@ -0,0 +1,40 @@ +class Backend::Programs::Form < ApplicationForm + def view_template(&) + div do + labeled field(:name).input, "Program Name: " + end + div do + label(class: "field-label") { "Redirect URIs (one per line):" } + textarea( + name: "oauth_application[redirect_uri]", + placeholder: "https://example.com/callback", + class: "input-field", + rows: 3, + style: "width: 100%;", + ) { model.redirect_uri } + end + program_manager_tool do + div style: "margin: 1rem 0;" do + label(class: "field-label") { "OAuth Scopes:" } + # Hidden field to ensure empty scopes array is submitted when no checkboxes are checked + input type: "hidden", name: "program[scopes_array][]", value: "" + Program::AVAILABLE_SCOPES.each do |scope| + div class: "checkbox-row" do + scope_checked = model.persisted? ? model.has_scope?(scope[:name]) : false + input( + type: "checkbox", + name: "program[scopes_array][]", + value: scope[:name], + id: "program_scopes_#{scope[:name]}", + checked: scope_checked, + ) + label(for: "program_scopes_#{scope[:name]}", class: "checkbox-label", style: "margin-right: 0.5rem;") { scope[:name] } + small { scope[:description] } + end + end + end + end + + submit model.new_record? ? "Create Program" : "Update Program" + end +end diff --git a/app/views/forms/backend/users/form.rb b/app/views/forms/backend/users/form.rb new file mode 100644 index 0000000..aa8ec1d --- /dev/null +++ b/app/views/forms/backend/users/form.rb @@ -0,0 +1,37 @@ +# params.require(:backend_user).permit(:slack_id, :username, :all_fields_access, :human_endorser, :program_manager, :manual_document_verifier, :super_admin) + +class Backend::Users::Form < ApplicationForm + def view_template(&) + div do + labeled field(:slack_id).input(disabled: !model.new_record?), "Slack ID: " + end + div do + labeled field(:username).input, "Display Name: " + end + b { "Roles: " } + div class: "grid gap align-center", style: "grid-template-columns: max-content auto;" do + check_box(field(:super_admin), "Allows this user access to all permissions
(this includes managing other users)") + check_box(field(:program_manager), "This user can provision API keys and program tags.") + check_box(field(:human_endorser), "This user can mark identities as
human-endorsed.") + check_box(field(:all_fields_access), "This user can view all fields on all identities.") + check_box(field(:manual_document_verifier), "This user can mark documents as
manually verified.") + check_box(field(:can_break_glass), "This user can view ID docs after they've been reviewed.") + end + + b { "Program Organizer Positions: " } + div class: "grid gap", style: "grid-template-columns: 1fr;" do + Program.all.each do |program| + is_organizer = model.organized_programs.include?(program) + + div class: "flex-column" do + div class: "checkbox-row" do + check_box_tag("backend_user[organized_program_ids][]", program.id, is_organizer, id: "organized_program_#{program.id}") + label(for: "organized_program_#{program.id}") { program.name } + end + end + end + end + + submit model.new_record? ? "create!" : "save" + end +end diff --git a/app/views/kaminari/_first_page.html.erb b/app/views/kaminari/_first_page.html.erb new file mode 100644 index 0000000..631c842 --- /dev/null +++ b/app/views/kaminari/_first_page.html.erb @@ -0,0 +1,8 @@ +<%# Link to the "First" page + - available local variables + url: url to the first page + current_page: a page object for the currently displayed page + total_pages: total number of pages + per_page: number of items to fetch per page + remote: data-remote -%> +<%= link_to "First", url, {remote: remote, class: "button button-secondary"} %> diff --git a/app/views/kaminari/_gap.html.erb b/app/views/kaminari/_gap.html.erb new file mode 100644 index 0000000..3f52598 --- /dev/null +++ b/app/views/kaminari/_gap.html.erb @@ -0,0 +1,7 @@ +<%# Non-link tag that stands for skipped pages... + - available local variables + current_page: a page object for the currently displayed page + total_pages: total number of pages + per_page: number of items to fetch per page + remote: data-remote -%> + diff --git a/app/views/kaminari/_last_page.html.erb b/app/views/kaminari/_last_page.html.erb new file mode 100644 index 0000000..480125e --- /dev/null +++ b/app/views/kaminari/_last_page.html.erb @@ -0,0 +1,8 @@ +<%# Link to the "Last" page + - available local variables + url: url to the last page + current_page: a page object for the currently displayed page + total_pages: total number of pages + per_page: number of items to fetch per page + remote: data-remote -%> +<%= link_to "Last", url, {remote: remote, class: "button button-secondary"} %> diff --git a/app/views/kaminari/_next_page.html.erb b/app/views/kaminari/_next_page.html.erb new file mode 100644 index 0000000..e3357c1 --- /dev/null +++ b/app/views/kaminari/_next_page.html.erb @@ -0,0 +1,8 @@ +<%# Link to the "Next" page + - available local variables + url: url to the next page + current_page: a page object for the currently displayed page + total_pages: total number of pages + per_page: number of items to fetch per page + remote: data-remote -%> +<%= link_to "Next →", url, {rel: 'next', remote: remote, class: "button"} %> diff --git a/app/views/kaminari/_page.html.erb b/app/views/kaminari/_page.html.erb new file mode 100644 index 0000000..8e132a5 --- /dev/null +++ b/app/views/kaminari/_page.html.erb @@ -0,0 +1,13 @@ +<%# Link showing page number + - available local variables + page: a page object for "this" page + url: url to this page + current_page: a page object for the currently displayed page + total_pages: total number of pages + per_page: number of items to fetch per page + remote: data-remote -%> +<% if page.current? %> + <%= page %> +<% else %> + <%= link_to page, url, {remote: remote, rel: page.rel, class: "button", style: "min-width: 2rem;"} %> +<% end %> diff --git a/app/views/kaminari/_paginator.html.erb b/app/views/kaminari/_paginator.html.erb new file mode 100644 index 0000000..8e06795 --- /dev/null +++ b/app/views/kaminari/_paginator.html.erb @@ -0,0 +1,24 @@ +<%# The container tag + - available local variables + current_page: a page object for the currently displayed page + total_pages: total number of pages + per_page: number of items to fetch per page + remote: data-remote + paginator: the paginator that renders the pagination tags inside -%> +<%= paginator.render do -%> + +<% end -%> diff --git a/app/views/kaminari/_prev_page.html.erb b/app/views/kaminari/_prev_page.html.erb new file mode 100644 index 0000000..68459ec --- /dev/null +++ b/app/views/kaminari/_prev_page.html.erb @@ -0,0 +1,8 @@ +<%# Link to the "Previous" page + - available local variables + url: url to the previous page + current_page: a page object for the currently displayed page + total_pages: total number of pages + per_page: number of items to fetch per page + remote: data-remote -%> +<%= link_to "← Prev", url, {rel: 'prev', remote: remote, class: "button"} %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb new file mode 100644 index 0000000..6cf4f83 --- /dev/null +++ b/app/views/layouts/application.html.erb @@ -0,0 +1,36 @@ + + + + <%= content_for(:title) || "Identity Vault" %> + + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + <%= yield :head %> + + + + + <%= vite_client_tag %> + <%= vite_stylesheet_tag "application.css" %> + <%= vite_javascript_tag 'application' %> + + +
+ <%= render Components::Brand.new(identity: current_identity) %> + <%= render "shared/flash" %> + + <%= yield %> + <%= render Components::Footer.new %> +
+ <% unless Rails.env.production? %> +
+ <% end %> + + diff --git a/app/views/layouts/backend.html.erb b/app/views/layouts/backend.html.erb new file mode 100644 index 0000000..7afe1fc --- /dev/null +++ b/app/views/layouts/backend.html.erb @@ -0,0 +1,28 @@ + + + + <%= content_for(:title) || "Identity Vault" %> + + + + <%= 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) %> + + + + <%# Includes all stylesheet files in app/assets/stylesheets %> + <%= vite_client_tag %> + <%= vite_stylesheet_tag "backend.css" %> + <%= vite_javascript_tag 'backend' %> + + + <%= render "shared/flash" %> + <%= yield %> + <% unless Rails.env.production? %> +
+ <% end %> + + diff --git a/app/views/layouts/doorkeeper/admin.html.erb b/app/views/layouts/doorkeeper/admin.html.erb new file mode 100644 index 0000000..3d1aead --- /dev/null +++ b/app/views/layouts/doorkeeper/admin.html.erb @@ -0,0 +1,39 @@ + + + + + + + <%= t('doorkeeper.layouts.admin.title') %> + <%= stylesheet_link_tag "doorkeeper/admin/application" %> + <%= csrf_meta_tags %> + + + + +
+ <%- if flash[:notice].present? %> +
+ <%= flash[:notice] %> +
+ <% end -%> + + <%= yield %> +
+ + diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb new file mode 100644 index 0000000..5939564 --- /dev/null +++ b/app/views/layouts/mailer.text.erb @@ -0,0 +1,14 @@ +<%= { + transactionalId: @TRANSACTIONAL_ID, + email: @recipient, + dataVariables: @datavariables.merge({ + env: case Rails.env + when "development" + "[DEV] " + when "staging" + "[STAGING] " + else + "​" + end, + }) +}.to_json %> \ No newline at end of file diff --git a/app/views/mailers/blank_mailer.text.erb b/app/views/mailers/blank_mailer.text.erb new file mode 100644 index 0000000..e69de29 diff --git a/app/views/onboardings/aadhaar.html.erb b/app/views/onboardings/aadhaar.html.erb new file mode 100644 index 0000000..ade0370 --- /dev/null +++ b/app/views/onboardings/aadhaar.html.erb @@ -0,0 +1,37 @@ +

Aadhaar Number Verification

+

As an Indian resident, we will verify your identity using your Aadhaar number.

+<% if @identity.errors.any? %> +
+

<%= pluralize(@identity.errors.count, "error") %> prohibited this from being saved:

+
    + <% @identity.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+<% end %> +<%= form_with model: @identity, url: aadhaar_onboarding_path, method: :post, local: true do |form| %> +
+ <%= form.label :aadhaar_number, "Enter your 12-digit Aadhaar number:" %> + <%= form.text_field :aadhaar_number, + value: @identity.aadhaar_number, + placeholder: "123456789012", + required: true, + pattern: "\\d{12}", + maxlength: "12", + style: "font-family: monospace; font-size: 1.2em; letter-spacing: 2px;" %> + Your Aadhaar number is encrypted and stored securely. We use it only for identity verification. +
+
+ <%= form.submit "Verify Aadhaar →" %> +
+<% end %> + diff --git a/app/views/onboardings/aadhaar_step_2.html.erb b/app/views/onboardings/aadhaar_step_2.html.erb new file mode 100644 index 0000000..e569efc --- /dev/null +++ b/app/views/onboardings/aadhaar_step_2.html.erb @@ -0,0 +1,2 @@ +

Continue with Digilocker

+<%= render Components::BootlegTurbo.new(async_digilocker_link_aadhaar_path, text: "generating link...") %> diff --git a/app/views/onboardings/address.html.erb b/app/views/onboardings/address.html.erb new file mode 100644 index 0000000..e2e81fe --- /dev/null +++ b/app/views/onboardings/address.html.erb @@ -0,0 +1,13 @@ +
+
+

Mailing Address

+ <% case session.dig(:stashed_data, "context") %> + <% when "stickers" %> +

We need your mailing address to send you stickers.

+ <% else %> +

Please provide your current mailing address.

+ <% end %> +
+ + <%= render 'addresses/form', address: @address, url: address_onboarding_path, submit: "Continue →" %> +
diff --git a/app/views/onboardings/basic_info.html.erb b/app/views/onboardings/basic_info.html.erb new file mode 100644 index 0000000..55bdd57 --- /dev/null +++ b/app/views/onboardings/basic_info.html.erb @@ -0,0 +1,84 @@ +<% prefill = session.dig(:stashed_data, "prefill") || {} %> +
+
+

First things first:

+ (Already have an account? + <%= link_to "Sign In", signin_onboarding_path %>) + +
+ <%= form_with model: @identity, url: basic_info_onboarding_path, method: :post, local: true do |form| %> + <%= form.hidden_field :slack_id, value: params[:slack_id] %> + <% if @identity.errors.any? %> + + <% end %> +
+
+
+ <%= form.text_field :first_name, placeholder: "First name", required: true, value: prefill["first_name"] %> + <%= form.text_field :last_name, placeholder: "Last name", required: true, value: prefill["last_name"] %> +
+ This must match the name on your identification document, unless... + + <%= form.hidden_field :legal_name_different, value: "false" %> +
+

Please enter your legal name as it appears on official documents:

+
+ <%= form.text_field :legal_first_name, placeholder: "Legal first name", value: prefill["legal_first_name"] %> + <%= form.text_field :legal_last_name, placeholder: "Legal last name", value: prefill["legal_last_name"] %> +
+
+
+
+ <%= form.label :country, "Primary country of residence" %> + <%= form.collection_select :country, Identity.countries_for_select, + :first, :last, + { include_blank: "Select a country" }, + { required: true, "x-ref": "countrySelect", "@change": "checkSanctioned()" } %> + + +
+
+ <%= form.label :primary_email, "Email address" %> + <%= form.email_field :primary_email, value: prefill["email"], placeholder: "you@example.com", required: true %> +
+
+ <%= form.label :phone_number, "Phone number" %> + <%= form.telephone_field :phone_number, value: prefill["phone_number"], placeholder: "+1 (555) 123-4567", required: true %> +
+
+ <%= form.label :birthday, "Birthday" %> + <%= form.date_field :birthday, value: prefill["birthday"], required: true %> + Required to verify program eligibility +
+
+ <%= form.submit "Continue →", "x-bind:disabled": "showSanctionedWarning" %> +
+
+ <% end %> +
diff --git a/app/views/onboardings/document.html.erb b/app/views/onboardings/document.html.erb new file mode 100644 index 0000000..661c50f --- /dev/null +++ b/app/views/onboardings/document.html.erb @@ -0,0 +1,114 @@ +<% if @is_resubmission %> +

Document Resubmission Required

+ +<% else %> +

Identity Document Upload

+<% end %> +<% case @identity.country %> +<% when "US", "AU", "CA", "SG", "UK" %> +

Please provide one of the following:

+
    +
  • A government-issued ID (driver's license, passport, + <% case @identity.country %> + <% when "US" %> + state ID + <% when "SG" %> + FIN/NRIC card + <% when "UK" %> + PASS card + <% end %> or similar) +
  • +
  • A school ID and your most recent report card/transcript
  • +
+<% when "IN" %> + We’ll need an E-Aadhaar issued by UIDAI that is digitally verified or carries a validated digital signature
+ You can get this from UIDAI, <%= link_to "instructions here", "https://hack.club/aadhaar", target: "_blank" %>.

+<% else %> +

Please provide a government-issued ID that includes your name, date of birth, and photo.

+

Examples: National ID card, Passport, Driver's license, etc.

+<% end %> +

Upload your identification document below:

+<% if @document.errors.any? %> +
+

<%= pluralize(@document.errors.count, "error") %> prohibited this document from being saved:

+
    + <% @document.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+<% end %> +<%= form_with model: @document, url: document_onboarding_path, method: :post, local: true, multipart: true, + html: { + "x-data" => "{ selectedType: '#{ @document.document_type }', transcriptFileCount: 0, studentIdFileCount: 0, governmentIdFileCount: 0, get canSubmit() { return this.selectedType === 'transcript' ? (this.transcriptFileCount >= 1 && this.studentIdFileCount >= 1) : this.governmentIdFileCount >= 1; }, submitted: false }" + } do |form| %> + <%= form.label :document_type, "Document type:" %> + <%= form.select :document_type, + Identity::Document.collection_select_options_for_country(@identity.country), + { include_blank: "Select document type" }, + { required: true, "x-model": "selectedType" } %> +
+ + <%= form.label :files, "Upload your government-issued ID:" %> + <%= file_field_tag "identity_document[files][]", :required => false, :accept => (@identity.country == "IN" ? ".pdf" : "image/*,.pdf,.heic,.heif"), "x-on:change" => 'governmentIdFileCount = $event.target.files.length' %> +
+
+
+ <%= label_tag "identity_document_files", "Upload your transcript:" %> + <%= file_field_tag "identity_document[files][]", :required => false, :accept => (@identity.country == "IN" ? ".pdf" : "image/*,.pdf,.heic,.heif"), "x-on:change" => 'transcriptFileCount = $event.target.files.length' %> +
+
+ <%= label_tag "identity_document_files", "Upload your student ID:" %> + <%= file_field_tag "identity_document[files][]", :required => false, :accept => (@identity.country == "IN" ? ".pdf" : "image/*,.pdf,.heic,.heif"), "x-on:change" => 'studentIdFileCount = $event.target.files.length' %> +
+
+
+ Uploading...
+ <%= vite_image_tag "images/loader.gif", style: "image-rendering: pixelated;" %>
+
+
+ <% case session.dig(:stashed_data, "context") %> +<% when nil, "stickers" %> +

After this, the HCB team will review your documents (this should take 1-2 business days).

+

Once you're approved, you can start earning prizes!

+ <% else %> +

We'll review your documents and get back to you as soon as possible.

+ <% end %> + <%= form.submit "Submit Documents →", + ":disabled" => "!canSubmit", + "x-bind:class" => "!canSubmit ? 'opacity-50 cursor-not-allowed' : ''", + "x-on:click" => "submitted = true" %> +<% end %> + diff --git a/app/views/onboardings/submitted.html.erb b/app/views/onboardings/submitted.html.erb new file mode 100644 index 0000000..a5de5b5 --- /dev/null +++ b/app/views/onboardings/submitted.html.erb @@ -0,0 +1,8 @@ +<%= render "shared/verification_status", identity: @identity %> +
+ <% if session[:oauth_return_to] %> + <%= link_to "Continue →", continue_onboarding_path, role: "button" %> + <% else %> + <%= render Components::HomeButton.new %> + <% end %> +
diff --git a/app/views/onboardings/welcome.html.erb b/app/views/onboardings/welcome.html.erb new file mode 100644 index 0000000..235d17f --- /dev/null +++ b/app/views/onboardings/welcome.html.erb @@ -0,0 +1,26 @@ +

Welcome to Hack Club's identity platform!

+

+ <% case session.dig(:stashed_data, "context") %> + <% when "stickers" %> + We are excited to send you free stickers + <% else %> + We are excited to meet you + <% end %> + – but first, we need to verify that you are 18 years old or under.

+

In the past year, Hack Club has given out ~$1M in grants to students like you, and with that comes a lot of adults trying to slip in.

+
+
+
+

✨ I'm new here!

+

Let's get you set up with a new account

+ <%= link_to "Get started →", basic_info_onboarding_path, + role: "button" %> +
+
+

🔑 I've been here before...

+

Sign in to continue where you left off

+ <%= link_to "Sign in →", signin_onboarding_path, + role: "button", + class: "secondary" %> +
+
diff --git a/app/views/public_activity/break_glass_record/_create.html.erb b/app/views/public_activity/break_glass_record/_create.html.erb new file mode 100644 index 0000000..1ac0d81 --- /dev/null +++ b/app/views/public_activity/break_glass_record/_create.html.erb @@ -0,0 +1,7 @@ +<%= render Components::PublicActivity::Snippet.new(activity) do %> + <% if activity.trackable&.automatic? %> + automatically + <% end %> + broke the glass on <%= render activity.trackable&.break_glassable&.identity if activity.trackable&.break_glassable&.identity %>'s <%= link_to "verification", backend_verification_path(activity.trackable.break_glassable.verification) if activity.trackable.present? %> + <%= activity.trackable&.reason %>. +<% end %> diff --git a/app/views/public_activity/identity/_admin_update.html.erb b/app/views/public_activity/identity/_admin_update.html.erb new file mode 100644 index 0000000..7ac2fd0 --- /dev/null +++ b/app/views/public_activity/identity/_admin_update.html.erb @@ -0,0 +1,3 @@ +<%= render Components::PublicActivity::Snippet.new(activity) do %> + edited <%= render activity.trackable %>'s identity. (<%= activity.parameters[:reason] %>) +<% end %> diff --git a/app/views/public_activity/identity/_clear_slack_id.html.erb b/app/views/public_activity/identity/_clear_slack_id.html.erb new file mode 100644 index 0000000..e66fe0f --- /dev/null +++ b/app/views/public_activity/identity/_clear_slack_id.html.erb @@ -0,0 +1,3 @@ +<%= render Components::PublicActivity::Snippet.new(activity) do %> + cleared <%= render activity.trackable %>'s linked Slack ID +<% end %> diff --git a/app/views/public_activity/identity/_create.html.erb b/app/views/public_activity/identity/_create.html.erb new file mode 100644 index 0000000..220d7b1 --- /dev/null +++ b/app/views/public_activity/identity/_create.html.erb @@ -0,0 +1,3 @@ +<%= render Components::PublicActivity::Snippet.new(activity, owner: activity.trackable) do %> + created an identity. +<% end %> diff --git a/app/views/public_activity/identity/_set_slack_id.html.erb b/app/views/public_activity/identity/_set_slack_id.html.erb new file mode 100644 index 0000000..aacd58e --- /dev/null +++ b/app/views/public_activity/identity/_set_slack_id.html.erb @@ -0,0 +1,3 @@ +<%= render Components::PublicActivity::Snippet.new(activity) do %> + set this identity's Slack ID. +<% end %> diff --git a/app/views/public_activity/identity/_update.html.erb b/app/views/public_activity/identity/_update.html.erb new file mode 100644 index 0000000..a4a9fa5 --- /dev/null +++ b/app/views/public_activity/identity/_update.html.erb @@ -0,0 +1,3 @@ +<%= render Components::PublicActivity::Snippet.new(activity) do %> + edited <%= render activity.trackable %>'s identity. +<% end %> diff --git a/app/views/public_activity/verification/_approve.html.erb b/app/views/public_activity/verification/_approve.html.erb new file mode 100644 index 0000000..9ecceff --- /dev/null +++ b/app/views/public_activity/verification/_approve.html.erb @@ -0,0 +1,6 @@ +<%= render Components::PublicActivity::Snippet.new(activity) do %> + approved <%= render activity.trackable.identity %>'s <%= link_to "verification", backend_verification_path(activity.trackable) %> + <% unless activity.parameters[:ysws_eligible].nil? %> + as YSWS <%= activity.parameters[:ysws_eligible] ? "eligible" : "ineligible" %>. + <% end %> +<% end %> diff --git a/app/views/public_activity/verification/_create.html.erb b/app/views/public_activity/verification/_create.html.erb new file mode 100644 index 0000000..4edae59 --- /dev/null +++ b/app/views/public_activity/verification/_create.html.erb @@ -0,0 +1,3 @@ +<%= render Components::PublicActivity::Snippet.new(activity) do %> + started the verification process. +<% end %> diff --git a/app/views/public_activity/verification/_reject.html.erb b/app/views/public_activity/verification/_reject.html.erb new file mode 100644 index 0000000..e8f748a --- /dev/null +++ b/app/views/public_activity/verification/_reject.html.erb @@ -0,0 +1,6 @@ +<%= render Components::PublicActivity::Snippet.new(activity) do %> + rejected <%= render activity.trackable.identity %>'s <%= link_to "verification", backend_verification_path(activity.trackable) %> + <% unless activity.parameters[:reason].nil? %> + for <%= Verification::DocumentVerification::REJECTION_REASON_NAMES[activity.parameters[:reason]].downcase %>. + <% end %> +<% end %> diff --git a/app/views/public_activity/verification_aadhaar_verification/_create.html.erb b/app/views/public_activity/verification_aadhaar_verification/_create.html.erb new file mode 100644 index 0000000..1af3902 --- /dev/null +++ b/app/views/public_activity/verification_aadhaar_verification/_create.html.erb @@ -0,0 +1,3 @@ +<%= render Components::PublicActivity::Snippet.new(activity) do %> + started the Aadhaar flow. +<% end %> diff --git a/app/views/public_activity/verification_aadhaar_verification/_create_link.html.erb b/app/views/public_activity/verification_aadhaar_verification/_create_link.html.erb new file mode 100644 index 0000000..9b16228 --- /dev/null +++ b/app/views/public_activity/verification_aadhaar_verification/_create_link.html.erb @@ -0,0 +1,3 @@ +<%= render Components::PublicActivity::Snippet.new(activity) do %> +created a TruthScreen link in the Aadhaar flow. +<% end %> diff --git a/app/views/public_activity/verification_aadhaar_verification/_data_received.html.erb b/app/views/public_activity/verification_aadhaar_verification/_data_received.html.erb new file mode 100644 index 0000000..3893209 --- /dev/null +++ b/app/views/public_activity/verification_aadhaar_verification/_data_received.html.erb @@ -0,0 +1,3 @@ +<%= render Components::PublicActivity::Snippet.new(activity) do %> + successfully imported their Aadhaar data. +<% end %> diff --git a/app/views/public_activity/verification_document_verification/_approve.html.erb b/app/views/public_activity/verification_document_verification/_approve.html.erb new file mode 100644 index 0000000..9ecceff --- /dev/null +++ b/app/views/public_activity/verification_document_verification/_approve.html.erb @@ -0,0 +1,6 @@ +<%= render Components::PublicActivity::Snippet.new(activity) do %> + approved <%= render activity.trackable.identity %>'s <%= link_to "verification", backend_verification_path(activity.trackable) %> + <% unless activity.parameters[:ysws_eligible].nil? %> + as YSWS <%= activity.parameters[:ysws_eligible] ? "eligible" : "ineligible" %>. + <% end %> +<% end %> diff --git a/app/views/public_activity/verification_document_verification/_create.html.erb b/app/views/public_activity/verification_document_verification/_create.html.erb new file mode 100644 index 0000000..8e6d9c5 --- /dev/null +++ b/app/views/public_activity/verification_document_verification/_create.html.erb @@ -0,0 +1,3 @@ +<%= render Components::PublicActivity::Snippet.new(activity) do %> + uploaded a <%= activity.trackable.identity_document.document_type %> <%= link_to "document", backend_verification_path(activity.trackable) %>. +<% end %> diff --git a/app/views/public_activity/verification_document_verification/_ignored.html.erb b/app/views/public_activity/verification_document_verification/_ignored.html.erb new file mode 100644 index 0000000..d7ead0e --- /dev/null +++ b/app/views/public_activity/verification_document_verification/_ignored.html.erb @@ -0,0 +1,3 @@ +<%= render Components::PublicActivity::Snippet.new(activity) do %> + marked a <%= link_to "verification", backend_verification_path(activity.trackable) %> as ignored because <%= activity.parameters[:reason] %> +<% end %> diff --git a/app/views/public_activity/verification_document_verification/_reject.html.erb b/app/views/public_activity/verification_document_verification/_reject.html.erb new file mode 100644 index 0000000..e8f748a --- /dev/null +++ b/app/views/public_activity/verification_document_verification/_reject.html.erb @@ -0,0 +1,6 @@ +<%= render Components::PublicActivity::Snippet.new(activity) do %> + rejected <%= render activity.trackable.identity %>'s <%= link_to "verification", backend_verification_path(activity.trackable) %> + <% unless activity.parameters[:reason].nil? %> + for <%= Verification::DocumentVerification::REJECTION_REASON_NAMES[activity.parameters[:reason]].downcase %>. + <% end %> +<% end %> diff --git a/app/views/public_activity/verification_document_verification/_update.html.erb b/app/views/public_activity/verification_document_verification/_update.html.erb new file mode 100644 index 0000000..2156c3c --- /dev/null +++ b/app/views/public_activity/verification_document_verification/_update.html.erb @@ -0,0 +1,3 @@ +<%= render Components::PublicActivity::Snippet.new(activity) do %> + edited a <%= activity.trackable.identity_document.document_type %> <%= link_to "document", backend_verification_path(activity.trackable) %>. +<% end %> diff --git a/app/views/public_activity/verification_vouch_verification/_create.html.erb b/app/views/public_activity/verification_vouch_verification/_create.html.erb new file mode 100644 index 0000000..e005b1c --- /dev/null +++ b/app/views/public_activity/verification_vouch_verification/_create.html.erb @@ -0,0 +1,3 @@ +<%= render Components::PublicActivity::Snippet.new(activity) do %> + created a vouch <%= link_to "verification", backend_verification_path(activity.trackable) %>. +<% end %> diff --git a/app/views/public_activity/verification_vouch_verification/_ignored.html.erb b/app/views/public_activity/verification_vouch_verification/_ignored.html.erb new file mode 100644 index 0000000..2563d38 --- /dev/null +++ b/app/views/public_activity/verification_vouch_verification/_ignored.html.erb @@ -0,0 +1,3 @@ +<%= render Components::PublicActivity::Snippet.new(activity) do %> + marked a <%= link_to "vouch verification", backend_verification_path(activity.trackable) %> as ignored because <%= activity.parameters[:reason] %> +<% end %> diff --git a/app/views/pwa/manifest.json.erb b/app/views/pwa/manifest.json.erb new file mode 100644 index 0000000..27d570f --- /dev/null +++ b/app/views/pwa/manifest.json.erb @@ -0,0 +1,22 @@ +{ + "name": "IdentityVault", + "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": "IdentityVault.", + "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 0000000..b3a13fb --- /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/app/views/sessions/check_your_email.html.erb b/app/views/sessions/check_your_email.html.erb new file mode 100644 index 0000000..48cced9 --- /dev/null +++ b/app/views/sessions/check_your_email.html.erb @@ -0,0 +1,17 @@ +<% content_for :title, "check your email..." %> +
+
+

check your email...

+

we've sent a login link to your email address. please click the link to continue.

+

+ Didn't receive an email? + <%= link_to "Send another login link", new_sessions_path, class: "secondary" %> + +

+ <% if Rails.env.development? %> +

you're in dev! check + <%= link_to "letter opener", letter_opener_web_path, target: "_blank" %>! +

+ <% end %> +
+
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb new file mode 100644 index 0000000..26a0d0a --- /dev/null +++ b/app/views/sessions/new.html.erb @@ -0,0 +1,21 @@ +<% content_for :title, "Sign in" %> +
+

Sign in

+
+<%= form_with url: sessions_path, method: :post, local: true do |form| %> + <%= form.hidden_field :return_url, value: params[:return_url] if params[:return_url] %> +
+ <%= form.label :email, "Email address" %> + <%= form.email_field :email, + required: true, + placeholder: "Enter your email address", + value: session[:sign_in_email] %> +
+ <%= form.submit "Send login code!", style: "max-width: 200px;" %> +<% end %> +
+

+ Don't have an account? + <%= link_to "Get started here", basic_info_onboarding_path %> +

+
diff --git a/app/views/sessions/verify.html.erb b/app/views/sessions/verify.html.erb new file mode 100644 index 0000000..dad2e3c --- /dev/null +++ b/app/views/sessions/verify.html.erb @@ -0,0 +1,19 @@ +<% content_for :title, "Confirm Sign In" %> +
+
+
+

+ You're signing in as:
+ <%= @login_code.identity.primary_email %> +

+
+ <%= form_with url: confirm_sessions_path, method: :post, local: true do |form| %> + <%= form.hidden_field :token, value: @login_code.token %> + <%= form.submit "Confirm Sign In", class: "primary" %> + <% end %> + + Not you? + <%= link_to "Request a new login link", new_sessions_path %> + +
+
diff --git a/app/views/shared/_flash.html.erb b/app/views/shared/_flash.html.erb new file mode 100644 index 0000000..45fe5b6 --- /dev/null +++ b/app/views/shared/_flash.html.erb @@ -0,0 +1,28 @@ +<% flash.each do |type, message| %> + <% if message.present? %> + <% alert_class = case type.to_sym + when :success then 'success' + when :notice, :info then 'info' + when :warning then 'warning' + when :alert, :error, :danger then 'danger' + else 'info' + end %> + + <% end %> +<% end %> diff --git a/app/views/shared/_verification_status.html.erb b/app/views/shared/_verification_status.html.erb new file mode 100644 index 0000000..74c31ab --- /dev/null +++ b/app/views/shared/_verification_status.html.erb @@ -0,0 +1,53 @@ +<% case identity.verification_status %> +<% when "verified" %> +

Verification Complete

+

Your identity has been successfully verified and you are approved!

+<% when "pending" %> + <% if identity.needs_resubmission? %> +

Document Resubmission Required

+

Some of your documents were rejected for correctable reasons. Please address the issues below and resubmit:

+
+
    + <% identity.rejected_verifications_needing_resubmission.each do |verification| %> +
  • + <%= verification.rejection_reason_name %> + <% if verification.rejection_reason_details.present? %> + - <%= verification.rejection_reason_details %> + <% end %> +
  • + <% end %> +
+
+ <% if identity.country == "IN" %> +

<%= link_to "Resubmit Aadhaar Verification", aadhaar_onboarding_path, role: "button" %>

+ <% else %> +

<%= link_to "Resubmit Documents", document_onboarding_path, role: "button" %>

+ <% end %> + <% else %> +

Documents Under Review

+

Thanks for uploading your documents! Our team is reviewing them now, which typically takes 1-2 business days.

+

We'll email you as soon as your verification is complete.

+

You'll be promoted to a full Slack user at that point. (?)

+ <% end %> +<% when "ineligible" %> +

Verification Rejected

+

Your identity verification has been rejected for the following reasons:

+ <% fatal_rejections = identity.verifications.rejected.fatal_rejections %> +
+
    + <% fatal_rejections.each do |verification| %> +
  • + <%= verification.rejection_reason_name %> + <% if verification.rejection_reason_details.present? %> + - <%= verification.rejection_reason_details %> + <% end %> +
  • + <% end %> +
+
+

These issues cannot be corrected through resubmission.

+<% else %> +

Documents Under Review

+

Thanks for uploading your documents! Our team is reviewing them now, which typically takes 1-2 business days.

+

We'll email you as soon as your verification is complete.

+<% end %> diff --git a/app/views/shared/async_flash.erb b/app/views/shared/async_flash.erb new file mode 100644 index 0000000..aaef2e4 --- /dev/null +++ b/app/views/shared/async_flash.erb @@ -0,0 +1,28 @@ +<% f.each do |type, message| %> + <% if message.present? %> + <% alert_class = case type.to_sym + when :success then 'success' + when :notice, :info then 'info' + when :warning then 'warning' + when :alert, :error, :danger then 'danger' + else 'info' + end %> + + <% end %> +<% end %> \ No newline at end of file diff --git a/app/views/static_pages/external_api_docs.html.erb b/app/views/static_pages/external_api_docs.html.erb new file mode 100644 index 0000000..5cec5ce --- /dev/null +++ b/app/views/static_pages/external_api_docs.html.erb @@ -0,0 +1,64 @@ +<%= render Components::Window.new("Identity Vault External API") do %> +
+ (click any of the URLs to copy them) +
+
+ + Check an identity's status: + +
+ + <%= render Components::APIExample.new(method: "GET", url: api_external_check_url) %> + Parameters are any of:
+ <%= render Components::APIExample.new(method: "GET", url: api_external_check_url(email: "nora@hackclub.com"), path_only: true) %> + <%= render Components::APIExample.new(method: "GET", url: api_external_check_url(slack_id: "U06QK6AG3RD"), path_only: true) %> + <%= render Components::APIExample.new(method: "GET", url: api_external_check_url(idv_id: "ident!ZEOfPe"), path_only: true) %> + + Response will be of the shape:

+
{
+    "result": "{code}"
+}
+
+ Possible results: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Code + + Meaning +
not_foundcouldn't find that identity
needs_submissionuser needs to submit or resubmit
pendingsubmitted, but not processed yet
rejectedduplicate identity or bad submission
verified_eligibleverified and YSWS eligible!
verified_but_over_18verified but not YSWS eligible
+
+<% end %> diff --git a/app/views/static_pages/faq.html.erb b/app/views/static_pages/faq.html.erb new file mode 100644 index 0000000..9ef3d06 --- /dev/null +++ b/app/views/static_pages/faq.html.erb @@ -0,0 +1,77 @@ +<% +# language=markdown +md = <<~EOM +## #0 – a little background: + +Hi! First off, I'd like to share a little background on Hack Club if you're not familiar (yet!): + +We're a charity that helps teenagers build projects, founded about 10 years ago. + +You can read about us on [Wikipedia](https://en.wikipedia.org/wiki/Hack_Club), in the [Wall Street Journal](https://www.wsj.com/articles/teen-hackers-try-to-convince-parents-they-are-up-to-good-11569922200), and [Make: Magazine](https://makezine.com/article/technology/hack-club-creating-a-foundation-to-empower-thousands-of-teen-makers-worldwide/). + +Every year tens of thousands of teens build something as part of Hack Club. (check out some videos of them [here!](https://www.youtube.com/channel/UCQzO0jpcRkP-9eWKMpJyB0w)) + +## #1 – why do you need my ID? + +Because we give out free resources to teenagers, and people like free things, there is a small but persistent contingent of people pretending to be teenagers or otherwise trying to defraud us. + +We need your ID to verify that you are, in fact, 18 or under, and that you aren't someone we've previously had to ban for fraud. + +## #2 – can I trust how the ID verification system is structured? + +hi! i'm nora, the creator of [identity.hackclub.com](https://identity.hackclub.com), and in your position i would absolutely be asking the same question. + +i care a lot about privacy, probably more than a lot of people. + +i grew up on the internet being paranoid about this kind of thing, which is the right way to operate, you *should* be skeptical of anybody asking you for this kind of trust! + +that said, i built this platform to be, within the constraints we operate under as a small nonprofit, something i would feel safe uploading a scan of my passport to. + +### #2a – who has access to my identity documents? +**During review:** +Only a small number of people at Hack Club/on the HCB team who have all signed comprehensive NDAs can view your identity document for the purpose of verifying it. + +**After review:** 24 hours after a document is accepted or rejected by a reviewer, the review team loses access to the files and the only people who can access historic, encrypted documents are myself ([@nora](https://hackclub.slack.com/team/U06QK6AG3RD), creator of this platform, full-time HQ staff & NDA signer), Zach ([@zrl](https://hackclub.slack.com/team/U0266FRGP), Hack Club founder & executive director), and [Leo](https://hackclub.slack.com/team/U07BLJ1MBEE), HCB operations & identity team lead. + +This is called "Break-the-Glass" access and it's reserved for rare situations such as investigating fraud. +There is an audit log of every time it's used, and each time requires a separate specific justification. + +### #2b – what can you tell me about the security of this platform? + +This is a Ruby on Rails app running on a Hetzner server separate from the rest of Hack Club HQ's infrastructure. +The only people who have direct access to this server are me (Nora) and Zach, the founder of Hack Club. +We both need physical second-factor tokens to access this server. Access to the production console is [logged](https://github.com/basecamp/console1984) and [audited](https://github.com/basecamp/audits1984/). + +Identity documents are encrypted in transit between your computer and our server, in transit between that server and the underlying object storage (Cloudflare R2), and at rest in that storage (using a unique AES-256-GCM key per file via [SSE-C](https://docs.aws.amazon.com/AmazonS3/latest/userguide/serv-side-encryption.html)). + +## #3 – what's your retention policy? +This may change as Hack Club grows and scales, but: + +Right now, we need to keep ID docs on file indefinitely in an encrypted, restricted-access form to investigate cases of fraud. +We are a charity, and unfortunately, a small number of people are willing to try to defraud us to have our money go to them instead of supporting teenagers building projects. +Historically, our ID verification system has been key in catching examples of this. + +As stated in #2a, there are only two people who can view historical documents, and only in extreme cases. + +If this isn't a tradeoff you're comfortable with, that's fine, but in that case we aren't able to offer you resources through our grant programs. + +## #4 – why are you rolling your own platform?
why not use SheerID, or Stripe Identity, or {some other third party service?} +2 reasons: + +1: As I'm writing this (2025-06-18), there is no 3rd-party ID verification service that reliably works for high schoolers' IDs in the wide range of countries we operate in. + +and, 2: many of these services pass fraudulent documents at rates higher than you'd expect. + +## #5 – my country has a digital ID platform, why don't you support it? + +Linking against {your country's digital ID platform} would be a great feature! + +Unfortunately, we have a very small team – this identity platform is currently a one-woman show and I'm wearing a lot of other hats here. + +Right now, doing things that directly help people build projects is a higher priority for me. Maybe in the future! +EOM +%> +<% content_for :title, "HC Identity FAQ" %> +

FAQ

+<%== Redcarpet::Markdown.new(Redcarpet::Render::HTML.new(link_attributes: { target: "_blank" })).render(md) %> +<%= render Components::HomeButton.new %> diff --git a/app/views/static_pages/index.html.erb b/app/views/static_pages/index.html.erb new file mode 100644 index 0000000..b1a1690 --- /dev/null +++ b/app/views/static_pages/index.html.erb @@ -0,0 +1,61 @@ +<% if current_identity %> +

you're logged in as <%= current_identity.primary_email %>.

+
    +
  • + <% case current_identity.verification_status %> + <% when "verified" %> + ✅ your identity is verified + <% when "pending" %> + ⏳ your identity verification is pending review + <% when "ineligible" %> + ❌ your identity verification has been rejected + <% rejected_verifications = current_identity.verifications.rejected %> + <% fatal_rejections = rejected_verifications.fatal_rejections %> + <% if fatal_rejections.any? %> +
    + + Unfortunately, you are not eligible: + + <% fatal_rejections.each do |verification| %> +
    + + • <%= verification.rejection_reason_name %> + <% if verification.rejection_reason_details.present? %> + - <%= verification.rejection_reason_details %> + <% end %> + + <% end %> + <% end %> + <% when "needs_submission" %> + <% if current_identity.needs_resubmission? %> +
    +
    + + <% else %> + 📋 <%= link_to "complete your identity verification", basic_info_onboarding_path %> + <% end %> + <% end %> +
  • +
  • <%= link_to "see the programs", oauth_authorized_applications_path %> you've given access to?
  • +
  • + <% if current_identity.slack_linked? %> + 🔗 your Slack account is linked (<%= current_identity.slack_id %>) + <% else %> + <%= link_to "link your Slack account", link_slack_account_path %>? + <% end %> +
  • +
  • <%= link_to "manage your addresses", addresses_path %>?
  • +
+<% end %> diff --git a/bin/brakeman b/bin/brakeman new file mode 100755 index 0000000..ace1c9b --- /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/bundle b/bin/bundle new file mode 100755 index 0000000..50da5fd --- /dev/null +++ b/bin/bundle @@ -0,0 +1,109 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'bundle' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +require "rubygems" + +m = Module.new do + module_function + + def invoked_as_script? + File.expand_path($0) == File.expand_path(__FILE__) + end + + def env_var_version + ENV["BUNDLER_VERSION"] + end + + def cli_arg_version + return unless invoked_as_script? # don't want to hijack other binstubs + return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update` + bundler_version = nil + update_index = nil + ARGV.each_with_index do |a, i| + if update_index && update_index.succ == i && a.match?(Gem::Version::ANCHORED_VERSION_PATTERN) + bundler_version = a + end + next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ + bundler_version = $1 + update_index = i + end + bundler_version + end + + def gemfile + gemfile = ENV["BUNDLE_GEMFILE"] + return gemfile if gemfile && !gemfile.empty? + + File.expand_path("../Gemfile", __dir__) + end + + def lockfile + lockfile = + case File.basename(gemfile) + when "gems.rb" then gemfile.sub(/\.rb$/, ".locked") + else "#{gemfile}.lock" + end + File.expand_path(lockfile) + end + + def lockfile_version + return unless File.file?(lockfile) + lockfile_contents = File.read(lockfile) + return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ + Regexp.last_match(1) + end + + def bundler_requirement + @bundler_requirement ||= + env_var_version || + cli_arg_version || + bundler_requirement_for(lockfile_version) + end + + def bundler_requirement_for(version) + return "#{Gem::Requirement.default}.a" unless version + + bundler_gem_version = Gem::Version.new(version) + + bundler_gem_version.approximate_recommendation + end + + def load_bundler! + ENV["BUNDLE_GEMFILE"] ||= gemfile + + activate_bundler + end + + def activate_bundler + gem_error = activation_error_handling do + gem "bundler", bundler_requirement + end + return if gem_error.nil? + require_error = activation_error_handling do + require "bundler/version" + end + return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION)) + warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`" + exit 42 + end + + def activation_error_handling + yield + nil + rescue StandardError, LoadError => e + e + end +end + +m.load_bundler! + +if m.invoked_as_script? + load Gem.bin_path("bundler", "bundle") +end diff --git a/bin/dev b/bin/dev new file mode 100755 index 0000000..5f91c20 --- /dev/null +++ b/bin/dev @@ -0,0 +1,2 @@ +#!/usr/bin/env ruby +exec "./bin/rails", "server", *ARGV diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint new file mode 100755 index 0000000..57567d6 --- /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/lint b/bin/lint new file mode 100755 index 0000000..a44a972 --- /dev/null +++ b/bin/lint @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -e + +echo "rubocoppin'..." +bundle exec rubocop -A +echo "erb_lint-in'?" +bundle exec erb_lint app/views/ -a diff --git a/bin/rails b/bin/rails new file mode 100755 index 0000000..efc0377 --- /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 0000000..4fbf10b --- /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 0000000..40330c0 --- /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 0000000..be3db3c --- /dev/null +++ b/bin/setup @@ -0,0 +1,34 @@ +#!/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") + + # 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 0000000..36bde2d --- /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 0000000..5da3388 --- /dev/null +++ b/bin/vite @@ -0,0 +1,27 @@ +#!/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__) + +bundle_binstub = File.expand_path("bundle", __dir__) + +if File.file?(bundle_binstub) + if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") + load(bundle_binstub) + else + abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. +Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") + end +end + +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 0000000..4a3c09a --- /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 0000000..4e38802 --- /dev/null +++ b/config/application.rb @@ -0,0 +1,77 @@ +require_relative "boot" + +require "rails" +# Pick the frameworks you want: +require "active_model/railtie" +require "active_job/railtie" +require "active_record/railtie" +require "active_storage/engine" +require "action_controller/railtie" +require "action_mailer/railtie" +require "action_mailbox/engine" +require "action_text/engine" +require "action_view/railtie" +# require "action_cable/engine" +# require "rails/test_unit/railtie" + +# Require the gems listed in Gemfile, including any gems +# you've limited to :test, :development, or :production. +Bundler.require(*Rails.groups) + +module IdentityVault + class Application < Rails::Application + config.autoload_paths << "#{root}/app/views/forms" + # 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") + + # Don't generate system test files. + config.generators.system_tests = nil + + config.semantic_logger.application = "identity-vault" + config.semantic_logger.environment = Rails.env + config.log_level = :info + + unless Rails.env.development? + config.rails_semantic_logger.add_file_appender = false + config.semantic_logger.add_appender(io: $stdout, formatter: :json) + config.semantic_logger.add_appender(appender: :honeybadger_insights) + end + + config.to_prepare do + Doorkeeper::ApplicationController.layout "application" + Doorkeeper::ApplicationController.skip_before_action :authenticate_identity! + Backend::NoAuthController.skip_after_action :verify_authorized + end + + config.session_store :cookie_store, + key: "_identity_vault_session_v2", + expire_after: 90.days, + secure: Rails.env.production?, + httponly: true + + config.audits1984.base_controller_class = "Backend::NoAuthController" + config.audits1984.auditor_class = "Backend::User" + config.audits1984.auditor_name_attribute = :username + + config.console1984.ask_for_username_if_empty = true + + # Aadhaar verification configuration + config.sanctioned_countries = config_for(:sanctioned_countries) + + # Use ImageMagick for image processing instead of VIPS + config.active_storage.variant_processor = :mini_magick + end +end diff --git a/config/boot.rb b/config/boot.rb new file mode 100644 index 0000000..988a5dd --- /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.ignore b/config/brakeman.ignore new file mode 100644 index 0000000..61675c6 --- /dev/null +++ b/config/brakeman.ignore @@ -0,0 +1,62 @@ +{ + "ignored_warnings": [ + { + "warning_type": "Dynamic Render Path", + "warning_code": 15, + "fingerprint": "60d8df7190a1ed518ea8679aa9c8d919f27fe7a6366669433833caa541f5040d", + "check_name": "Render", + "message": "Render path contains parameter value", + "file": "app/views/backend/users/edit.html.erb", + "line": 3, + "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/", + "code": "render(action => Backend::Users::Form.new(User.find(params[:id])), { :locals => ({ :\"backend::users::form\" => Backend::Users::Form.new(User.find(params[:id])) }) })", + "render_path": [ + { + "type": "controller", + "class": "Backend::UsersController", + "method": "edit", + "line": 17, + "file": "app/controllers/backend/users_controller.rb", + "rendered": { + "name": "backend/users/edit", + "file": "app/views/backend/users/edit.html.erb" + } + } + ], + "location": { + "type": "template", + "template": "backend/users/edit" + }, + "user_input": "params[:id]", + "confidence": "Weak", + "cwe_id": [ + 22 + ], + "note": "barring some bug in superform, this is fine" + }, + { + "warning_type": "Redirect", + "warning_code": 18, + "fingerprint": "aedd908b558aa6308899ced4cdfa0918a074c38253b96d1ef06448762bd556be", + "check_name": "Redirect", + "message": "Possible unprotected redirect", + "file": "app/controllers/slack_accounts_controller.rb", + "line": 7, + "link": "https://brakemanscanner.org/docs/warning_types/redirect/", + "code": "redirect_to(Identity.slack_authorize_url(url_for(:action => :create, :only_path => false)), :host => \"https://slack.com\", :allow_other_host => true)", + "render_path": null, + "location": { + "type": "method", + "class": "SlackAccountsController", + "method": "new" + }, + "user_input": "Identity.slack_authorize_url(url_for(:action => :create, :only_path => false))", + "confidence": "Weak", + "cwe_id": [ + 601 + ], + "note": "this will only ever be Slack" + } + ], + "brakeman_version": "7.1.0" +} diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc new file mode 100644 index 0000000..94f40e1 --- /dev/null +++ b/config/credentials.yml.enc @@ -0,0 +1 @@ +zo3UI/UqwQPGM+e23doyOyZFpQ+toWYutFpboBvWf3c5b+KiaUeFzwQ3X5F2maJRW+dgDmjy7YTMiRGehZ0bpoKfZg/v6f2A4FJhBfjMsUn14uziDlnJoKfcL3kz4H47pAEG9pTaMSo4Al8Z9+deRsK//thtDHLmBCE3kuI7NU7zf+kBio7zANNK/TzvHG+nzdoUMEdVV+UyjMBMAJpRsr6d+s2+RJyzVuxRkZ4mzn+BnCjVq6BprmEnNBktY9c8wsfX1DU0Bet8pzjVOJu47mFyzp1Z30e1cTG0WMA1wqNb9Wm+ny0zctriH5UkPFFMbHDPhlyX2r/+3aPTMziG/G5CE6RpnQ9J2GVkQe0+O8JWhZmZaE2GOfGNdSfk1Dnr3JKN8jFmufAD3+dfVXlbVbrA/YRgMlvomWU+9dTXMeEKuM3nnjF+A+0ljePtuX4EQqN/9UmaY+9LxaCPjKuAbvBYbFeIgZR3jNN0NOrVaxmEC90VPJ07hbfEaXys7xBP9blA0MU0b2tpptjOKE+UMybucSCSCZ+tYoBLgz3UygWoRJpLtBGLo0cluDkMbl7pNAVvfhN49nfzrnFwh95BnbPwd9fkYH+eKVhXr6Y5fu99bsBschN+nYdcejdfBBW7k0vrdnySNWEyZ7qEecJVCJuMod1Y3LysnN8REDFtYW39msIo65HjMhVwDRcz3B3ECE/nXVRzsV1yThUG5M3u24CxYbgwemjrothTBk68bTVm7ZWRRhXHhQT+tIt1T083CBNXOzmNxg86XUVRiiop3020EQAxK/k8bFosyz9gxVFkg5XiAvJGb1nK8cRAKs6Qpa5vLn3qVHjXTxDBLhIFIAJ4yMkMD/islArQbyVel0lkNm6L2I0wAE/SmZfn+Q86KS5rHPD5qg7brpLtiSMgklcj1+p9g59tDXZF9J4NlbBxlLnmHLpDeA1qK5bcqm6YIcN2pJNOsHtCZzUK1xI8sLCJdXEKztdwly5jgVxxsB3aYZymvdZbfXyyy3XsYHHEZHyVpS4xOLepzGddD/TJELIs3cvXjynWn/l58mhZNRZZHUVGRZXPE1D+uQFnMKNCeVHC8B4owdXGnD/TnZG3s50N/iJPN/b0Wog0mI/pIdGyMM+H2XpDEWgieBhW9kGBiWRMrzSJz8F3zEFM602AzvtdHQrE+WH7NEr5mNy0/hWgxL2SGpbySWjWbeXAXQDFH8Ovx1P/pmrbdEbsX6GTUCM=--ze+UL8nllGsLNhFb--20IqL5AlS4VjzgMzC3tFkg== \ No newline at end of file diff --git a/config/credentials/production.yml.enc b/config/credentials/production.yml.enc new file mode 100644 index 0000000..e5830e7 --- /dev/null +++ b/config/credentials/production.yml.enc @@ -0,0 +1 @@ +BurUUbLP11IDHWyiYRYz7FkogGjs+Pv9lhau25ucHYy2ud87j0YGvEuuHA0a5ajtFGhLwnP3uW5EAaIMSftch709K0gpuSZuKm2e4a2PPNNPiTIj068LslsfmqX2l2WDIuKwqZYMohzcrQxgB8xAxzyGJcTXXM8JI3ati8mD2pOur4S/Mfg9A1rCuXU42puPCwV7euQa/UAsuZsM+2BfL2biIVVYtPqJKXFd7/53hswRXLmylTa7ZG61EonDZvX21fBr4mFMOR8dQD5X4fS8rNH2NdVK4Fs1YcfFFw+UfHPYav9/NIwdzEJ9NCJ6qMOO7iimrxA0TVl8tvcymOaW+fiQ/ucaTwbf1JbLM+5W/EIKTHSbXMDqQzb1eQgivagKS8jN1Pit3YG1hkAFMgmjjuGX71coOWqPI8TLRgZ/Rq6UvLVWS6CmStHDA7fyPXRvyzTPey8LAWVwwhWxPxtt4bCw4z6D4qOi7OMWPD+dVuItkj/oSEd9LK3dsjHENhPEj3JTwcaT00LpJ6MQdnfd1EK54rFF0Fu6fCy0qY59ugsrP+leSoTNFGcDBhLHdgKFwR9bw6cT8fkrA7BkBss9UFNiz7LJ0ufGJt367H4hvuHHiWN9W2B8HkHEu7ES8v1xJCyxTcKO1ZdPF6GSiQzs6xjTajtBO+rf9uDl2Ey3YPpZqNKWeP9qu0Z45wksAIrBhTaPiS1JuDgfeHKoujo5D+eO/7ESdyMPuDTQlOqgPIPHhV4RJDW5OG9e/ZpF+ouSkYyWjxlWWbTWIyi1YLowoPdjNKRr7g0axwKSRyCscGXyC5Ww5DnUYRbtr7/PP8n2hWb8hIZ7vrjebYwyGzqKBE0PZdbbqgP0OuPCfXksh/NEs3Mrjfchs3xxLvzk7zrKUNP8epY88DduT5NxcOgo3u3k+wVdiXu5XCkVJw9JGySjr0vqt+3nes9Sc8zLrIPIhiCt4giX4Xa4NZ9CdHwT3n9BcX91lcZlRjrNAZeN8f7ewnkzNPESahXTJ5gaRHFF/Er85DmFJQYFGcR0gcAeE/oJlAb4wSEmoAMw2RBKIfFIG9lSzoeeGgJTIHTtADUY5JeYjWMBpqj/4qwqpspaO8p/WIz56xpd3YVBkbxqskfiaKi7zSCUGV1Cnbdwr9ZYs2xvBk51P/ReSUNFwS6PCzeBwal6o/DcttBG15kXP3AcZESyfENWqRI9OKoqsQ+hFP2FCaPO6iKHjiihvIt4S1DMnlEuhWQuZLqRcVafWBmHCA7Uvfpze3OsvZ8M7LypUwNEbfBjOYeK+yAmjJ8frEQMMjOME1wb3UwFfarKqSewHkLTlx7yBHcwbFoAtSRCz6ddA2SmI/Lcoa7t36tNGM8UJATpk4iApRwkeKvvPHWC0WDSkPeuvI6MBK6ByMjWNaR3Ys2njVRTRLz1YwM3YrTbz6uuyd6t0VVq95RAg5F2b73MIAbl4lzIK+clXvNXiU2wlzx7eo+Fm5bjiVnUydkdG6UBunU8k/CgSc80t3dFi6RhoqPTLxReREoCIU+y2DSFititK9Gagz6QMPJrFM2KbxkC74ALFR2X5WLkZub5ilaQGduTnn4RiGLUurZ6EGIb5g1GN9QzkKOLuJBucRWsdOLSq313gioIz6CMLpgA0mQFdKQ6WfoqDvW2V9zZnN+RTzH8rY5coBI2sEYO7EdFlMj+RFkXGWGkcydmFjDhQKka3iIgAHbFvfo53ks+cMIsg+d8Kz20o3pxiRjXm4peaS+zAhPRLNe/Jd0XrZokUk015KOBknT8y7V/BINxo7Og6p2mjXTThyghr2BUmV5aH9yosIMo4uUv+0+lPvHWFiQcYJlF1IfGtZZiquBxINUw--GDb+xqHfrmVhCNL3--jR4vs8c4CF5EgXdXMy873A== \ No newline at end of file diff --git a/config/credentials/staging.yml.enc b/config/credentials/staging.yml.enc new file mode 100644 index 0000000..b413c26 --- /dev/null +++ b/config/credentials/staging.yml.enc @@ -0,0 +1 @@ +5qMfsU4fxbkcUzYC52hpVq1uzVvsrW6GVf9gu7coXCcL2PInEtwBdqfEdyN1t9UdTkZApvOJP5NnFKHQurSCJsXFWjybmC3u6Lxvt0sltxYlPfUhFqyUACvv7B22T0NlesNKWK815GwzigIoxIlls7Vcy3nwCwc+2V9ymhvLSACy9070eIaQNisqtmST32K4Q3n5Wl1C2vQh/uRWwR5Uw21y+UtryZVNdlN//aWZHBcqkCtNvP2R20dv7Oy0RQ2eQ6l5y/OMUKcaEpbynwxNTMNJbRV4yEAMFf7NmXXAlsA/DwzaD0at/ywXBGrrvU65NC7cZ7XzSN+RmoV5Eche+P6+4MrqDnxJx9dUqH76ZUdx0ORPtRitc/KwRSZ2FfsGf5t1CTqIZrvm+v8g7+65EcCA6uqsqGCs2zFf2VOmNhP6ycf/bkCfciJgmFtRMBd5UEuZNovN0uNEYqFZyTDfNhYcGab4FX9DaTrNaYSltPmcjyAkWTUpM6Ve7008dFzG3p9mSpHv84kx+ffxD/+O7qVd3WZQt7EMs1JW+B6vflGyTMuBzdsuI/LcbDuqMfqaVfcZA9S6neth5e8Vx531pJYeUdV33mEYtdzr5i2r3giLH4+hjRbS+d2COdhvQXbYrQ/VYoV7qc5PzM0h2Iy0EY8wq7DoxyzCzE7iCHuv3HS/er+HaDr1ApvQifHG9YUCgX8KbrFxl47GjDW50TimWdZNmUyiRDiuZbJPBQMUBbbX99rEo5DO8CNN3lBL0JGP2+1eRt775W9tUGEeDN3vFx1HYaNYzVepZ1QaeawCA3BxlaYmN+rzh2fShBiCn48Majwl+eL0bmgDBlIQGZwTuGEDUmjY+TdT8ClAlpPsKu6S5f2j9+OGS/oXOAlCiELAxwqj5IZWYVXH2EPl6lOHxLRHpJke8/yVJwWqZInXwBxJ--EscHX+y20+os5i98--17TYWnHEc5miH/qc5tjlBg== \ No newline at end of file diff --git a/config/database.yml b/config/database.yml new file mode 100644 index 0000000..a720bb8 --- /dev/null +++ b/config/database.yml @@ -0,0 +1,86 @@ +# PostgreSQL. Versions 9.3 and up are supported. +# +# Install the pg driver: +# gem install pg +# On macOS with Homebrew: +# gem install pg -- --with-pg-config=/usr/local/bin/pg_config +# On Windows: +# gem install pg +# Choose the win32 build. +# Install PostgreSQL and put its /bin directory on your path. +# +# Configure Using Gemfile +# gem "pg" +# +default: &default + adapter: postgresql + encoding: unicode + # For details on connection pooling, see Rails configuration guide + # https://guides.rubyonrails.org/configuring.html#database-pooling + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 20 } %> + url: <%= ENV['DATABASE_URL'] %> + + +development: + <<: *default + database: identity_vault_development + + # The specified database role being used to connect to PostgreSQL. + # To create additional roles in PostgreSQL see `$ createuser --help`. + # When left blank, PostgreSQL will use the default role. This is + # the same name as the operating system users running Rails. + #username: identity_vault + + # The password associated with the PostgreSQL role (username). + #password: + + # Connect on a TCP socket. Omitted by default since the client uses a + # domain socket that doesn't need configuration. Windows does not have + # domain sockets, so uncomment these lines. + #host: localhost + + # The TCP port the server listens on. Defaults to 5432. + # If your server runs on a different port number, change accordingly. + #port: 5432 + + # Schema search path. The server defaults to $users,public + #schema_search_path: myapp,sharedapp,public + + # Minimum log levels, in increasing order: + # debug5, debug4, debug3, debug2, debug1, + # log, notice, warning, error, fatal, and panic + # Defaults to warning. + #min_messages: notice + +# 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: identity_vault_test + +# As with config/credentials.yml, you never want to store sensitive information, +# like your database password, in your source code. If your source code is +# ever seen by anyone, they now have access to your database. +# +# Instead, provide the password or a full connection URL as an environment +# variable when you boot the app. For example: +# +# DATABASE_URL="postgres://myuser:mypass@localhost/somedatabase" +# +# If the connection URL is provided in the special DATABASE_URL environment +# variable, Rails will automatically merge its configuration values on top of +# the values provided in this file. Alternatively, you can specify a connection +# URL environment variable explicitly: +# +# production: +# url: <%= ENV["MY_APP_DATABASE_URL"] %> +# +# Read https://guides.rubyonrails.org/configuring.html#configuring-a-database +# for a full overview on how database connection configuration can be specified. +# +production: + <<: *default + database: identity_vault_production + username: identity_vault + password: <%= ENV["IDENTITY_VAULT_DATABASE_PASSWORD"] %> diff --git a/config/environment.rb b/config/environment.rb new file mode 100644 index 0000000..cac5315 --- /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 0000000..e6602e7 --- /dev/null +++ b/config/environments/development.rb @@ -0,0 +1,73 @@ +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 = true + + # 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 = :encrypted_local_disk + + # 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 = Rails.application.routes.default_url_options = { host: "localhost", port: 3000 } + + config.action_mailer.delivery_method = :letter_opener_web + + # 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 + + config.active_job.queue_adapter = :good_job + + # 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 + + # 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 0000000..75c91f0 --- /dev/null +++ b/config/environments/production.rb @@ -0,0 +1,95 @@ +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}" } + + # 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 = :prod_id_documents + + # 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 = :mem_cache_store + + # Replace the default in-process and non-durable queuing backend for Active Job. + config.active_job.queue_adapter = :good_job + + # Ignore bad email addresses and do not raise email delivery errors. + # Set this to true and configure the email server for immediate delivery to raise delivery errors. + # config.action_mailer.raise_delivery_errors = false + + # Set host to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "identity.hackclub.com" } + + Rails.application.routes.default_url_options[:host] = "identity.hackclub.com" + + # Specify outgoing SMTP server. Remember to add smtp/* credentials via rails credentials:edit. + config.action_mailer.delivery_method = :smtp + config.action_mailer.smtp_settings = { + address: "smtp.loops.so", + port: 587, + user_name: "loops", + password: Rails.application.credentials.dig(:loops, :api_key), + authentication: "plain", + enable_starttls: true + } + # 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 ] + + config.console1984.protected_urls = [ + "https://hel1.your-objectstorage.com" + ] + # Enable DNS rebinding protection and other `Host` header attacks. + # config.hosts = [ + # "example.com", # Allow requests from example.com + # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` + # ] + # + # Skip DNS rebinding protection for the default health check endpoint. + # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } +end diff --git a/config/environments/staging.rb b/config/environments/staging.rb new file mode 100644 index 0000000..d1f10be --- /dev/null +++ b/config/environments/staging.rb @@ -0,0 +1,91 @@ +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}" } + + # 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 = :encrypted_local_disk + + # 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 = :mem_cache_store + + # Replace the default in-process and non-durable queuing backend for Active Job. + config.active_job.queue_adapter = :good_job + + # Ignore bad email addresses and do not raise email delivery errors. + # Set this to true and configure the email server for immediate delivery to raise delivery errors. + # config.action_mailer.raise_delivery_errors = false + + # Set host to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "idv-staging.a.hackclub.dev" } + + # Specify outgoing SMTP server. Remember to add smtp/* credentials via rails credentials:edit. + config.action_mailer.delivery_method = :smtp + config.action_mailer.smtp_settings = { + address: "smtp.loops.so", + port: 587, + user_name: "loops", + password: Rails.application.credentials.dig(:loops, :api_key), + authentication: "plain", + enable_starttls: true + } + + # 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 = [ + # "example.com", # Allow requests from example.com + # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` + # ] + # + # Skip DNS rebinding protection for the default health check endpoint. + # 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 0000000..c2095b1 --- /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 = :null_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/honeybadger.yml b/config/honeybadger.yml new file mode 100644 index 0000000..cb5a8cc --- /dev/null +++ b/config/honeybadger.yml @@ -0,0 +1,39 @@ +--- +# For more options, see https://docs.honeybadger.io/lib/ruby/gem-reference/configuration + +api_key: '<%= ENV["HONEYBADGER_API_KEY"] %>' + +# The environment your app is running in. +env: "<%= Rails.env %>" + +# The absolute path to your project folder. +root: "<%= Rails.root.to_s %>" + +# Honeybadger won't report errors in these environments. +development_environments: +- test +- development +- cucumber + +# By default, Honeybadger won't report errors in the development_environments. +# You can override this by explicitly setting report_data to true or false. +# report_data: true + +# The current Git revision of your project. Defaults to the last commit hash. +# revision: null + +# Enable verbose debug logging (useful for troubleshooting). +debug: false + +# Enable Honeybadger Insights +insights: + enabled: true +rails: + insights: + metrics: true +net_http: + insights: + metrics: true +puma: + insights: + metrics: true diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb new file mode 100644 index 0000000..0b31954 --- /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/doorkeeper.rb b/config/initializers/doorkeeper.rb new file mode 100644 index 0000000..b08f77a --- /dev/null +++ b/config/initializers/doorkeeper.rb @@ -0,0 +1,549 @@ +# frozen_string_literal: true + +Doorkeeper.configure do + base_controller "::ApplicationController" + # Change the ORM that doorkeeper will use (requires ORM extensions installed). + # Check the list of supported ORMs here: https://github.com/doorkeeper-gem/doorkeeper#orms + orm :active_record + + application_class "::Program" + access_token_class "::OAuthToken" + + access_token_expires_in 6.months + # Explicitly set table names to use our custom tables + # This ensures that even if the default Doorkeeper classes are referenced, + # they point to the correct tables + # + # Set table names for Doorkeeper's default classes + Doorkeeper::Application.table_name = "programs" if defined?(Doorkeeper::Application) + + # This block will be called to check whether the resource owner is authenticated or not. + resource_owner_authenticator do + hide_some_data_away + if current_identity + current_identity + else + # Parse the URL and remove stash_data parameter + uri = URI.parse(request.original_url) + params = URI.decode_www_form(uri.query || "") + params.reject! { |key, _| key == "stash_data" } + uri.query = URI.encode_www_form(params) unless params.empty? + session[:oauth_return_to] = uri.to_s + redirect_to "/onboarding/welcome" + end + end + + # If you didn't skip applications controller from Doorkeeper routes in your application routes.rb + # file then you need to declare this block in order to restrict access to the web interface for + # adding oauth authorized applications. In other case it will return 403 Forbidden response + # every time somebody will try to access the admin web interface. + # + admin_authenticator do + # Put your admin authentication logic here. + # Example implementation: + cu = Backend::User.find_by(id: session[:user_id]) + if cu + head :forbidden unless cu.super_admin? + else + redirect_to backend_login_path, alert: "you need to be logged in!" + end + end + + # You can use your own model classes if you need to extend (or even override) default + # Doorkeeper models such as `Application`, `AccessToken` and `AccessGrant. + # + # By default Doorkeeper ActiveRecord ORM uses its own classes: + # + # access_token_class "Doorkeeper::AccessToken" + # access_grant_class "Doorkeeper::AccessGrant" + # application_class "Doorkeeper::Application" + # + # Don't forget to include Doorkeeper ORM mixins into your custom models: + # + # * ::Doorkeeper::Orm::ActiveRecord::Mixins::AccessToken - for access token + # * ::Doorkeeper::Orm::ActiveRecord::Mixins::AccessGrant - for access grant + # * ::Doorkeeper::Orm::ActiveRecord::Mixins::Application - for application (OAuth2 clients) + # + # For example: + # + # access_token_class "MyAccessToken" + # + # class MyAccessToken < ApplicationRecord + # include ::Doorkeeper::Orm::ActiveRecord::Mixins::AccessToken + # + # self.table_name = "hey_i_wanna_my_name" + # + # def destroy_me! + # destroy + # end + # end + + # Enables polymorphic Resource Owner association for Access Tokens and Access Grants. + # By default this option is disabled. + # + # Make sure you properly setup you database and have all the required columns (run + # `bundle exec rails generate doorkeeper:enable_polymorphic_resource_owner` and execute Rails + # migrations). + # + # If this option enabled, Doorkeeper will store not only Resource Owner primary key + # value, but also it's type (class name). See "Polymorphic Associations" section of + # Rails guides: https://guides.rubyonrails.org/association_basics.html#polymorphic-associations + # + # [NOTE] If you apply this option on already existing project don't forget to manually + # update `resource_owner_type` column in the database and fix migration template as it will + # set NOT NULL constraint for Access Grants table. + # + use_polymorphic_resource_owner + + # If you are planning to use Doorkeeper in Rails 5 API-only application, then you might + # want to use API mode that will skip all the views management and change the way how + # Doorkeeper responds to a requests. + # + # api_only + + # Enforce token request content type to application/x-www-form-urlencoded. + # It is not enabled by default to not break prior versions of the gem. + # + # enforce_content_type + + # Authorization Code expiration time (default: 10 minutes). + # + # authorization_code_expires_in 10.minutes + + # Access token expiration time (default: 2 hours). + # If you set this to `nil` Doorkeeper will not expire the token and omit expires_in in response. + # It is RECOMMENDED to set expiration time explicitly. + # Prefer access_token_expires_in 100.years or similar, + # which would be functionally equivalent and avoid the risk of unexpected behavior by callers. + # + # access_token_expires_in 2.hours + + # Assign custom TTL for access tokens. Will be used instead of access_token_expires_in + # option if defined. In case the block returns `nil` value Doorkeeper fallbacks to + # +access_token_expires_in+ configuration option value. If you really need to issue a + # non-expiring access token (which is not recommended) then you need to return + # Float::INFINITY from this block. + # + # `context` has the following properties available: + # + # * `client` - the OAuth client application (see Doorkeeper::OAuth::Client) + # * `grant_type` - the grant type of the request (see Doorkeeper::OAuth) + # * `scopes` - the requested scopes (see Doorkeeper::OAuth::Scopes) + # * `resource_owner` - authorized resource owner instance (if present) + # + # custom_access_token_expires_in do |context| + # context.client.additional_settings.implicit_oauth_expiration + # end + + # Use a custom class for generating the access token. + # See https://doorkeeper.gitbook.io/guides/configuration/other-configurations#custom-access-token-generator + # + # access_token_generator '::Doorkeeper::JWT' + + # The controller +Doorkeeper::ApplicationController+ inherits from. + # Defaults to +ActionController::Base+ unless +api_only+ is set, which changes the default to + # +ActionController::API+. The return value of this option must be a stringified class name. + # See https://doorkeeper.gitbook.io/guides/configuration/other-configurations#custom-controllers + # + # base_controller 'ApplicationController' + + # Reuse access token for the same resource owner within an application (disabled by default). + # + # This option protects your application from creating new tokens before old **valid** one becomes + # expired so your database doesn't bloat. Keep in mind that when this option is enabled Doorkeeper + # doesn't update existing token expiration time, it will create a new token instead if no active matching + # token found for the application, resources owner and/or set of scopes. + # Rationale: https://github.com/doorkeeper-gem/doorkeeper/issues/383 + # + # You can not enable this option together with +hash_token_secrets+. + # + # reuse_access_token + + # In case you enabled `reuse_access_token` option Doorkeeper will try to find matching + # token using `matching_token_for` Access Token API that searches for valid records + # in batches in order not to pollute the memory with all the database records. By default + # Doorkeeper uses batch size of 10 000 records. You can increase or decrease this value + # depending on your needs and server capabilities. + # + # token_lookup_batch_size 10_000 + + # Set a limit for token_reuse if using reuse_access_token option + # + # This option limits token_reusability to some extent. + # If not set then access_token will be reused unless it expires. + # Rationale: https://github.com/doorkeeper-gem/doorkeeper/issues/1189 + # + # This option should be a percentage(i.e. (0,100]) + # + # token_reuse_limit 100 + + # Only allow one valid access token obtained via client credentials + # per client. If a new access token is obtained before the old one + # expired, the old one gets revoked (disabled by default) + # + # When enabling this option, make sure that you do not expect multiple processes + # using the same credentials at the same time (e.g. web servers spanning + # multiple machines and/or processes). + # + # revoke_previous_client_credentials_token + + # Only allow one valid access token obtained via authorization code + # per client. If a new access token is obtained before the old one + # expired, the old one gets revoked (disabled by default) + # + # revoke_previous_authorization_code_token + + # Require non-confidential clients to use PKCE when using an authorization code + # to obtain an access_token (disabled by default) + # + # force_pkce + + # Hash access and refresh tokens before persisting them. + # This will disable the possibility to use +reuse_access_token+ + # since plain values can no longer be retrieved. + # + # Note: If you are already a user of doorkeeper and have existing tokens + # in your installation, they will be invalid without adding 'fallback: :plain'. + # + # hash_token_secrets + # By default, token secrets will be hashed using the + # +Doorkeeper::Hashing::SHA256+ strategy. + # + # If you wish to use another hashing implementation, you can override + # this strategy as follows: + # + # hash_token_secrets using: '::Doorkeeper::Hashing::MyCustomHashImpl' + # + # Keep in mind that changing the hashing function will invalidate all existing + # secrets, if there are any. + + # Hash application secrets before persisting them. + # + # hash_application_secrets + # + # By default, applications will be hashed + # with the +Doorkeeper::SecretStoring::SHA256+ strategy. + # + # If you wish to use bcrypt for application secret hashing, uncomment + # this line instead: + # + # hash_application_secrets using: '::Doorkeeper::SecretStoring::BCrypt' + + # When the above option is enabled, and a hashed token or secret is not found, + # you can allow to fall back to another strategy. For users upgrading + # doorkeeper and wishing to enable hashing, you will probably want to enable + # the fallback to plain tokens. + # + # This will ensure that old access tokens and secrets + # will remain valid even if the hashing above is enabled. + # + # This can be done by adding 'fallback: plain', e.g. : + # + # hash_application_secrets using: '::Doorkeeper::SecretStoring::BCrypt', fallback: :plain + + # Issue access tokens with refresh token (disabled by default), you may also + # pass a block which accepts `context` to customize when to give a refresh + # token or not. Similar to +custom_access_token_expires_in+, `context` has + # the following properties: + # + # `client` - the OAuth client application (see Doorkeeper::OAuth::Client) + # `grant_type` - the grant type of the request (see Doorkeeper::OAuth) + # `scopes` - the requested scopes (see Doorkeeper::OAuth::Scopes) + # + # use_refresh_token + + # Provide support for an owner to be assigned to each registered application (disabled by default) + # Optional parameter confirmation: true (default: false) if you want to enforce ownership of + # a registered application + # NOTE: you must also run the rails g doorkeeper:application_owner generator + # to provide the necessary support + # + # enable_application_owner confirmation: false + + # Define access token scopes for your provider + # For more information go to + # https://doorkeeper.gitbook.io/guides/ruby-on-rails/scopes + # + # default_scopes :public + # optional_scopes :write, :update + + # Allows to restrict only certain scopes for grant_type. + # By default, all the scopes will be available for all the grant types. + # + # Keys to this hash should be the name of grant_type and + # values should be the array of scopes for that grant type. + # Note: scopes should be from configured_scopes (i.e. default or optional) + # + # scopes_by_grant_type password: [:write], client_credentials: [:update] + + # Forbids creating/updating applications with arbitrary scopes that are + # not in configuration, i.e. +default_scopes+ or +optional_scopes+. + # (disabled by default) + # + # enforce_configured_scopes + + # Change the way client credentials are retrieved from the request object. + # By default it retrieves first from the `HTTP_AUTHORIZATION` header, then + # falls back to the `:client_id` and `:client_secret` params from the `params` object. + # Check out https://github.com/doorkeeper-gem/doorkeeper/wiki/Changing-how-clients-are-authenticated + # for more information on customization + # + # client_credentials :from_basic, :from_params + + # Change the way access token is authenticated from the request object. + # By default it retrieves first from the `HTTP_AUTHORIZATION` header, then + # falls back to the `:access_token` or `:bearer_token` params from the `params` object. + # Check out https://github.com/doorkeeper-gem/doorkeeper/wiki/Changing-how-clients-are-authenticated + # for more information on customization + # + # access_token_methods :from_bearer_authorization, :from_access_token_param, :from_bearer_param + + # Forces the usage of the HTTPS protocol in non-native redirect uris (enabled + # by default in non-development environments). OAuth2 delegates security in + # communication to the HTTPS protocol so it is wise to keep this enabled. + # + # Callable objects such as proc, lambda, block or any object that responds to + # #call can be used in order to allow conditional checks (to allow non-SSL + # redirects to localhost for example). + # + # force_ssl_in_redirect_uri !Rails.env.development? + # + # force_ssl_in_redirect_uri { |uri| uri.host != 'localhost' } + + # Specify what redirect URI's you want to block during Application creation. + # Any redirect URI is allowed by default. + # + # You can use this option in order to forbid URI's with 'javascript' scheme + # for example. + # + # forbid_redirect_uri { |uri| uri.scheme.to_s.downcase == 'javascript' } + + # Allows to set blank redirect URIs for Applications in case Doorkeeper configured + # to use URI-less OAuth grant flows like Client Credentials or Resource Owner + # Password Credentials. The option is on by default and checks configured grant + # types, but you **need** to manually drop `NOT NULL` constraint from `redirect_uri` + # column for `oauth_applications` database table. + # + # You can completely disable this feature with: + # + # allow_blank_redirect_uri false + # + # Or you can define your custom check: + # + # allow_blank_redirect_uri do |grant_flows, client| + # client.superapp? + # end + + # Specify how authorization errors should be handled. + # By default, doorkeeper renders json errors when access token + # is invalid, expired, revoked or has invalid scopes. + # + # If you want to render error response yourself (i.e. rescue exceptions), + # set +handle_auth_errors+ to `:raise` and rescue Doorkeeper::Errors::InvalidToken + # or following specific errors: + # + # Doorkeeper::Errors::TokenForbidden, Doorkeeper::Errors::TokenExpired, + # Doorkeeper::Errors::TokenRevoked, Doorkeeper::Errors::TokenUnknown + # + # handle_auth_errors :raise + # + # If you want to redirect back to the client application in accordance with + # https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1, you can set + # +handle_auth_errors+ to :redirect + # + # handle_auth_errors :redirect + + # Customize token introspection response. + # Allows to add your own fields to default one that are required by the OAuth spec + # for the introspection response. It could be `sub`, `aud` and so on. + # This configuration option can be a proc, lambda or any Ruby object responds + # to `.call` method and result of it's invocation must be a Hash. + # + # custom_introspection_response do |token, context| + # { + # "sub": "Z5O3upPC88QrAjx00dis", + # "aud": "https://protected.example.net/resource", + # "username": User.find(token.resource_owner_id).username + # } + # end + # + # or + # + # custom_introspection_response CustomIntrospectionResponder + + # Specify what grant flows are enabled in array of Strings. The valid + # strings and the flows they enable are: + # + # "authorization_code" => Authorization Code Grant Flow + # "implicit" => Implicit Grant Flow + # "password" => Resource Owner Password Credentials Grant Flow + # "client_credentials" => Client Credentials Grant Flow + # + # If not specified, Doorkeeper enables authorization_code and + # client_credentials. + # + # implicit and password grant flows have risks that you should understand + # before enabling: + # https://datatracker.ietf.org/doc/html/rfc6819#section-4.4.2 + # https://datatracker.ietf.org/doc/html/rfc6819#section-4.4.3 + # + # grant_flows %w[authorization_code client_credentials] + + # Allows to customize OAuth grant flows that +each+ application support. + # You can configure a custom block (or use a class respond to `#call`) that must + # return `true` in case Application instance supports requested OAuth grant flow + # during the authorization request to the server. This configuration +doesn't+ + # set flows per application, it only allows to check if application supports + # specific grant flow. + # + # For example you can add an additional database column to `oauth_applications` table, + # say `t.array :grant_flows, default: []`, and store allowed grant flows that can + # be used with this application there. Then when authorization requested Doorkeeper + # will call this block to check if specific Application (passed with client_id and/or + # client_secret) is allowed to perform the request for the specific grant type + # (authorization, password, client_credentials, etc). + # + # Example of the block: + # + # ->(flow, client) { client.grant_flows.include?(flow) } + # + # In case this option invocation result is `false`, Doorkeeper server returns + # :unauthorized_client error and stops the request. + # + # @param allow_grant_flow_for_client [Proc] Block or any object respond to #call + # @return [Boolean] `true` if allow or `false` if forbid the request + # + # allow_grant_flow_for_client do |grant_flow, client| + # # `grant_flows` is an Array column with grant + # # flows that application supports + # + # client.grant_flows.include?(grant_flow) + # end + + # If you need arbitrary Resource Owner-Client authorization you can enable this option + # and implement the check your need. Config option must respond to #call and return + # true in case resource owner authorized for the specific application or false in other + # cases. + # + # By default all Resource Owners are authorized to any Client (application). + # + # authorize_resource_owner_for_client do |client, resource_owner| + # resource_owner.admin? || client.owners_allowlist.include?(resource_owner) + # end + + # Allows additional data fields to be sent while granting access to an application, + # and for this additional data to be included in subsequently generated access tokens. + # The 'authorizations/new' page will need to be overridden to include this additional data + # in the request params when granting access. The access grant and access token models + # will both need to respond to these additional data fields, and have a database column + # to store them in. + # + # Example: + # You have a multi-tenanted platform and want to be able to grant access to a specific + # tenant, rather than all the tenants a user has access to. You can use this config + # option to specify that a ':tenant_id' will be passed when authorizing. This tenant_id + # will be included in the access tokens. When a request is made with one of these access + # tokens, you can check that the requested data belongs to the specified tenant. + # + # Default value is an empty Array: [] + # custom_access_token_attributes [:tenant_id] + + # Hook into the strategies' request & response life-cycle in case your + # application needs advanced customization or logging: + # + # before_successful_strategy_response do |request| + # puts "BEFORE HOOK FIRED! #{request}" + # end + # + # after_successful_strategy_response do |request, response| + # puts "AFTER HOOK FIRED! #{request}, #{response}" + # end + + # Hook into Authorization flow in order to implement Single Sign Out + # or add any other functionality. Inside the block you have an access + # to `controller` (authorizations controller instance) and `context` + # (Doorkeeper::OAuth::Hooks::Context instance) which provides pre auth + # or auth objects with issued token based on hook type (before or after). + # + # before_successful_authorization do |controller, context| + # Rails.logger.info(controller.request.params.inspect) + # + # Rails.logger.info(context.pre_auth.inspect) + # end + # + # after_successful_authorization do |controller, context| + # controller.session[:logout_urls] << + # Doorkeeper::Application + # .find_by(controller.request.params.slice(:redirect_uri)) + # .logout_uri + # + # Rails.logger.info(context.auth.inspect) + # Rails.logger.info(context.issued_token) + # end + + # Under some circumstances you might want to have applications auto-approved, + # so that the user skips the authorization step. + # For example if dealing with a trusted application. + # + # skip_authorization do |resource_owner, client| + # client.superapp? or resource_owner.admin? + # end + + # Configure custom constraints for the Token Introspection request. + # By default this configuration option allows to introspect a token by another + # token of the same application, OR to introspect the token that belongs to + # authorized client (from authenticated client) OR when token doesn't + # belong to any client (public token). Otherwise requester has no access to the + # introspection and it will return response as stated in the RFC. + # + # Block arguments: + # + # @param token [Doorkeeper::AccessToken] + # token to be introspected + # + # @param authorized_client [Doorkeeper::Application] + # authorized client (if request is authorized using Basic auth with + # Client Credentials for example) + # + # @param authorized_token [Doorkeeper::AccessToken] + # Bearer token used to authorize the request + # + # In case the block returns `nil` or `false` introspection responses with 401 status code + # when using authorized token to introspect, or you'll get 200 with { "active": false } body + # when using authorized client to introspect as stated in the + # RFC 7662 section 2.2. Introspection Response. + # + # Using with caution: + # Keep in mind that these three parameters pass to block can be nil as following case: + # `authorized_client` is nil if and only if `authorized_token` is present, and vice versa. + # `token` will be nil if and only if `authorized_token` is present. + # So remember to use `&` or check if it is present before calling method on + # them to make sure you doesn't get NoMethodError exception. + # + # You can define your custom check: + # + # allow_token_introspection do |token, authorized_client, authorized_token| + # if authorized_token + # # customize: require `introspection` scope + # authorized_token.application == token&.application || + # authorized_token.scopes.include?("introspection") + # elsif token.application + # # `protected_resource` is a new database boolean column, for example + # authorized_client == token.application || authorized_client.protected_resource? + # else + # # public token (when token.application is nil, token doesn't belong to any application) + # true + # end + # end + # + # Or you can completely disable any token introspection: + # + # allow_token_introspection false + # + # If you need to block the request at all, then configure your routes.rb or web-server + # like nginx to forbid the request. + + # WWW-Authenticate Realm (default: "Doorkeeper"). + # + # realm "Doorkeeper" +end diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb new file mode 100644 index 0000000..c0b717f --- /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/flipper.rb b/config/initializers/flipper.rb new file mode 100644 index 0000000..52c07d6 --- /dev/null +++ b/config/initializers/flipper.rb @@ -0,0 +1,45 @@ +Rails.application.configure do + ## Memoization ensures that only one adapter call is made per feature per request. + ## For more info, see https://www.flippercloud.io/docs/optimization#memoization + # config.flipper.memoize = true + + ## Flipper preloads all features before each request, which is recommended if: + ## * you have a limited number of features (< 100?) + ## * most of your requests depend on most of your features + ## * you have limited gate data combined across all features (< 1k enabled gates, like individual actors, across all features) + ## + ## For more info, see https://www.flippercloud.io/docs/optimization#preloading + # config.flipper.preload = true + + ## Warn or raise an error if an unknown feature is checked + ## Can be set to `:warn`, `:raise`, or `false` + # config.flipper.strict = Rails.env.development? && :warn + + ## Show Flipper checks in logs + # config.flipper.log = true + + ## Reconfigure Flipper to use the Memory adapter and disable Cloud in tests + # config.flipper.test_help = true + + ## The path that Flipper Cloud will use to sync features + # config.flipper.cloud_path = "_flipper" + + ## The instrumenter that Flipper will use. Defaults to ActiveSupport::Notifications. + # config.flipper.instrumenter = ActiveSupport::Notifications +end + +Flipper.configure do |config| + ## Configure other adapters that you want to use here: + ## See http://flippercloud.io/docs/adapters + # config.use Flipper::Adapters::ActiveSupportCacheStore, Rails.cache, expires_in: 5.minutes +end + +## Register a group that can be used for enabling features. +## +## Flipper.enable_group :my_feature, :admins +## +## See https://www.flippercloud.io/docs/features#enablement-group +# +# Flipper.register(:admins) do |actor| +# actor.respond_to?(:admin?) && actor.admin? +# end diff --git a/config/initializers/git_version.rb b/config/initializers/git_version.rb new file mode 100644 index 0000000..241603d --- /dev/null +++ b/config/initializers/git_version.rb @@ -0,0 +1,22 @@ +# Get the first 6 characters of the current git commit hash +git_hash = ENV["SOURCE_COMMIT"] || `git rev-parse HEAD` rescue "unknown" + +commit_link = git_hash != "unknown" ? "https://github.com/hackclub/identity-vault/commit/#{git_hash}" : nil + +short_hash = git_hash[0..7] + +commit_count = `git rev-list --count HEAD`.strip rescue 0 + +# Check if there are any uncommitted changes +is_dirty = `git status --porcelain`.strip.length > 0 rescue false + +# Append "-dirty" if there are uncommitted changes +version = is_dirty ? "#{short_hash}-dirty" : short_hash + +# Store server start time +Rails.application.config.server_start_time = Time.current + +# Store the version +Rails.application.config.git_version = version +Rails.application.config.git_commit_count = commit_count +Rails.application.config.commit_link = commit_link diff --git a/config/initializers/good_job.rb b/config/initializers/good_job.rb new file mode 100644 index 0000000..baee96e --- /dev/null +++ b/config/initializers/good_job.rb @@ -0,0 +1,8 @@ +Rails.application.configure do + config.good_job.cron = { + expire_draft_aadhaar_verifications: { + cron: "*/5 * * * *", # Run every 5 minutes + class: "Verification::ExpireDraftAadhaarVerificationsJob" + } + } +end diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb new file mode 100644 index 0000000..4122c62 --- /dev/null +++ b/config/initializers/inflections.rb @@ -0,0 +1,5 @@ +ActiveSupport::Inflector.inflections(:en) do |inflect| + inflect.acronym "API" + inflect.acronym "OAuth" + inflect.acronym "HCB" +end diff --git a/config/initializers/monkey_patches.rb b/config/initializers/monkey_patches.rb new file mode 100644 index 0000000..2091c0d --- /dev/null +++ b/config/initializers/monkey_patches.rb @@ -0,0 +1,20 @@ +Rails.application.config.to_prepare do + class ActiveStorage::Blob + before_validation :generate_encryption_key, on: :create + + private + + def generate_encryption_key + self.encryption_key ||= SecureRandom.bytes(48) + end + end + + class Doorkeeper::AuthorizationsController + before_action :hide_some_data_away, only: :new + end + + class Doorkeeper::RedirectUriValidator + def validate_each(record, attribute, value) + end + end +end diff --git a/config/initializers/paper_trail.rb b/config/initializers/paper_trail.rb new file mode 100644 index 0000000..c337164 --- /dev/null +++ b/config/initializers/paper_trail.rb @@ -0,0 +1,12 @@ +class PaperTrail::Version + def responsible_party + return nil unless whodunnit.present? + if whodunnit&.start_with? "Backend user: " + uid = whodunnit[14..] + return nil unless uid.present? + Backend::User.find_by(id: uid) + else + Identity.find_by(id: whodunnit) + end + end +end diff --git a/config/initializers/phlex.rb b/config/initializers/phlex.rb new file mode 100644 index 0000000..44b6e85 --- /dev/null +++ b/config/initializers/phlex.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Views +end + +module Components + extend Phlex::Kit +end + +Rails.autoloaders.main.push_dir( + Rails.root.join("app/views"), namespace: Views +) + +Rails.autoloaders.main.push_dir( + Rails.root.join("app/components"), namespace: Components +) diff --git a/config/initializers/public_activity.rb b/config/initializers/public_activity.rb new file mode 100644 index 0000000..6384e55 --- /dev/null +++ b/config/initializers/public_activity.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +PublicActivity.enabled = true diff --git a/config/initializers/slack.rb b/config/initializers/slack.rb new file mode 100644 index 0000000..36ebe04 --- /dev/null +++ b/config/initializers/slack.rb @@ -0,0 +1,3 @@ +Slack.configure do |config| + config.token = ENV["SLACK_BOT_TOKEN"] +end diff --git a/config/locales/doorkeeper.en.yml b/config/locales/doorkeeper.en.yml new file mode 100644 index 0000000..f593d0d --- /dev/null +++ b/config/locales/doorkeeper.en.yml @@ -0,0 +1,159 @@ +en: + activerecord: + attributes: + doorkeeper/application: + name: 'Name' + redirect_uri: 'Redirect URI' + errors: + models: + doorkeeper/application: + attributes: + redirect_uri: + fragment_present: 'cannot contain a fragment.' + invalid_uri: 'must be a valid URI.' + unspecified_scheme: 'must specify a scheme.' + relative_uri: 'must be an absolute URI.' + secured_uri: 'must be an HTTPS/SSL URI.' + forbidden_uri: 'is forbidden by the server.' + scopes: + not_match_configured: "doesn't match configured on the server." + + doorkeeper: + applications: + confirmations: + destroy: 'Are you sure?' + buttons: + edit: 'Edit' + destroy: 'Destroy' + submit: 'Submit' + cancel: 'Cancel' + authorize: 'Authorize' + form: + error: 'Whoops! Check your form for possible errors' + help: + confidential: 'Application will be used where the client secret can be kept confidential. Native mobile apps and Single Page Apps are considered non-confidential.' + redirect_uri: 'Use one line per URI' + blank_redirect_uri: "Leave it blank if you configured your provider to use Client Credentials, Resource Owner Password Credentials or any other grant type that doesn't require redirect URI." + scopes: 'Separate scopes with spaces. Leave blank to use the default scopes.' + edit: + title: 'Edit application' + index: + title: 'Your applications' + new: 'New Application' + name: 'Name' + callback_url: 'Callback URL' + confidential: 'Confidential?' + actions: 'Actions' + confidentiality: + 'yes': 'Yes' + 'no': 'No' + new: + title: 'New Application' + show: + title: 'Application: %{name}' + application_id: 'UID' + secret: 'Secret' + secret_hashed: 'Secret hashed' + scopes: 'Scopes' + confidential: 'Confidential' + callback_urls: 'Callback urls' + actions: 'Actions' + not_defined: 'Not defined' + + authorizations: + buttons: + authorize: 'Authorize' + deny: 'Deny' + error: + title: 'An error has occurred' + new: + title: 'Grant access to your information' + prompt: "You're authorizing %{client_name} to use your identity." + able_to: 'This application will be able to' + show: + title: 'Authorization code' + form_post: + title: 'Submit this form' + + authorized_applications: + confirmations: + revoke: 'Are you sure?' + buttons: + revoke: 'Revoke' + index: + title: 'Your authorized applications' + application: 'Application' + created_at: 'Created At' + date_format: '%Y-%m-%d %H:%M:%S' + + pre_authorization: + status: 'Pre-authorization' + + errors: + messages: + # Common error messages + invalid_request: + unknown: 'The request is missing a required parameter, includes an unsupported parameter value, or is otherwise malformed.' + missing_param: 'Missing required parameter: %{value}.' + request_not_authorized: 'Request need to be authorized. Required parameter for authorizing request is missing or invalid.' + invalid_code_challenge: 'Code challenge is required.' + invalid_redirect_uri: "The requested redirect uri is malformed or doesn't match client redirect URI." + unauthorized_client: 'The client is not authorized to perform this request using this method.' + access_denied: 'The resource owner or authorization server denied the request.' + invalid_scope: 'The requested scope is invalid, unknown, or malformed.' + invalid_code_challenge_method: + zero: 'The authorization server does not support PKCE as there are no accepted code_challenge_method values.' + one: 'The code_challenge_method must be %{challenge_methods}.' + other: 'The code_challenge_method must be one of %{challenge_methods}.' + server_error: 'The authorization server encountered an unexpected condition which prevented it from fulfilling the request.' + temporarily_unavailable: 'The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server.' + + # Configuration error messages + credential_flow_not_configured: 'Resource Owner Password Credentials flow failed due to Doorkeeper.configure.resource_owner_from_credentials being unconfigured.' + resource_owner_authenticator_not_configured: 'Resource Owner find failed due to Doorkeeper.configure.resource_owner_authenticator being unconfigured.' + admin_authenticator_not_configured: 'Access to admin panel is forbidden due to Doorkeeper.configure.admin_authenticator being unconfigured.' + + # Access grant errors + unsupported_response_type: 'The authorization server does not support this response type.' + unsupported_response_mode: 'The authorization server does not support this response mode.' + + # Access token errors + invalid_client: 'Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method.' + invalid_grant: 'The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.' + unsupported_grant_type: 'The authorization grant type is not supported by the authorization server.' + + invalid_token: + revoked: "The access token was revoked" + expired: "The access token expired" + unknown: "The access token is invalid" + revoke: + unauthorized: "You are not authorized to revoke this token" + + forbidden_token: + missing_scope: 'Access to this resource requires scope "%{oauth_scopes}".' + + flash: + applications: + create: + notice: 'Application created.' + destroy: + notice: 'Application deleted.' + update: + notice: 'Application updated.' + authorized_applications: + destroy: + notice: 'Application revoked.' + + layouts: + admin: + title: 'Doorkeeper' + nav: + oauth2_provider: 'OAuth2 Provider' + applications: 'Applications' + home: 'Home' + application: + title: 'OAuth authorization required' + scopes: + basic: "See basic information about you (email address, name, verification status)" + address: "See your mailing address(es)" + legal_name: "See your legal name" \ No newline at end of file diff --git a/config/locales/en.yml b/config/locales/en.yml new file mode 100644 index 0000000..1d7f872 --- /dev/null +++ b/config/locales/en.yml @@ -0,0 +1,34 @@ +# Files in the config/locales directory are used for internationalization and +# are automatically loaded by Rails. If you want to use locales other than +# English, add the necessary files in this directory. +# +# To use the locales, use `I18n.t`: +# +# I18n.t "hello" +# +# In views, this is aliased to just `t`: +# +# <%= t("hello") %> +# +# To use a different locale, set it with `I18n.locale`: +# +# I18n.locale = :es +# +# This would use the information in config/locales/es.yml. +# +# To learn more about the API, please read the Rails Internationalization guide +# at https://guides.rubyonrails.org/i18n.html. +# +# Be aware that YAML interprets the following case-insensitive strings as +# booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings +# must be quoted to be interpreted as strings. For example: +# +# en: +# "yes": yup +# enabled: "ON" + +en: + activerecord: + attributes: + backend_user: + slack_id: Slack ID diff --git a/config/puma.rb b/config/puma.rb new file mode 100644 index 0000000..787e4ce --- /dev/null +++ b/config/puma.rb @@ -0,0 +1,38 @@ +# 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 + +# 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/routes.rb b/config/routes.rb new file mode 100644 index 0000000..f43e0e0 --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,312 @@ +# == Route Map +# +# Prefix Verb URI Pattern Controller#Action +# native_oauth_authorization GET /oauth/authorize/native(.:format) doorkeeper/authorizations#show +# oauth_authorization GET /oauth/authorize(.:format) doorkeeper/authorizations#new +# DELETE /oauth/authorize(.:format) doorkeeper/authorizations#destroy +# POST /oauth/authorize(.:format) doorkeeper/authorizations#create +# oauth_token POST /oauth/token(.:format) doorkeeper/tokens#create +# oauth_revoke POST /oauth/revoke(.:format) doorkeeper/tokens#revoke +# oauth_introspect POST /oauth/introspect(.:format) doorkeeper/tokens#introspect +# oauth_applications GET /oauth/applications(.:format) doorkeeper/applications#index +# POST /oauth/applications(.:format) doorkeeper/applications#create +# new_oauth_application GET /oauth/applications/new(.:format) doorkeeper/applications#new +# edit_oauth_application GET /oauth/applications/:id/edit(.:format) doorkeeper/applications#edit +# oauth_application GET /oauth/applications/:id(.:format) doorkeeper/applications#show +# PATCH /oauth/applications/:id(.:format) doorkeeper/applications#update +# PUT /oauth/applications/:id(.:format) doorkeeper/applications#update +# DELETE /oauth/applications/:id(.:format) doorkeeper/applications#destroy +# oauth_authorized_applications GET /oauth/authorized_applications(.:format) doorkeeper/authorized_applications#index +# oauth_authorized_application DELETE /oauth/authorized_applications/:id(.:format) doorkeeper/authorized_applications#destroy +# oauth_token_info GET /oauth/token/info(.:format) doorkeeper/token_info#show +# letter_opener_web /letter_opener LetterOpenerWeb::Engine +# active_storage_encryption /encrypted_blobs ActiveStorageEncryption::Engine +# static_pages_index GET /static_pages/index(.:format) static_pages#index +# credentials_enroll_window GET /credentials/enroll_window(.:format) credentials#enroll_window +# credentials_enroll GET /credentials/enroll(.:format) credentials#enroll +# backend_identities_index GET /backend/identities/index(.:format) backend/identities#index +# backend_identities_show GET /backend/identities/show(.:format) backend/identities#show +# backend_good_job /backend/good_job GoodJob::Engine +# backend_audit_logs GET /backend/audit_logs(.:format) backend/audit_logs#index +# backend_root GET /backend(.:format) backend/static_pages#index +# backend_login GET /backend/login(.:format) backend/static_pages#login +# backend_slack_auth GET /backend/auth/slack(.:format) backend/sessions#new +# backend_auth_slack_callback GET /backend/auth/slack/callback(.:format) backend/sessions#create +# backend_fake_slack_callback_for_dev POST /backend/auth/slack/fake(.:format) backend/sessions#fake_slack_callback_for_dev +# deactivate_backend_user POST /backend/users/:id/deactivate(.:format) backend/users#deactivate +# activate_backend_user POST /backend/users/:id/activate(.:format) backend/users#activate +# backend_users GET /backend/users(.:format) backend/users#index +# POST /backend/users(.:format) backend/users#create +# new_backend_user GET /backend/users/new(.:format) backend/users#new +# edit_backend_user GET /backend/users/:id/edit(.:format) backend/users#edit +# backend_user GET /backend/users/:id(.:format) backend/users#show +# PATCH /backend/users/:id(.:format) backend/users#update +# PUT /backend/users/:id(.:format) backend/users#update +# DELETE /backend/users/:id(.:format) backend/users#destroy +# pending_backend_verifications GET /backend/verifications/pending(.:format) backend/verifications#pending +# approve_backend_verification PATCH /backend/verifications/:id/approve(.:format) backend/verifications#approve +# reject_backend_verification PATCH /backend/verifications/:id/reject(.:format) backend/verifications#reject +# backend_verifications GET /backend/verifications(.:format) backend/verifications#index +# backend_verification GET /backend/verifications/:id(.:format) backend/verifications#show +# backend_identities GET /backend/identities(.:format) backend/identities#index +# backend_identity GET /backend/identities/:id(.:format) backend/identities#show +# backend_programs GET /backend/programs(.:format) backend/programs#index +# POST /backend/programs(.:format) backend/programs#create +# new_backend_program GET /backend/programs/new(.:format) backend/programs#new +# edit_backend_program GET /backend/programs/:id/edit(.:format) backend/programs#edit +# backend_program GET /backend/programs/:id(.:format) backend/programs#show +# PATCH /backend/programs/:id(.:format) backend/programs#update +# PUT /backend/programs/:id(.:format) backend/programs#update +# DELETE /backend/programs/:id(.:format) backend/programs#destroy +# backend_break_glass POST /backend/break_glass(.:format) backend/break_glass#create +# root GET / static_pages#index +# check_your_email_sessions GET /sessions/check_your_email(.:format) sessions#check_your_email +# verify_sessions GET /sessions/verify(.:format) sessions#verify +# confirm_sessions POST /sessions/confirm(.:format) sessions#confirm +# new_sessions GET /sessions/new(.:format) sessions#new +# sessions DELETE /sessions(.:format) sessions#destroy +# POST /sessions(.:format) sessions#create +# welcome_onboarding GET /onboarding/welcome(.:format) onboardings#welcome +# signin_onboarding GET /onboarding/signin(.:format) onboardings#signin +# basic_info_onboarding GET /onboarding/basic_info(.:format) onboardings#basic_info +# POST /onboarding/basic_info(.:format) onboardings#create_basic_info +# document_onboarding GET /onboarding/document(.:format) onboardings#document +# POST /onboarding/document(.:format) onboardings#create_document +# submitted_onboarding GET /onboarding/submitted(.:format) onboardings#submitted +# continue_onboarding GET /onboarding/continue(.:format) onboardings#continue +# onboarding GET /onboarding(.:format) onboardings#show +# addresses GET /addresses(.:format) addresses#index +# POST /addresses(.:format) addresses#create +# new_address GET /addresses/new(.:format) addresses#new +# edit_address GET /addresses/:id/edit(.:format) addresses#edit +# address GET /addresses/:id(.:format) addresses#show +# PATCH /addresses/:id(.:format) addresses#update +# PUT /addresses/:id(.:format) addresses#update +# DELETE /addresses/:id(.:format) addresses#destroy +# api_v1_identities GET /api/v1/identities(.:format) api/v1/identities#index +# api_v1_identity GET /api/v1/identities/:id(.:format) api/v1/identities#show +# api_v1_me GET /api/v1/me(.:format) api/v1/identities#me +# api_v1_hcb GET /api/v1/hcb(.:format) api/v1/hcb#show +# link_slack_account GET /slack/link(.:format) slack_accounts#new +# slack_account_callback GET /slack/callback(.:format) slack_accounts#create +# rails_health_check GET /up(.:format) rails/health#show +# rails_postmark_inbound_emails POST /rails/action_mailbox/postmark/inbound_emails(.:format) action_mailbox/ingresses/postmark/inbound_emails#create +# rails_relay_inbound_emails POST /rails/action_mailbox/relay/inbound_emails(.:format) action_mailbox/ingresses/relay/inbound_emails#create +# rails_sendgrid_inbound_emails POST /rails/action_mailbox/sendgrid/inbound_emails(.:format) action_mailbox/ingresses/sendgrid/inbound_emails#create +# rails_mandrill_inbound_health_check GET /rails/action_mailbox/mandrill/inbound_emails(.:format) action_mailbox/ingresses/mandrill/inbound_emails#health_check +# rails_mandrill_inbound_emails POST /rails/action_mailbox/mandrill/inbound_emails(.:format) action_mailbox/ingresses/mandrill/inbound_emails#create +# rails_mailgun_inbound_emails POST /rails/action_mailbox/mailgun/inbound_emails/mime(.:format) action_mailbox/ingresses/mailgun/inbound_emails#create +# rails_conductor_inbound_emails GET /rails/conductor/action_mailbox/inbound_emails(.:format) rails/conductor/action_mailbox/inbound_emails#index +# POST /rails/conductor/action_mailbox/inbound_emails(.:format) rails/conductor/action_mailbox/inbound_emails#create +# new_rails_conductor_inbound_email GET /rails/conductor/action_mailbox/inbound_emails/new(.:format) rails/conductor/action_mailbox/inbound_emails#new +# rails_conductor_inbound_email GET /rails/conductor/action_mailbox/inbound_emails/:id(.:format) rails/conductor/action_mailbox/inbound_emails#show +# new_rails_conductor_inbound_email_source GET /rails/conductor/action_mailbox/inbound_emails/sources/new(.:format) rails/conductor/action_mailbox/inbound_emails/sources#new +# rails_conductor_inbound_email_sources POST /rails/conductor/action_mailbox/inbound_emails/sources(.:format) rails/conductor/action_mailbox/inbound_emails/sources#create +# rails_conductor_inbound_email_reroute POST /rails/conductor/action_mailbox/:inbound_email_id/reroute(.:format) rails/conductor/action_mailbox/reroutes#create +# rails_conductor_inbound_email_incinerate POST /rails/conductor/action_mailbox/:inbound_email_id/incinerate(.:format) rails/conductor/action_mailbox/incinerates#create +# rails_service_blob GET /rails/active_storage/blobs/redirect/:signed_id/*filename(.:format) active_storage/blobs/redirect#show +# rails_service_blob_proxy GET /rails/active_storage/blobs/proxy/:signed_id/*filename(.:format) active_storage/blobs/proxy#show +# GET /rails/active_storage/blobs/:signed_id/*filename(.:format) active_storage/blobs/redirect#show +# rails_blob_representation GET /rails/active_storage/representations/redirect/:signed_blob_id/:variation_key/*filename(.:format) active_storage/representations/redirect#show +# rails_blob_representation_proxy GET /rails/active_storage/representations/proxy/:signed_blob_id/:variation_key/*filename(.:format) active_storage/representations/proxy#show +# GET /rails/active_storage/representations/:signed_blob_id/:variation_key/*filename(.:format) active_storage/representations/redirect#show +# rails_disk_service GET /rails/active_storage/disk/:encoded_key/*filename(.:format) active_storage/disk#show +# update_rails_disk_service PUT /rails/active_storage/disk/:encoded_token(.:format) active_storage/disk#update +# rails_direct_uploads POST /rails/active_storage/direct_uploads(.:format) active_storage/direct_uploads#create +# +# Routes for LetterOpenerWeb::Engine: +# letters GET / letter_opener_web/letters#index +# clear_letters POST /clear(.:format) letter_opener_web/letters#clear +# letter GET /:id(/:style)(.:format) letter_opener_web/letters#show +# delete_letter POST /:id/delete(.:format) letter_opener_web/letters#destroy +# GET /:id/attachments/:file(.:format) letter_opener_web/letters#attachment {file: /[^\/]+/} +# +# Routes for ActiveStorageEncryption::Engine: +# encrypted_blob_put PUT /blob/:token(.:format) active_storage_encryption/encrypted_blobs#update +# create_encrypted_blob_direct_upload POST /blob/direct-uploads(.:format) active_storage_encryption/encrypted_blobs#create_direct_upload +# encrypted_blob_streaming_get GET /blob/:token/*filename(.:format) active_storage_encryption/encrypted_blob_proxy#show +# +# Routes for GoodJob::Engine: +# root GET / good_job/jobs#redirect_to_index +# mass_update_jobs GET /jobs/mass_update(.:format) redirect(301, path: jobs) +# PUT /jobs/mass_update(.:format) good_job/jobs#mass_update +# discard_job PUT /jobs/:id/discard(.:format) good_job/jobs#discard +# force_discard_job PUT /jobs/:id/force_discard(.:format) good_job/jobs#force_discard +# reschedule_job PUT /jobs/:id/reschedule(.:format) good_job/jobs#reschedule +# retry_job PUT /jobs/:id/retry(.:format) good_job/jobs#retry +# jobs GET /jobs(.:format) good_job/jobs#index +# job GET /jobs/:id(.:format) good_job/jobs#show +# DELETE /jobs/:id(.:format) good_job/jobs#destroy +# metrics_primary_nav GET /jobs/metrics/primary_nav(.:format) good_job/metrics#primary_nav +# metrics_job_status GET /jobs/metrics/job_status(.:format) good_job/metrics#job_status +# retry_batch PUT /batches/:id/retry(.:format) good_job/batches#retry +# batches GET /batches(.:format) good_job/batches#index +# batch GET /batches/:id(.:format) good_job/batches#show +# enqueue_cron_entry POST /cron_entries/:cron_key/enqueue(.:format) good_job/cron_entries#enqueue +# enable_cron_entry PUT /cron_entries/:cron_key/enable(.:format) good_job/cron_entries#enable +# disable_cron_entry PUT /cron_entries/:cron_key/disable(.:format) good_job/cron_entries#disable +# cron_entries GET /cron_entries(.:format) good_job/cron_entries#index +# cron_entry GET /cron_entries/:cron_key(.:format) good_job/cron_entries#show +# processes GET /processes(.:format) good_job/processes#index +# performance_index GET /performance(.:format) good_job/performance#index +# performance GET /performance/:id(.:format) good_job/performance#show +# pauses POST /pauses(.:format) good_job/pauses#create +# DELETE /pauses(.:format) good_job/pauses#destroy +# GET /pauses(.:format) good_job/pauses#index +# cleaner_index GET /cleaner(.:format) good_job/cleaner#index +# frontend_module GET /frontend/modules/:version/:id(.:format) good_job/frontends#module {version: "4-10-2", format: "js"} +# frontend_static GET /frontend/static/:version/:id(.:format) good_job/frontends#static {version: "4-10-2"} + +class SuperAdminConstraint + def self.matches?(request) + return false unless request.session[:user_id] + + user = Backend::User.find_by(id: request.session[:user_id]) + user&.super_admin? + end +end + +Rails.application.routes.draw do + use_doorkeeper + mount LetterOpenerWeb::Engine, at: "/letter_opener" if Rails.env.development? + mount ActiveStorageEncryption::Engine, at: "/encrypted_blobs" + + # Image conversion routes + + get "static_pages/index" + namespace :backend do + get "identities/index" + get "identities/show" + constraints SuperAdminConstraint do + mount GoodJob::Engine => "good_job" + mount Audits1984::Engine => "/console_audit" + mount Flipper::UI.app(Flipper) => "/flipper", as: :flipper + end + resources :audit_logs, only: [ :index ] + get "dashboard", to: "dashboard#show", as: :dashboard + root "static_pages#index", as: :root + get "login", to: "static_pages#login", as: :login + get "session_dump", to: "static_pages#session_dump", as: :session_dump unless Rails.env.production? + + get "/auth/slack", to: "sessions#new", as: :slack_auth + get "/auth/slack/callback", to: "sessions#create" + + if Rails.env.development? + post "/auth/slack/fake", to: "sessions#fake_slack_callback_for_dev", as: :fake_slack_callback_for_dev + end + + resources :users do + member do + post :deactivate + post :activate + end + end + + resources :verifications, only: [ :index, :show ] do + collection do + get :pending + end + member do + patch :approve + patch :reject + patch :ignore + end + end + + resources :identities do + member do + post :clear_slack_id + get :new_vouch + post :create_vouch + end + end + + resources :programs + + post "/break_glass", to: "break_glass#create" + + scope :json do + defaults format: :json do + end + end + end + + root "static_pages#index" + + get "/faq", to: "static_pages#faq", as: :faq + + # Login system routes + resource :sessions, only: [ :new, :create, :destroy ] do + collection do + get :check_your_email + get :verify + post :confirm + end + end + + resource :onboarding, only: [ :show ] do + get :welcome + get :signin + get :basic_info + post :basic_info, to: "onboardings#create_basic_info" + get :document + post :document, to: "onboardings#create_document" + get :aadhaar + post :aadhaar, to: "onboardings#submit_aadhaar" + get :aadhaar_step_2, to: "onboardings#aadhaar_step_2" + get :address + post :address, to: "onboardings#create_address" + get :submitted + get :continue + end + + resource :aadhaar, only: [], controller: "aadhaar" do + get :async_digilocker_link + get :digilocker_redirect + end + + resources :addresses do + collection do + get :program_create_address + end + end + + namespace :api do + namespace :v1 do + resources :identities, only: [ :show, :index ] do + member do + post :set_slack_id + end + end + get "/me", to: "identities#me" + get "/hcb", to: "hcb#show" + get "/health_check", to: "health_check#show" + end + namespace :external do + get "/check", to: "identities#check" + end + end + + get "/api/external", to: "static_pages#external_api_docs" + + namespace :webhooks do + post "/aadhaar/:secret_key", to: "aadhaar#create", as: :aadhaar_callback + end + + # Slack account linking routes + get "/slack/link", to: "slack_accounts#new", as: :link_slack_account + get "/slack/callback", to: "slack_accounts#create", as: :slack_account_callback + + # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html + + # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. + # Can be used by load balancers and uptime monitors to verify that the app is live. + get "up" => "rails/health#show", as: :rails_health_check + + # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) + # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest + # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker + + # Defines the root path route ("/") + # root "posts#index" +end diff --git a/config/sanctioned_countries.yml b/config/sanctioned_countries.yml new file mode 100644 index 0000000..54c8f7c --- /dev/null +++ b/config/sanctioned_countries.yml @@ -0,0 +1,5 @@ +shared: + - CU + - IR + - KP + - SY \ No newline at end of file diff --git a/config/storage.yml b/config/storage.yml new file mode 100644 index 0000000..cfa2236 --- /dev/null +++ b/config/storage.yml @@ -0,0 +1,32 @@ +test: + service: Disk + root: <%= Rails.root.join("tmp/storage") %> + +local: + service: Disk + root: <%= Rails.root.join("storage") %> + +encrypted_local_disk: + service: EncryptedDisk + private_url_policy: stream + root: <%= ENV["ENCRYPTED_LOCAL_DISK_ROOT"] || Rails.root.join("storage", "encrypted") %> + +#prod_id_documents: +# service: EncryptedS3 +# endpoint: "https://hel1.your-objectstorage.com" +# access_key_id: <%#= Rails.application.credentials.dig(:hetzner, :access_key_id) %> +# secret_access_key: <%#= Rails.application.credentials.dig(:hetzner, :secret_access_key) %> +# region: hel1 +# bucket: hackclub-identity-docs-prod +# private_url_policy: stream + +prod_id_documents: + service: EncryptedS3 + endpoint: <%= Rails.application.credentials.dig(:cloudflare, :endpoint) %> + access_key_id: <%= Rails.application.credentials.dig(:cloudflare, :access_key_id) %> + secret_access_key: <%= Rails.application.credentials.dig(:cloudflare, :secret_access_key) %> + bucket: hackclub-identity-docs-prod + region: auto + private_url_policy: stream + request_checksum_calculation: "when_required" + response_checksum_validation: "when_required" \ No newline at end of file diff --git a/config/vite.json b/config/vite.json new file mode 100644 index 0000000..500968a --- /dev/null +++ b/config/vite.json @@ -0,0 +1,25 @@ +{ + "all": { + "sourceCodeDir": "app/frontend", + "watchAdditionalPaths": ["app/views/**/*.html.erb", "app/frontend/**/*.scss"], + "packageManager": "yarn" + }, + "development": { + "autoBuild": true, + "publicOutputDir": "vite-dev", + "port": 3036 + }, + "test": { + "autoBuild": true, + "publicOutputDir": "vite-test", + "port": 3037 + }, + "production": { + "publicOutputDir": "", + "port": 3038 + }, + "staging": { + "publicOutputDir": "", + "port": 3039 + } +} diff --git a/db/migrate/20250822205652_init_schema.rb b/db/migrate/20250822205652_init_schema.rb new file mode 100644 index 0000000..7aba3f8 --- /dev/null +++ b/db/migrate/20250822205652_init_schema.rb @@ -0,0 +1,422 @@ +class InitSchema < ActiveRecord::Migration[8.0] + def up + # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" + enable_extension "pgcrypto" + create_table "active_storage_attachments" 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" 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.string "encryption_key" + t.index [ "key" ], name: "index_active_storage_blobs_on_key", unique: true + end + create_table "active_storage_variant_records" 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 "activities" do |t| + t.string "trackable_type" + t.bigint "trackable_id" + t.string "owner_type" + t.bigint "owner_id" + t.string "key" + t.text "parameters" + t.string "recipient_type" + t.bigint "recipient_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "owner_id", "owner_type" ], name: "index_activities_on_owner_id_and_owner_type" + t.index [ "owner_type", "owner_id" ], name: "index_activities_on_owner" + t.index [ "recipient_id", "recipient_type" ], name: "index_activities_on_recipient_id_and_recipient_type" + t.index [ "recipient_type", "recipient_id" ], name: "index_activities_on_recipient" + t.index [ "trackable_id", "trackable_type" ], name: "index_activities_on_trackable_id_and_trackable_type" + t.index [ "trackable_type", "trackable_id" ], name: "index_activities_on_trackable" + end + create_table "addresses" do |t| + t.string "first_name" + t.string "last_name" + t.string "line_1" + t.string "line_2" + t.string "city" + t.string "state" + t.string "postal_code" + t.integer "country" + t.bigint "identity_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "identity_id" ], name: "index_addresses_on_identity_id" + end + create_table "audits1984_audits" do |t| + t.integer "status", default: 0, null: false + t.text "notes" + t.bigint "session_id", null: false + t.bigint "auditor_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "auditor_id" ], name: "index_audits1984_audits_on_auditor_id" + t.index [ "session_id" ], name: "index_audits1984_audits_on_session_id" + end + create_table "backend_organizer_positions" do |t| + t.bigint "program_id", null: false + t.bigint "backend_user_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "backend_user_id" ], name: "index_backend_organizer_positions_on_backend_user_id" + t.index [ "program_id" ], name: "index_backend_organizer_positions_on_program_id" + end + create_table "backend_users" do |t| + t.string "slack_id" + t.string "username" + t.string "icon_url" + t.boolean "super_admin" + t.boolean "program_manager" + t.boolean "all_fields_access" + t.boolean "manual_document_verifier" + t.boolean "human_endorser" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.boolean "active" + t.string "credential_id" + t.boolean "can_break_glass" + t.index [ "slack_id" ], name: "index_backend_users_on_slack_id" + end + create_table "break_glass_records" do |t| + t.bigint "backend_user_id", null: false + t.bigint "break_glassable_id", null: false + t.text "reason", null: false + t.datetime "accessed_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.boolean "automatic", default: false + t.string "break_glassable_type", null: false + t.index [ "backend_user_id", "break_glassable_id", "accessed_at" ], name: "idx_on_backend_user_id_break_glassable_id_accessed__e06f302c56" + t.index [ "backend_user_id" ], name: "index_break_glass_records_on_backend_user_id" + t.index [ "break_glassable_id", "break_glassable_type" ], name: "idx_on_break_glassable_id_break_glassable_type_14e1e3ce71" + t.index [ "break_glassable_id" ], name: "index_break_glass_records_on_break_glassable_id" + end + create_table "console1984_commands" do |t| + t.text "statements" + t.bigint "sensitive_access_id" + t.bigint "session_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "sensitive_access_id" ], name: "index_console1984_commands_on_sensitive_access_id" + t.index [ "session_id", "created_at", "sensitive_access_id" ], name: "on_session_and_sensitive_chronologically" + end + create_table "console1984_sensitive_accesses" do |t| + t.text "justification" + t.bigint "session_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "session_id" ], name: "index_console1984_sensitive_accesses_on_session_id" + end + create_table "console1984_sessions" do |t| + t.text "reason" + t.bigint "user_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "created_at" ], name: "index_console1984_sessions_on_created_at" + t.index [ "user_id", "created_at" ], name: "index_console1984_sessions_on_user_id_and_created_at" + end + create_table "console1984_users" do |t| + t.string "username", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "username" ], name: "index_console1984_users_on_username" + end + create_table "flipper_features" do |t| + t.string "key", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "key" ], name: "index_flipper_features_on_key", unique: true + end + create_table "flipper_gates" do |t| + t.string "feature_key", null: false + t.string "key", null: false + t.text "value" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "feature_key", "key", "value" ], name: "index_flipper_gates_on_feature_key_and_key_and_value", unique: true + end + create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" } do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.text "description" + t.jsonb "serialized_properties" + t.text "on_finish" + t.text "on_success" + t.text "on_discard" + t.text "callback_queue_name" + t.integer "callback_priority" + t.datetime "enqueued_at" + t.datetime "discarded_at" + t.datetime "finished_at" + t.datetime "jobs_finished_at" + end + create_table "good_job_executions", id: :uuid, default: -> { "gen_random_uuid()" } do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "active_job_id", null: false + t.text "job_class" + t.text "queue_name" + t.jsonb "serialized_params" + t.datetime "scheduled_at" + t.datetime "finished_at" + t.text "error" + t.integer "error_event", limit: 2 + t.text "error_backtrace", array: true + t.uuid "process_id" + t.interval "duration" + t.index [ "active_job_id", "created_at" ], name: "index_good_job_executions_on_active_job_id_and_created_at" + t.index [ "process_id", "created_at" ], name: "index_good_job_executions_on_process_id_and_created_at" + end + create_table "good_job_processes", id: :uuid, default: -> { "gen_random_uuid()" } do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.jsonb "state" + t.integer "lock_type", limit: 2 + end + create_table "good_job_settings", id: :uuid, default: -> { "gen_random_uuid()" } do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.text "key" + t.jsonb "value" + t.index [ "key" ], name: "index_good_job_settings_on_key", unique: true + end + create_table "good_jobs", id: :uuid, default: -> { "gen_random_uuid()" } do |t| + t.text "queue_name" + t.integer "priority" + t.jsonb "serialized_params" + t.datetime "scheduled_at" + t.datetime "performed_at" + t.datetime "finished_at" + t.text "error" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "active_job_id" + t.text "concurrency_key" + t.text "cron_key" + t.uuid "retried_good_job_id" + t.datetime "cron_at" + t.uuid "batch_id" + t.uuid "batch_callback_id" + t.boolean "is_discrete" + t.integer "executions_count" + t.text "job_class" + t.integer "error_event", limit: 2 + t.text "labels", array: true + t.uuid "locked_by_id" + t.datetime "locked_at" + t.index [ "active_job_id", "created_at" ], name: "index_good_jobs_on_active_job_id_and_created_at" + t.index [ "batch_callback_id" ], name: "index_good_jobs_on_batch_callback_id", where: "(batch_callback_id IS NOT NULL)" + t.index [ "batch_id" ], name: "index_good_jobs_on_batch_id", where: "(batch_id IS NOT NULL)" + t.index [ "concurrency_key", "created_at" ], name: "index_good_jobs_on_concurrency_key_and_created_at" + t.index [ "concurrency_key" ], name: "index_good_jobs_on_concurrency_key_when_unfinished", where: "(finished_at IS NULL)" + t.index [ "cron_key", "created_at" ], name: "index_good_jobs_on_cron_key_and_created_at_cond", where: "(cron_key IS NOT NULL)" + t.index [ "cron_key", "cron_at" ], name: "index_good_jobs_on_cron_key_and_cron_at_cond", unique: true, where: "(cron_key IS NOT NULL)" + t.index [ "finished_at" ], name: "index_good_jobs_jobs_on_finished_at", where: "((retried_good_job_id IS NULL) AND (finished_at IS NOT NULL))" + t.index [ "labels" ], name: "index_good_jobs_on_labels", where: "(labels IS NOT NULL)", using: :gin + t.index [ "locked_by_id" ], name: "index_good_jobs_on_locked_by_id", where: "(locked_by_id IS NOT NULL)" + t.index [ "priority", "created_at" ], name: "index_good_job_jobs_for_candidate_lookup", where: "(finished_at IS NULL)" + t.index [ "priority", "created_at" ], name: "index_good_jobs_jobs_on_priority_created_at_when_unfinished", order: { priority: "DESC NULLS LAST" }, where: "(finished_at IS NULL)" + t.index [ "priority", "scheduled_at" ], name: "index_good_jobs_on_priority_scheduled_at_unfinished_unlocked", where: "((finished_at IS NULL) AND (locked_by_id IS NULL))" + t.index [ "queue_name", "scheduled_at" ], name: "index_good_jobs_on_queue_name_and_scheduled_at", where: "(finished_at IS NULL)" + t.index [ "scheduled_at" ], name: "index_good_jobs_on_scheduled_at", where: "(finished_at IS NULL)" + end + create_table "identities" do |t| + t.string "first_name" + t.string "last_name" + t.date "birthday" + t.string "legal_first_name" + t.string "legal_last_name" + t.string "primary_email" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "country" + t.string "slack_id" + t.boolean "ysws_eligible" + t.bigint "primary_address_id" + t.datetime "deleted_at" + t.text "aadhaar_number_ciphertext" + t.string "aadhaar_number_bidx" + t.boolean "hq_override", default: false + t.boolean "came_in_through_adult_program", default: false + t.string "phone_number" + t.boolean "permabanned", default: false + t.index [ "aadhaar_number_bidx" ], name: "index_identities_on_aadhaar_number_bidx", unique: true + t.index [ "deleted_at" ], name: "index_identities_on_deleted_at" + t.index [ "primary_address_id" ], name: "index_identities_on_primary_address_id" + t.index [ "slack_id" ], name: "index_identities_on_slack_id" + end + create_table "identity_aadhaar_records" do |t| + t.bigint "identity_id", null: false + t.datetime "deleted_at" + t.text "raw_json_response" + t.date "date_of_birth" + t.string "name" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "identity_id" ], name: "index_identity_aadhaar_records_on_identity_id" + end + create_table "identity_documents" do |t| + t.integer "document_type" + t.bigint "identity_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.datetime "deleted_at" + t.index [ "deleted_at" ], name: "index_identity_documents_on_deleted_at" + t.index [ "identity_id" ], name: "index_identity_documents_on_identity_id" + end + create_table "identity_login_codes" do |t| + t.datetime "expires_at" + t.string "token_bidx" + t.text "token_ciphertext" + t.datetime "used_at" + t.bigint "identity_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "return_url" + t.index [ "identity_id" ], name: "index_identity_login_codes_on_identity_id" + end + create_table "identity_resemblances" do |t| + t.bigint "identity_id", null: false + t.bigint "past_identity_id", null: false + t.string "type" + t.bigint "document_id" + t.bigint "past_document_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "document_id" ], name: "index_identity_resemblances_on_document_id" + t.index [ "identity_id" ], name: "index_identity_resemblances_on_identity_id" + t.index [ "past_document_id" ], name: "index_identity_resemblances_on_past_document_id" + t.index [ "past_identity_id" ], name: "index_identity_resemblances_on_past_identity_id" + end + create_table "oauth_access_grants" do |t| + t.bigint "resource_owner_id", null: false + t.bigint "application_id", null: false + t.string "token", null: false + t.integer "expires_in", null: false + t.text "redirect_uri", null: false + t.string "scopes", default: "", null: false + t.datetime "created_at", null: false + t.datetime "revoked_at" + t.string "resource_owner_type", null: false + t.index [ "application_id" ], name: "index_oauth_access_grants_on_application_id" + t.index [ "resource_owner_id", "resource_owner_type" ], name: "polymorphic_owner_oauth_access_grants" + t.index [ "resource_owner_id" ], name: "index_oauth_access_grants_on_resource_owner_id" + t.index [ "token" ], name: "index_oauth_access_grants_on_token", unique: true + end + create_table "oauth_access_tokens" do |t| + t.bigint "resource_owner_id" + t.bigint "application_id", null: false + t.string "refresh_token" + t.integer "expires_in" + t.string "scopes" + t.datetime "created_at", null: false + t.datetime "revoked_at" + t.string "previous_refresh_token", default: "", null: false + t.string "resource_owner_type" + t.text "token_ciphertext" + t.string "token_bidx" + t.index [ "application_id" ], name: "index_oauth_access_tokens_on_application_id" + t.index [ "refresh_token" ], name: "index_oauth_access_tokens_on_refresh_token", unique: true + t.index [ "resource_owner_id", "resource_owner_type" ], name: "polymorphic_owner_oauth_access_tokens" + t.index [ "resource_owner_id" ], name: "index_oauth_access_tokens_on_resource_owner_id" + t.index [ "token_bidx" ], name: "index_oauth_access_tokens_on_token_bidx", unique: true + end + create_table "oauth_applications" do |t| + t.string "name", null: false + t.string "uid", null: false + t.string "secret", null: false + t.text "redirect_uri", null: false + t.string "scopes", default: "", null: false + t.boolean "confidential", default: true, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "program_key_bidx" + t.text "program_key_ciphertext" + t.boolean "active", default: true + t.index [ "program_key_bidx" ], name: "index_oauth_applications_on_program_key_bidx", unique: true + t.index [ "uid" ], name: "index_oauth_applications_on_uid", unique: true + end + create_table "verifications" do |t| + t.bigint "identity_id", null: false + t.bigint "identity_document_id" + t.string "status", null: false + t.string "rejection_reason" + t.string "rejection_reason_details" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.datetime "deleted_at" + t.string "type" + t.boolean "fatal", default: false, null: false + t.string "aadhaar_hc_transaction_id" + t.string "aadhaar_external_transaction_id" + t.string "aadhaar_link" + t.bigint "aadhaar_record_id" + t.string "issues", default: [], array: true + t.datetime "pending_at" + t.datetime "ignored_at" + t.string "ignored_reason" + t.datetime "approved_at" + t.datetime "rejected_at" + t.text "internal_rejection_comment" + t.index [ "aadhaar_record_id" ], name: "index_verifications_on_aadhaar_record_id" + t.index [ "deleted_at" ], name: "index_verifications_on_deleted_at" + t.index [ "fatal" ], name: "index_verifications_on_fatal" + t.index [ "identity_document_id" ], name: "index_verifications_on_identity_document_id" + t.index [ "identity_id" ], name: "index_verifications_on_identity_id" + t.index [ "type" ], name: "index_verifications_on_type" + end + create_table "versions" do |t| + t.string "whodunnit" + t.datetime "created_at" + t.bigint "item_id", null: false + t.string "item_type", null: false + t.string "event", null: false + t.text "object" + t.jsonb "object_changes" + t.index [ "item_type", "item_id" ], name: "index_versions_on_item_type_and_item_id" + 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 "addresses", "identities" + add_foreign_key "backend_organizer_positions", "backend_users" + add_foreign_key "backend_organizer_positions", "oauth_applications", column: "program_id" + add_foreign_key "break_glass_records", "backend_users" + add_foreign_key "identities", "addresses", column: "primary_address_id" + add_foreign_key "identity_aadhaar_records", "identities" + add_foreign_key "identity_documents", "identities" + add_foreign_key "identity_login_codes", "identities" + add_foreign_key "identity_resemblances", "identities" + add_foreign_key "identity_resemblances", "identities", column: "past_identity_id" + add_foreign_key "identity_resemblances", "identity_documents", column: "document_id" + add_foreign_key "identity_resemblances", "identity_documents", column: "past_document_id" + add_foreign_key "oauth_access_grants", "identities", column: "resource_owner_id" + add_foreign_key "oauth_access_grants", "oauth_applications", column: "application_id" + add_foreign_key "oauth_access_tokens", "identities", column: "resource_owner_id" + add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id" + add_foreign_key "verifications", "identities" + add_foreign_key "verifications", "identity_aadhaar_records", column: "aadhaar_record_id" + add_foreign_key "verifications", "identity_documents" + end + + def down + raise ActiveRecord::IrreversibleMigration, "The initial migration is not revertable" + end +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..d8b1662 --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,467 @@ +# 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: 2025_08_22_205652) do + # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" + enable_extension "pgcrypto" + + 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.string "encryption_key" + 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 "activities", force: :cascade do |t| + t.string "trackable_type" + t.bigint "trackable_id" + t.string "owner_type" + t.bigint "owner_id" + t.string "key" + t.text "parameters" + t.string "recipient_type" + t.bigint "recipient_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["owner_id", "owner_type"], name: "index_activities_on_owner_id_and_owner_type" + t.index ["owner_type", "owner_id"], name: "index_activities_on_owner" + t.index ["recipient_id", "recipient_type"], name: "index_activities_on_recipient_id_and_recipient_type" + t.index ["recipient_type", "recipient_id"], name: "index_activities_on_recipient" + t.index ["trackable_id", "trackable_type"], name: "index_activities_on_trackable_id_and_trackable_type" + t.index ["trackable_type", "trackable_id"], name: "index_activities_on_trackable" + end + + create_table "addresses", force: :cascade do |t| + t.string "first_name" + t.string "last_name" + t.string "line_1" + t.string "line_2" + t.string "city" + t.string "state" + t.string "postal_code" + t.integer "country" + t.bigint "identity_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["identity_id"], name: "index_addresses_on_identity_id" + end + + create_table "audits1984_audits", force: :cascade do |t| + t.integer "status", default: 0, null: false + t.text "notes" + t.bigint "session_id", null: false + t.bigint "auditor_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["auditor_id"], name: "index_audits1984_audits_on_auditor_id" + t.index ["session_id"], name: "index_audits1984_audits_on_session_id" + end + + create_table "backend_organizer_positions", force: :cascade do |t| + t.bigint "program_id", null: false + t.bigint "backend_user_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["backend_user_id"], name: "index_backend_organizer_positions_on_backend_user_id" + t.index ["program_id"], name: "index_backend_organizer_positions_on_program_id" + end + + create_table "backend_users", force: :cascade do |t| + t.string "slack_id" + t.string "username" + t.string "icon_url" + t.boolean "super_admin" + t.boolean "program_manager" + t.boolean "all_fields_access" + t.boolean "manual_document_verifier" + t.boolean "human_endorser" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.boolean "active" + t.string "credential_id" + t.boolean "can_break_glass" + t.index ["slack_id"], name: "index_backend_users_on_slack_id" + end + + create_table "break_glass_records", force: :cascade do |t| + t.bigint "backend_user_id", null: false + t.bigint "break_glassable_id", null: false + t.text "reason", null: false + t.datetime "accessed_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.boolean "automatic", default: false + t.string "break_glassable_type", null: false + t.index ["backend_user_id", "break_glassable_id", "accessed_at"], name: "idx_on_backend_user_id_break_glassable_id_accessed__e06f302c56" + t.index ["backend_user_id"], name: "index_break_glass_records_on_backend_user_id" + t.index ["break_glassable_id", "break_glassable_type"], name: "idx_on_break_glassable_id_break_glassable_type_14e1e3ce71" + t.index ["break_glassable_id"], name: "index_break_glass_records_on_break_glassable_id" + end + + create_table "console1984_commands", force: :cascade do |t| + t.text "statements" + t.bigint "sensitive_access_id" + t.bigint "session_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["sensitive_access_id"], name: "index_console1984_commands_on_sensitive_access_id" + t.index ["session_id", "created_at", "sensitive_access_id"], name: "on_session_and_sensitive_chronologically" + end + + create_table "console1984_sensitive_accesses", force: :cascade do |t| + t.text "justification" + t.bigint "session_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["session_id"], name: "index_console1984_sensitive_accesses_on_session_id" + end + + create_table "console1984_sessions", force: :cascade do |t| + t.text "reason" + t.bigint "user_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["created_at"], name: "index_console1984_sessions_on_created_at" + t.index ["user_id", "created_at"], name: "index_console1984_sessions_on_user_id_and_created_at" + end + + create_table "console1984_users", force: :cascade do |t| + t.string "username", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["username"], name: "index_console1984_users_on_username" + end + + create_table "flipper_features", force: :cascade do |t| + t.string "key", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["key"], name: "index_flipper_features_on_key", unique: true + end + + create_table "flipper_gates", force: :cascade do |t| + t.string "feature_key", null: false + t.string "key", null: false + t.text "value" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["feature_key", "key", "value"], name: "index_flipper_gates_on_feature_key_and_key_and_value", unique: true + end + + create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.text "description" + t.jsonb "serialized_properties" + t.text "on_finish" + t.text "on_success" + t.text "on_discard" + t.text "callback_queue_name" + t.integer "callback_priority" + t.datetime "enqueued_at" + t.datetime "discarded_at" + t.datetime "finished_at" + t.datetime "jobs_finished_at" + end + + create_table "good_job_executions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "active_job_id", null: false + t.text "job_class" + t.text "queue_name" + t.jsonb "serialized_params" + t.datetime "scheduled_at" + t.datetime "finished_at" + t.text "error" + t.integer "error_event", limit: 2 + t.text "error_backtrace", array: true + t.uuid "process_id" + t.interval "duration" + t.index ["active_job_id", "created_at"], name: "index_good_job_executions_on_active_job_id_and_created_at" + t.index ["process_id", "created_at"], name: "index_good_job_executions_on_process_id_and_created_at" + end + + create_table "good_job_processes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.jsonb "state" + t.integer "lock_type", limit: 2 + end + + create_table "good_job_settings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.text "key" + t.jsonb "value" + t.index ["key"], name: "index_good_job_settings_on_key", unique: true + end + + create_table "good_jobs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.text "queue_name" + t.integer "priority" + t.jsonb "serialized_params" + t.datetime "scheduled_at" + t.datetime "performed_at" + t.datetime "finished_at" + t.text "error" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "active_job_id" + t.text "concurrency_key" + t.text "cron_key" + t.uuid "retried_good_job_id" + t.datetime "cron_at" + t.uuid "batch_id" + t.uuid "batch_callback_id" + t.boolean "is_discrete" + t.integer "executions_count" + t.text "job_class" + t.integer "error_event", limit: 2 + t.text "labels", array: true + t.uuid "locked_by_id" + t.datetime "locked_at" + t.index ["active_job_id", "created_at"], name: "index_good_jobs_on_active_job_id_and_created_at" + t.index ["batch_callback_id"], name: "index_good_jobs_on_batch_callback_id", where: "(batch_callback_id IS NOT NULL)" + t.index ["batch_id"], name: "index_good_jobs_on_batch_id", where: "(batch_id IS NOT NULL)" + t.index ["concurrency_key", "created_at"], name: "index_good_jobs_on_concurrency_key_and_created_at" + t.index ["concurrency_key"], name: "index_good_jobs_on_concurrency_key_when_unfinished", where: "(finished_at IS NULL)" + t.index ["cron_key", "created_at"], name: "index_good_jobs_on_cron_key_and_created_at_cond", where: "(cron_key IS NOT NULL)" + t.index ["cron_key", "cron_at"], name: "index_good_jobs_on_cron_key_and_cron_at_cond", unique: true, where: "(cron_key IS NOT NULL)" + t.index ["finished_at"], name: "index_good_jobs_jobs_on_finished_at", where: "((retried_good_job_id IS NULL) AND (finished_at IS NOT NULL))" + t.index ["labels"], name: "index_good_jobs_on_labels", where: "(labels IS NOT NULL)", using: :gin + t.index ["locked_by_id"], name: "index_good_jobs_on_locked_by_id", where: "(locked_by_id IS NOT NULL)" + t.index ["priority", "created_at"], name: "index_good_job_jobs_for_candidate_lookup", where: "(finished_at IS NULL)" + t.index ["priority", "created_at"], name: "index_good_jobs_jobs_on_priority_created_at_when_unfinished", order: { priority: "DESC NULLS LAST" }, where: "(finished_at IS NULL)" + t.index ["priority", "scheduled_at"], name: "index_good_jobs_on_priority_scheduled_at_unfinished_unlocked", where: "((finished_at IS NULL) AND (locked_by_id IS NULL))" + t.index ["queue_name", "scheduled_at"], name: "index_good_jobs_on_queue_name_and_scheduled_at", where: "(finished_at IS NULL)" + t.index ["scheduled_at"], name: "index_good_jobs_on_scheduled_at", where: "(finished_at IS NULL)" + end + + create_table "identities", force: :cascade do |t| + t.string "first_name" + t.string "last_name" + t.date "birthday" + t.string "legal_first_name" + t.string "legal_last_name" + t.string "primary_email" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "country" + t.string "slack_id" + t.boolean "ysws_eligible" + t.bigint "primary_address_id" + t.datetime "deleted_at" + t.text "aadhaar_number_ciphertext" + t.string "aadhaar_number_bidx" + t.boolean "hq_override", default: false + t.boolean "came_in_through_adult_program", default: false + t.string "phone_number" + t.boolean "permabanned", default: false + t.index ["aadhaar_number_bidx"], name: "index_identities_on_aadhaar_number_bidx", unique: true + t.index ["deleted_at"], name: "index_identities_on_deleted_at" + t.index ["primary_address_id"], name: "index_identities_on_primary_address_id" + t.index ["slack_id"], name: "index_identities_on_slack_id" + end + + create_table "identity_aadhaar_records", force: :cascade do |t| + t.bigint "identity_id", null: false + t.datetime "deleted_at" + t.text "raw_json_response" + t.date "date_of_birth" + t.string "name" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["identity_id"], name: "index_identity_aadhaar_records_on_identity_id" + end + + create_table "identity_documents", force: :cascade do |t| + t.integer "document_type" + t.bigint "identity_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.datetime "deleted_at" + t.index ["deleted_at"], name: "index_identity_documents_on_deleted_at" + t.index ["identity_id"], name: "index_identity_documents_on_identity_id" + end + + create_table "identity_login_codes", force: :cascade do |t| + t.datetime "expires_at" + t.string "token_bidx" + t.text "token_ciphertext" + t.datetime "used_at" + t.bigint "identity_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "return_url" + t.index ["identity_id"], name: "index_identity_login_codes_on_identity_id" + end + + create_table "identity_resemblances", force: :cascade do |t| + t.bigint "identity_id", null: false + t.bigint "past_identity_id", null: false + t.string "type" + t.bigint "document_id" + t.bigint "past_document_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["document_id"], name: "index_identity_resemblances_on_document_id" + t.index ["identity_id"], name: "index_identity_resemblances_on_identity_id" + t.index ["past_document_id"], name: "index_identity_resemblances_on_past_document_id" + t.index ["past_identity_id"], name: "index_identity_resemblances_on_past_identity_id" + end + + create_table "oauth_access_grants", force: :cascade do |t| + t.bigint "resource_owner_id", null: false + t.bigint "application_id", null: false + t.string "token", null: false + t.integer "expires_in", null: false + t.text "redirect_uri", null: false + t.string "scopes", default: "", null: false + t.datetime "created_at", null: false + t.datetime "revoked_at" + t.string "resource_owner_type", null: false + t.index ["application_id"], name: "index_oauth_access_grants_on_application_id" + t.index ["resource_owner_id", "resource_owner_type"], name: "polymorphic_owner_oauth_access_grants" + t.index ["resource_owner_id"], name: "index_oauth_access_grants_on_resource_owner_id" + t.index ["token"], name: "index_oauth_access_grants_on_token", unique: true + end + + create_table "oauth_access_tokens", force: :cascade do |t| + t.bigint "resource_owner_id" + t.bigint "application_id", null: false + t.string "refresh_token" + t.integer "expires_in" + t.string "scopes" + t.datetime "created_at", null: false + t.datetime "revoked_at" + t.string "previous_refresh_token", default: "", null: false + t.string "resource_owner_type" + t.text "token_ciphertext" + t.string "token_bidx" + t.index ["application_id"], name: "index_oauth_access_tokens_on_application_id" + t.index ["refresh_token"], name: "index_oauth_access_tokens_on_refresh_token", unique: true + t.index ["resource_owner_id", "resource_owner_type"], name: "polymorphic_owner_oauth_access_tokens" + t.index ["resource_owner_id"], name: "index_oauth_access_tokens_on_resource_owner_id" + t.index ["token_bidx"], name: "index_oauth_access_tokens_on_token_bidx", unique: true + end + + create_table "oauth_applications", force: :cascade do |t| + t.string "name", null: false + t.string "uid", null: false + t.string "secret", null: false + t.text "redirect_uri", null: false + t.string "scopes", default: "", null: false + t.boolean "confidential", default: true, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "program_key_bidx" + t.text "program_key_ciphertext" + t.boolean "active", default: true + t.index ["program_key_bidx"], name: "index_oauth_applications_on_program_key_bidx", unique: true + t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true + end + + create_table "settings", force: :cascade do |t| + t.string "key", null: false + t.text "value" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["key"], name: "index_settings_on_key", unique: true + end + + create_table "verifications", force: :cascade do |t| + t.bigint "identity_id", null: false + t.bigint "identity_document_id" + t.string "status", null: false + t.string "rejection_reason" + t.string "rejection_reason_details" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.datetime "deleted_at" + t.string "type" + t.boolean "fatal", default: false, null: false + t.string "aadhaar_hc_transaction_id" + t.string "aadhaar_external_transaction_id" + t.string "aadhaar_link" + t.bigint "aadhaar_record_id" + t.string "issues", default: [], array: true + t.datetime "pending_at" + t.datetime "ignored_at" + t.string "ignored_reason" + t.datetime "approved_at" + t.datetime "rejected_at" + t.text "internal_rejection_comment" + t.index ["aadhaar_record_id"], name: "index_verifications_on_aadhaar_record_id" + t.index ["deleted_at"], name: "index_verifications_on_deleted_at" + t.index ["fatal"], name: "index_verifications_on_fatal" + t.index ["identity_document_id"], name: "index_verifications_on_identity_document_id" + t.index ["identity_id"], name: "index_verifications_on_identity_id" + t.index ["type"], name: "index_verifications_on_type" + end + + create_table "versions", force: :cascade do |t| + t.string "whodunnit" + t.datetime "created_at" + t.bigint "item_id", null: false + t.string "item_type", null: false + t.string "event", null: false + t.text "object" + t.jsonb "object_changes" + t.index ["item_type", "item_id"], name: "index_versions_on_item_type_and_item_id" + 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 "addresses", "identities" + add_foreign_key "backend_organizer_positions", "backend_users" + add_foreign_key "backend_organizer_positions", "oauth_applications", column: "program_id" + add_foreign_key "break_glass_records", "backend_users" + add_foreign_key "identities", "addresses", column: "primary_address_id" + add_foreign_key "identity_aadhaar_records", "identities" + add_foreign_key "identity_documents", "identities" + add_foreign_key "identity_login_codes", "identities" + add_foreign_key "identity_resemblances", "identities" + add_foreign_key "identity_resemblances", "identities", column: "past_identity_id" + add_foreign_key "identity_resemblances", "identity_documents", column: "document_id" + add_foreign_key "identity_resemblances", "identity_documents", column: "past_document_id" + add_foreign_key "oauth_access_grants", "identities", column: "resource_owner_id" + add_foreign_key "oauth_access_grants", "oauth_applications", column: "application_id" + add_foreign_key "oauth_access_tokens", "identities", column: "resource_owner_id" + add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id" + add_foreign_key "verifications", "identities" + add_foreign_key "verifications", "identity_aadhaar_records", column: "aadhaar_record_id" + add_foreign_key "verifications", "identity_documents" +end diff --git a/db/seeds.rb b/db/seeds.rb new file mode 100644 index 0000000..4fbd6ed --- /dev/null +++ b/db/seeds.rb @@ -0,0 +1,9 @@ +# This file should ensure the existence of records required to run the application in every environment (production, +# development, test). The code here should be idempotent so that it can be executed at any point in every environment. +# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). +# +# Example: +# +# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| +# MovieGenre.find_or_create_by!(name: genre_name) +# end diff --git a/docker-compose-dbonly.yml b/docker-compose-dbonly.yml new file mode 100644 index 0000000..1fc95d9 --- /dev/null +++ b/docker-compose-dbonly.yml @@ -0,0 +1,21 @@ +# To use this DB-only ("dockerless") setup, pass a `-f docker-compose.dbonly.yml` to docker compose. +# e.g. `docker compose -f docker-compose.dbonly.yml up` +services: + db: + image: "postgres:11.16" + volumes: + - pg-data:/var/lib/postgresql/data + environment: + POSTGRES_HOST_AUTH_METHOD: trust + ports: + - 5432:5432 + redis: + image: redis + volumes: + - redis-data:/data + ports: + - 6379:6379 + +volumes: + pg-data: + redis-data: diff --git a/lib/application_component.rb b/lib/application_component.rb new file mode 100644 index 0000000..7ef30a1 --- /dev/null +++ b/lib/application_component.rb @@ -0,0 +1,2 @@ +class ApplicationComponent < Components::Base +end diff --git a/lib/tasks/.keep b/lib/tasks/.keep new file mode 100644 index 0000000..e69de29 diff --git a/log/.keep b/log/.keep new file mode 100644 index 0000000..e69de29 diff --git a/package.json b/package.json new file mode 100644 index 0000000..652684c --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "identity-vault", + "private": true, + "devDependencies": { + "@csstools/postcss-sass": "^5.1.1", + "@noble/secp256k1": "^2.2.3", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.3", + "postcss-import": "^16.1.0", + "postcss-nested": "^7.0.2", + "postcss-sass": "^0.5.0", + "postcss-scss": "^4.0.9", + "rollup": "^4.41.0", + "sass-embedded": "^1.89.0", + "vite": "^5.0.0", + "vite-plugin-rails": "^0.5.0", + "vite-plugin-ruby": "^5.1.0" + }, + "dependencies": { + "@noble/curves": "^1.9.1", + "@picocss/pico": "^2.1.1", + "@rails/activestorage": "^8.0.200", + "alpinejs": "^3.14.9", + "axios": "^1.9.0", + "dreamland": "^0.0.25", + "htmx.org": "^1.9.12", + "jquery": "^3.7.1" + } +} diff --git a/public/.well-known/security.txt b/public/.well-known/security.txt new file mode 100644 index 0000000..c293470 --- /dev/null +++ b/public/.well-known/security.txt @@ -0,0 +1,7 @@ +Contact: https://security.hackclub.com +Contact: mailto:nora@hackclub.com +Expires: 2026-11-27T05:00:00.000Z +Acknowledgments: https://bugs.hackclub.com/hall-of-fame.php +Preferred-Languages: en +Canonical: https://identity.hackclub.com/.well-known/security.txt +Policy: https://security.hackclub.com/ diff --git a/public/400.html b/public/400.html new file mode 100644 index 0000000..282dbc8 --- /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 0000000..c0670bc --- /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 0000000..9532a9c --- /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 0000000..8bcf060 --- /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 0000000..d77718c --- /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/ChicagoFLF.ttf b/public/ChicagoFLF.ttf new file mode 100644 index 0000000000000000000000000000000000000000..b77813c7614d6ad1ed980a7ff6f182cdf7e6e92f GIT binary patch literal 48580 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 + + + + + + + + + diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..c19f78a --- /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 0000000..e69de29 diff --git a/storage/.keep b/storage/.keep new file mode 100644 index 0000000..e69de29 diff --git a/tmp/.keep b/tmp/.keep new file mode 100644 index 0000000..e69de29 diff --git a/tmp/storage/.keep b/tmp/storage/.keep new file mode 100644 index 0000000..e69de29 diff --git a/vendor/.keep b/vendor/.keep new file mode 100644 index 0000000..e69de29 diff --git a/vite.config.mts b/vite.config.mts new file mode 100644 index 0000000..4a740d2 --- /dev/null +++ b/vite.config.mts @@ -0,0 +1,17 @@ +import { defineConfig } from "vite"; +import ViteRails from "vite-plugin-rails"; +import tailwindcss from '@tailwindcss/vite' +export default defineConfig({ + plugins: [ + ViteRails({ + envVars: { RAILS_ENV: "development" }, + envOptions: { defineOn: "import.meta.env" }, + fullReload: { + additionalPaths: ["config/routes.rb", "app/views/**/*"], + delay: 300, + }, + }), + // tailwindcss(), + ], + build: { sourcemap: false }, +}); \ No newline at end of file diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..ab9149e --- /dev/null +++ b/yarn.lock @@ -0,0 +1,1235 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@bufbuild/protobuf@^2.0.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@bufbuild/protobuf/-/protobuf-2.4.0.tgz#ef6bfd2fc0374a95a4904cdc5fd8e02cc999cf73" + integrity sha512-RN9M76x7N11QRihKovEglEjjVCQEA9PRBVnDgk9xw8JHLrcUrp4FpAVSPSH91cNbcTft3u2vpLN4GMbiKY9PJw== + +"@csstools/postcss-sass@^5.1.1": + version "5.1.1" + resolved "https://registry.yarnpkg.com/@csstools/postcss-sass/-/postcss-sass-5.1.1.tgz#135921df13bc56bee50c7470a66e4e9f3d5c89ae" + integrity sha512-La7bgTcM6YwPBLqlaXg7lMLry82iLv1a+S1RmgvHq2mH2Zd57L2anjZvJC8ACUHWc4M9fXws93dq6gaK0kZyAw== + dependencies: + "@csstools/sass-import-resolve" "^1.0.0" + sass "^1.69.5" + source-map "~0.7.4" + +"@csstools/sass-import-resolve@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@csstools/sass-import-resolve/-/sass-import-resolve-1.0.0.tgz#32c3cdb2f7af3cd8f0dca357b592e7271f3831b5" + integrity sha512-pH4KCsbtBLLe7eqUrw8brcuFO8IZlN36JjdKlOublibVdAIPHCzEnpBWOVUXK5sCf+DpBi8ZtuWtjF0srybdeA== + +"@esbuild/aix-ppc64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f" + integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ== + +"@esbuild/android-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052" + integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A== + +"@esbuild/android-arm@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28" + integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg== + +"@esbuild/android-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e" + integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA== + +"@esbuild/darwin-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a" + integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ== + +"@esbuild/darwin-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22" + integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw== + +"@esbuild/freebsd-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e" + integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g== + +"@esbuild/freebsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261" + integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ== + +"@esbuild/linux-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b" + integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q== + +"@esbuild/linux-arm@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9" + integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA== + +"@esbuild/linux-ia32@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2" + integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg== + +"@esbuild/linux-loong64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df" + integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg== + +"@esbuild/linux-mips64el@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe" + integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg== + +"@esbuild/linux-ppc64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4" + integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w== + +"@esbuild/linux-riscv64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc" + integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA== + +"@esbuild/linux-s390x@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de" + integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A== + +"@esbuild/linux-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0" + integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ== + +"@esbuild/netbsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047" + integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg== + +"@esbuild/openbsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70" + integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow== + +"@esbuild/sunos-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b" + integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg== + +"@esbuild/win32-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d" + integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A== + +"@esbuild/win32-ia32@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b" + integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA== + +"@esbuild/win32-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c" + integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw== + +"@noble/curves@^1.9.1": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.9.1.tgz#9654a0bc6c13420ae252ddcf975eaf0f58f0a35c" + integrity sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA== + dependencies: + "@noble/hashes" "1.8.0" + +"@noble/hashes@1.8.0": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a" + integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== + +"@noble/secp256k1@^2.2.3": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@noble/secp256k1/-/secp256k1-2.2.3.tgz#c505ced542328ed13315a8d811684d042f7acc5b" + integrity sha512-l7r5oEQym9Us7EAigzg30/PQAvynhMt2uoYtT3t26eGDVm9Yii5mZ5jWSWmZ/oSIR2Et0xfc6DXrG0bZ787V3w== + +"@nodelib/fs.scandir@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" + integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== + dependencies: + "@nodelib/fs.stat" "2.0.5" + run-parallel "^1.1.9" + +"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" + integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== + +"@nodelib/fs.walk@^1.2.3": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" + integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== + dependencies: + "@nodelib/fs.scandir" "2.1.5" + fastq "^1.6.0" + +"@parcel/watcher-android-arm64@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz#507f836d7e2042f798c7d07ad19c3546f9848ac1" + integrity sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA== + +"@parcel/watcher-darwin-arm64@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz#3d26dce38de6590ef79c47ec2c55793c06ad4f67" + integrity sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw== + +"@parcel/watcher-darwin-x64@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz#99f3af3869069ccf774e4ddfccf7e64fd2311ef8" + integrity sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg== + +"@parcel/watcher-freebsd-x64@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz#14d6857741a9f51dfe51d5b08b7c8afdbc73ad9b" + integrity sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ== + +"@parcel/watcher-linux-arm-glibc@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz#43c3246d6892381db473bb4f663229ad20b609a1" + integrity sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA== + +"@parcel/watcher-linux-arm-musl@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz#663750f7090bb6278d2210de643eb8a3f780d08e" + integrity sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q== + +"@parcel/watcher-linux-arm64-glibc@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz#ba60e1f56977f7e47cd7e31ad65d15fdcbd07e30" + integrity sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w== + +"@parcel/watcher-linux-arm64-musl@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz#f7fbcdff2f04c526f96eac01f97419a6a99855d2" + integrity sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg== + +"@parcel/watcher-linux-x64-glibc@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz#4d2ea0f633eb1917d83d483392ce6181b6a92e4e" + integrity sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A== + +"@parcel/watcher-linux-x64-musl@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz#277b346b05db54f55657301dd77bdf99d63606ee" + integrity sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg== + +"@parcel/watcher-win32-arm64@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz#7e9e02a26784d47503de1d10e8eab6cceb524243" + integrity sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw== + +"@parcel/watcher-win32-ia32@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz#2d0f94fa59a873cdc584bf7f6b1dc628ddf976e6" + integrity sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ== + +"@parcel/watcher-win32-x64@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz#ae52693259664ba6f2228fa61d7ee44b64ea0947" + integrity sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA== + +"@parcel/watcher@^2.4.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.5.1.tgz#342507a9cfaaf172479a882309def1e991fb1200" + integrity sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg== + dependencies: + detect-libc "^1.0.3" + is-glob "^4.0.3" + micromatch "^4.0.5" + node-addon-api "^7.0.0" + optionalDependencies: + "@parcel/watcher-android-arm64" "2.5.1" + "@parcel/watcher-darwin-arm64" "2.5.1" + "@parcel/watcher-darwin-x64" "2.5.1" + "@parcel/watcher-freebsd-x64" "2.5.1" + "@parcel/watcher-linux-arm-glibc" "2.5.1" + "@parcel/watcher-linux-arm-musl" "2.5.1" + "@parcel/watcher-linux-arm64-glibc" "2.5.1" + "@parcel/watcher-linux-arm64-musl" "2.5.1" + "@parcel/watcher-linux-x64-glibc" "2.5.1" + "@parcel/watcher-linux-x64-musl" "2.5.1" + "@parcel/watcher-win32-arm64" "2.5.1" + "@parcel/watcher-win32-ia32" "2.5.1" + "@parcel/watcher-win32-x64" "2.5.1" + +"@picocss/pico@^2.1.1": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@picocss/pico/-/pico-2.1.1.tgz#f2c4573b0332758b6d9c3caf4ee8b24cf3819f8e" + integrity sha512-kIDugA7Ps4U+2BHxiNHmvgPIQDWPDU4IeU6TNRdvXQM1uZX+FibqDQT2xUOnnO2yq/LUHcwnGlu1hvf4KfXnMg== + +"@rails/activestorage@^8.0.200": + version "8.0.200" + resolved "https://registry.yarnpkg.com/@rails/activestorage/-/activestorage-8.0.200.tgz#147c088e2b4167d6d49292431bdbdf10b118d5bd" + integrity sha512-V7GnZXsAMPDWVOBv4/XpHwj5sOw5bWjidWCuUbK3Zx1xt2pOfFaeJDUG7fEWb1MwP4aW1oVVlGkJBdXVyvru0A== + dependencies: + spark-md5 "^3.0.1" + +"@rollup/rollup-android-arm-eabi@4.41.0": + version "4.41.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.41.0.tgz#9145b38faf3fbfe3ec557130110e772f797335aa" + integrity sha512-KxN+zCjOYHGwCl4UCtSfZ6jrq/qi88JDUtiEFk8LELEHq2Egfc/FgW+jItZiOLRuQfb/3xJSgFuNPC9jzggX+A== + +"@rollup/rollup-android-arm64@4.41.0": + version "4.41.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.41.0.tgz#d73d641c59e9d7827e5ce0af9dfbc168b95cce0f" + integrity sha512-yDvqx3lWlcugozax3DItKJI5j05B0d4Kvnjx+5mwiUpWramVvmAByYigMplaoAQ3pvdprGCTCE03eduqE/8mPQ== + +"@rollup/rollup-darwin-arm64@4.41.0": + version "4.41.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.41.0.tgz#45d9d71d941117c98e7a5e77f60f0bc682d27e82" + integrity sha512-2KOU574vD3gzcPSjxO0eyR5iWlnxxtmW1F5CkNOHmMlueKNCQkxR6+ekgWyVnz6zaZihpUNkGxjsYrkTJKhkaw== + +"@rollup/rollup-darwin-x64@4.41.0": + version "4.41.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.41.0.tgz#8d72fb5f81714cb43e90f263fb1674520cce3f2a" + integrity sha512-gE5ACNSxHcEZyP2BA9TuTakfZvULEW4YAOtxl/A/YDbIir/wPKukde0BNPlnBiP88ecaN4BJI2TtAd+HKuZPQQ== + +"@rollup/rollup-freebsd-arm64@4.41.0": + version "4.41.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.41.0.tgz#a52b58852c9cec9255e382a2f335b08bc8c6111d" + integrity sha512-GSxU6r5HnWij7FoSo7cZg3l5GPg4HFLkzsFFh0N/b16q5buW1NAWuCJ+HMtIdUEi6XF0qH+hN0TEd78laRp7Dg== + +"@rollup/rollup-freebsd-x64@4.41.0": + version "4.41.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.41.0.tgz#104511dc64612789ddda41d164ab07cdac84a6c1" + integrity sha512-KGiGKGDg8qLRyOWmk6IeiHJzsN/OYxO6nSbT0Vj4MwjS2XQy/5emsmtoqLAabqrohbgLWJ5GV3s/ljdrIr8Qjg== + +"@rollup/rollup-linux-arm-gnueabihf@4.41.0": + version "4.41.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.41.0.tgz#643e3ad19c93903201fde89abd76baaee725e6c2" + integrity sha512-46OzWeqEVQyX3N2/QdiU/CMXYDH/lSHpgfBkuhl3igpZiaB3ZIfSjKuOnybFVBQzjsLwkus2mjaESy8H41SzvA== + +"@rollup/rollup-linux-arm-musleabihf@4.41.0": + version "4.41.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.41.0.tgz#fdc6a595aec7b20c5bfdac81412028c56d734e63" + integrity sha512-lfgW3KtQP4YauqdPpcUZHPcqQXmTmH4nYU0cplNeW583CMkAGjtImw4PKli09NFi2iQgChk4e9erkwlfYem6Lg== + +"@rollup/rollup-linux-arm64-gnu@4.41.0": + version "4.41.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.41.0.tgz#c28620bcd385496bdbbc24920b21f9fcca9ecbfa" + integrity sha512-nn8mEyzMbdEJzT7cwxgObuwviMx6kPRxzYiOl6o/o+ChQq23gfdlZcUNnt89lPhhz3BYsZ72rp0rxNqBSfqlqw== + +"@rollup/rollup-linux-arm64-musl@4.41.0": + version "4.41.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.41.0.tgz#a6b71b1e8fa33bac9f65b6f879e8ed878035d120" + integrity sha512-l+QK99je2zUKGd31Gh+45c4pGDAqZSuWQiuRFCdHYC2CSiO47qUWsCcenrI6p22hvHZrDje9QjwSMAFL3iwXwQ== + +"@rollup/rollup-linux-loongarch64-gnu@4.41.0": + version "4.41.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.41.0.tgz#b06374601ce865a1110324b2f06db574d3a1b0e1" + integrity sha512-WbnJaxPv1gPIm6S8O/Wg+wfE/OzGSXlBMbOe4ie+zMyykMOeqmgD1BhPxZQuDqwUN+0T/xOFtL2RUWBspnZj3w== + +"@rollup/rollup-linux-powerpc64le-gnu@4.41.0": + version "4.41.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.41.0.tgz#8a2a1f6058c920889c2aff3753a20fead7a8cc26" + integrity sha512-eRDWR5t67/b2g8Q/S8XPi0YdbKcCs4WQ8vklNnUYLaSWF+Cbv2axZsp4jni6/j7eKvMLYCYdcsv8dcU+a6QNFg== + +"@rollup/rollup-linux-riscv64-gnu@4.41.0": + version "4.41.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.41.0.tgz#8ef6f680d011b95a2f6546c6c31a37a33138035f" + integrity sha512-TWrZb6GF5jsEKG7T1IHwlLMDRy2f3DPqYldmIhnA2DVqvvhY2Ai184vZGgahRrg8k9UBWoSlHv+suRfTN7Ua4A== + +"@rollup/rollup-linux-riscv64-musl@4.41.0": + version "4.41.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.41.0.tgz#9f4884c5955a7cd39b396f6e27aa59b3269988eb" + integrity sha512-ieQljaZKuJpmWvd8gW87ZmSFwid6AxMDk5bhONJ57U8zT77zpZ/TPKkU9HpnnFrM4zsgr4kiGuzbIbZTGi7u9A== + +"@rollup/rollup-linux-s390x-gnu@4.41.0": + version "4.41.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.41.0.tgz#5619303cc51994e3df404a497f42c79dc5efd6eb" + integrity sha512-/L3pW48SxrWAlVsKCN0dGLB2bi8Nv8pr5S5ocSM+S0XCn5RCVCXqi8GVtHFsOBBCSeR+u9brV2zno5+mg3S4Aw== + +"@rollup/rollup-linux-x64-gnu@4.41.0": + version "4.41.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.41.0.tgz#c3e42b66c04e25ad0f2a00beec42ede96ccc8983" + integrity sha512-XMLeKjyH8NsEDCRptf6LO8lJk23o9wvB+dJwcXMaH6ZQbbkHu2dbGIUindbMtRN6ux1xKi16iXWu6q9mu7gDhQ== + +"@rollup/rollup-linux-x64-musl@4.41.0": + version "4.41.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.41.0.tgz#8d3452de42aa72fc5fc3e5ad1eb0b68030742a25" + integrity sha512-m/P7LycHZTvSQeXhFmgmdqEiTqSV80zn6xHaQ1JSqwCtD1YGtwEK515Qmy9DcB2HK4dOUVypQxvhVSy06cJPEg== + +"@rollup/rollup-win32-arm64-msvc@4.41.0": + version "4.41.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.41.0.tgz#3b7bbd9f43f1c380061f306abce6f3f64de20306" + integrity sha512-4yodtcOrFHpbomJGVEqZ8fzD4kfBeCbpsUy5Pqk4RluXOdsWdjLnjhiKy2w3qzcASWd04fp52Xz7JKarVJ5BTg== + +"@rollup/rollup-win32-ia32-msvc@4.41.0": + version "4.41.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.41.0.tgz#e27ef5c40bbec49fac3d4e4b1618fbe4597b40e5" + integrity sha512-tmazCrAsKzdkXssEc65zIE1oC6xPHwfy9d5Ta25SRCDOZS+I6RypVVShWALNuU9bxIfGA0aqrmzlzoM5wO5SPQ== + +"@rollup/rollup-win32-x64-msvc@4.41.0": + version "4.41.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.41.0.tgz#b0b595ad4720259bbb81600750d26a655cac06be" + integrity sha512-h1J+Yzjo/X+0EAvR2kIXJDuTuyT7drc+t2ALY0nIcGPbTatNOf0VWdhEA2Z4AAjv6X1NJV7SYo5oCTYRJhSlVA== + +"@types/estree@1.0.7": + version "1.0.7" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8" + integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ== + +"@vue/reactivity@~3.1.1": + version "3.1.5" + resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.1.5.tgz#dbec4d9557f7c8f25c2635db1e23a78a729eb991" + integrity sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg== + dependencies: + "@vue/shared" "3.1.5" + +"@vue/shared@3.1.5": + version "3.1.5" + resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.1.5.tgz#74ee3aad995d0a3996a6bb9533d4d280514ede03" + integrity sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA== + +alpinejs@^3.14.9: + version "3.14.9" + resolved "https://registry.yarnpkg.com/alpinejs/-/alpinejs-3.14.9.tgz#381a050152acb221b2209d9398935d5cd4c18afc" + integrity sha512-gqSOhTEyryU9FhviNqiHBHzgjkvtukq9tevew29fTj+ofZtfsYriw4zPirHHOAy9bw8QoL3WGhyk7QqCh5AYlw== + dependencies: + "@vue/reactivity" "~3.1.1" + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + +autoprefixer@^10.4.21: + version "10.4.21" + resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.21.tgz#77189468e7a8ad1d9a37fbc08efc9f480cf0a95d" + integrity sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ== + dependencies: + browserslist "^4.24.4" + caniuse-lite "^1.0.30001702" + fraction.js "^4.3.7" + normalize-range "^0.1.2" + picocolors "^1.1.1" + postcss-value-parser "^4.2.0" + +axios@^1.9.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.9.0.tgz#25534e3b72b54540077d33046f77e3b8d7081901" + integrity sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg== + dependencies: + follow-redirects "^1.15.6" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + +braces@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + +browserslist@^4.24.4: + version "4.24.5" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.24.5.tgz#aa0f5b8560fe81fde84c6dcb38f759bafba0e11b" + integrity sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw== + dependencies: + caniuse-lite "^1.0.30001716" + electron-to-chromium "^1.5.149" + node-releases "^2.0.19" + update-browserslist-db "^1.1.3" + +buffer-builder@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/buffer-builder/-/buffer-builder-0.2.0.tgz#3322cd307d8296dab1f604618593b261a3fade8f" + integrity sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg== + +call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + +caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001716: + version "1.0.30001718" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz#dae13a9c80d517c30c6197515a96131c194d8f82" + integrity sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw== + +chokidar@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30" + integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== + dependencies: + readdirp "^4.0.1" + +colorjs.io@^0.5.0: + version "0.5.2" + resolved "https://registry.yarnpkg.com/colorjs.io/-/colorjs.io-0.5.2.tgz#63b20139b007591ebc3359932bef84628eb3fcef" + integrity sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw== + +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +cssesc@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" + integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== + +debug@^4.3, debug@^4.3.4: + version "4.4.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b" + integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ== + dependencies: + ms "^2.1.3" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + +detect-libc@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" + integrity sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg== + +dreamland@^0.0.25: + version "0.0.25" + resolved "https://registry.yarnpkg.com/dreamland/-/dreamland-0.0.25.tgz#179c2b465d954dbb66f88bf432e0251cd37e4caa" + integrity sha512-REhB1T/6do8R7nwa/B0+ie0tTWY97cLsfPwmmqSHOMCu2Qw+cVSWhqTDB1Boklqg9Z8qfRkmGU/rknu0R48QWw== + +dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + +electron-to-chromium@^1.5.149: + version "1.5.155" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.155.tgz#809dd0ae9ae1db87c358e0c0c17c09a2ffc432d1" + integrity sha512-ps5KcGGmwL8VaeJlvlDlu4fORQpv3+GIcF5I3f9tUKUlJ/wsysh6HU8P5L1XWRYeXfA0oJd4PyM8ds8zTFf6Ng== + +es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" + integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== + dependencies: + es-errors "^1.3.0" + +es-set-tostringtag@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d" + integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== + dependencies: + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + +esbuild@^0.21.3: + version "0.21.5" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d" + integrity sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw== + optionalDependencies: + "@esbuild/aix-ppc64" "0.21.5" + "@esbuild/android-arm" "0.21.5" + "@esbuild/android-arm64" "0.21.5" + "@esbuild/android-x64" "0.21.5" + "@esbuild/darwin-arm64" "0.21.5" + "@esbuild/darwin-x64" "0.21.5" + "@esbuild/freebsd-arm64" "0.21.5" + "@esbuild/freebsd-x64" "0.21.5" + "@esbuild/linux-arm" "0.21.5" + "@esbuild/linux-arm64" "0.21.5" + "@esbuild/linux-ia32" "0.21.5" + "@esbuild/linux-loong64" "0.21.5" + "@esbuild/linux-mips64el" "0.21.5" + "@esbuild/linux-ppc64" "0.21.5" + "@esbuild/linux-riscv64" "0.21.5" + "@esbuild/linux-s390x" "0.21.5" + "@esbuild/linux-x64" "0.21.5" + "@esbuild/netbsd-x64" "0.21.5" + "@esbuild/openbsd-x64" "0.21.5" + "@esbuild/sunos-x64" "0.21.5" + "@esbuild/win32-arm64" "0.21.5" + "@esbuild/win32-ia32" "0.21.5" + "@esbuild/win32-x64" "0.21.5" + +escalade@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +fast-glob@^3.3.2: + version "3.3.3" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818" + integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.8" + +fastq@^1.6.0: + version "1.19.1" + resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.19.1.tgz#d50eaba803c8846a883c16492821ebcd2cda55f5" + integrity sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ== + dependencies: + reusify "^1.0.4" + +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + +follow-redirects@^1.15.6: + version "1.15.9" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1" + integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ== + +form-data@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.2.tgz#35cabbdd30c3ce73deb2c42d3c8d3ed9ca51794c" + integrity sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + mime-types "^2.1.12" + +fraction.js@^4.3.7: + version "4.3.7" + resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7" + integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew== + +fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +get-intrinsic@^1.2.6: + version "1.3.0" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + function-bind "^1.1.2" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" + +get-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + dependencies: + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" + +glob-parent@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +gonzales-pe@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/gonzales-pe/-/gonzales-pe-4.3.0.tgz#fe9dec5f3c557eead09ff868c65826be54d067b3" + integrity sha512-otgSPpUmdWJ43VXyiNgEYE4luzHCL2pz4wQ0OnDluC6Eg4Ko3Vexy/SrSynglw/eR+OhkzmqFCZa/OFa/RgAOQ== + dependencies: + minimist "^1.2.5" + +gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +has-symbols@^1.0.3, has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + +has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + +hasown@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + +htmx.org@^1.9.12: + version "1.9.12" + resolved "https://registry.yarnpkg.com/htmx.org/-/htmx.org-1.9.12.tgz#1c5bc7fb4d3eb4e8c0d72323dc774a6b9b66addc" + integrity sha512-VZAohXyF7xPGS52IM8d1T1283y+X4D+Owf3qY1NZ9RuBypyu9l8cGsxUMAG5fEAb/DhT7rDoJ9Hpu5/HxFD3cw== + +immutable@^5.0.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.2.tgz#e8169476414505e5a4fa650107b65e1227d16d4b" + integrity sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ== + +is-core-module@^2.16.0: + version "2.16.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" + integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== + dependencies: + hasown "^2.0.2" + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-glob@^4.0.1, is-glob@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +jquery@^3.7.1: + version "3.7.1" + resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.7.1.tgz#083ef98927c9a6a74d05a6af02806566d16274de" + integrity sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg== + +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + +merge2@^1.3.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" + integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== + +micromatch@^4.0.5, micromatch@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" + integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== + dependencies: + braces "^3.0.3" + picomatch "^2.3.1" + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@^2.1.12: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +minimist@^1.2.5: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +nanoid@^3.3.8: + version "3.3.11" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" + integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== + +node-addon-api@^7.0.0: + version "7.1.1" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558" + integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ== + +node-releases@^2.0.19: + version "2.0.19" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.19.tgz#9e445a52950951ec4d177d843af370b411caf314" + integrity sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw== + +normalize-range@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" + integrity sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA== + +path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +picocolors@^1.0.0, picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +pify@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" + integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog== + +postcss-import@^16.1.0: + version "16.1.0" + resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-16.1.0.tgz#258732175518129667fe1e2e2a05b19b5654b96a" + integrity sha512-7hsAZ4xGXl4MW+OKEWCnF6T5jqBw80/EE9aXg1r2yyn1RsVEU8EtKXbijEODa+rg7iih4bKf7vlvTGYR4CnPNg== + dependencies: + postcss-value-parser "^4.0.0" + read-cache "^1.0.0" + resolve "^1.1.7" + +postcss-nested@^7.0.2: + version "7.0.2" + resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-7.0.2.tgz#863d83a6b5df0a2894560394be93d5383ea37a65" + integrity sha512-5osppouFc0VR9/VYzYxO03VaDa3e8F23Kfd6/9qcZTUI8P58GIYlArOET2Wq0ywSl2o2PjELhYOFI4W7l5QHKw== + dependencies: + postcss-selector-parser "^7.0.0" + +postcss-sass@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/postcss-sass/-/postcss-sass-0.5.0.tgz#a599717eef90165d267e6a974c66c2ed15376b77" + integrity sha512-qtu8awh1NMF3o9j/x9j3EZnd+BlF66X6NZYl12BdKoG2Z4hmydOt/dZj2Nq+g0kfk2pQy3jeYFBmvG9DBwynGQ== + dependencies: + gonzales-pe "^4.3.0" + postcss "^8.2.14" + +postcss-scss@^4.0.9: + version "4.0.9" + resolved "https://registry.yarnpkg.com/postcss-scss/-/postcss-scss-4.0.9.tgz#a03c773cd4c9623cb04ce142a52afcec74806685" + integrity sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A== + +postcss-selector-parser@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz#4d6af97eba65d73bc4d84bcb343e865d7dd16262" + integrity sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA== + dependencies: + cssesc "^3.0.0" + util-deprecate "^1.0.2" + +postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" + integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== + +postcss@^8.2.14, postcss@^8.4.43, postcss@^8.5.3: + version "8.5.3" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.3.tgz#1463b6f1c7fb16fe258736cba29a2de35237eafb" + integrity sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A== + dependencies: + nanoid "^3.3.8" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + +queue-microtask@^1.2.2: + version "1.2.3" + resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" + integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== + +read-cache@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774" + integrity sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA== + dependencies: + pify "^2.3.0" + +readdirp@^4.0.1: + version "4.1.2" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d" + integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg== + +resolve@^1.1.7: + version "1.22.10" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.10.tgz#b663e83ffb09bbf2386944736baae803029b8b39" + integrity sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w== + dependencies: + is-core-module "^2.16.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +reusify@^1.0.4: + version "1.1.0" + resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f" + integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw== + +rollup-plugin-gzip@^3.1.0: + version "3.1.2" + resolved "https://registry.yarnpkg.com/rollup-plugin-gzip/-/rollup-plugin-gzip-3.1.2.tgz#248267c09b23a7a48291625cf668d5511c517c36" + integrity sha512-9xemMyvCjkklgNpu6jCYqQAbvCLJzA2nilkiOGzFuXTUX3cXEFMwIhsIBRF7kTKD/SnZ1tNPcxFm4m4zJ3VfNQ== + +rollup@^4.20.0, rollup@^4.41.0: + version "4.41.0" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.41.0.tgz#17476835d2967759e3ffebe5823ed15fc4b7d13e" + integrity sha512-HqMFpUbWlf/tvcxBFNKnJyzc7Lk+XO3FGc3pbNBLqEbOz0gPLRgcrlS3UF4MfUrVlstOaP/q0kM6GVvi+LrLRg== + dependencies: + "@types/estree" "1.0.7" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.41.0" + "@rollup/rollup-android-arm64" "4.41.0" + "@rollup/rollup-darwin-arm64" "4.41.0" + "@rollup/rollup-darwin-x64" "4.41.0" + "@rollup/rollup-freebsd-arm64" "4.41.0" + "@rollup/rollup-freebsd-x64" "4.41.0" + "@rollup/rollup-linux-arm-gnueabihf" "4.41.0" + "@rollup/rollup-linux-arm-musleabihf" "4.41.0" + "@rollup/rollup-linux-arm64-gnu" "4.41.0" + "@rollup/rollup-linux-arm64-musl" "4.41.0" + "@rollup/rollup-linux-loongarch64-gnu" "4.41.0" + "@rollup/rollup-linux-powerpc64le-gnu" "4.41.0" + "@rollup/rollup-linux-riscv64-gnu" "4.41.0" + "@rollup/rollup-linux-riscv64-musl" "4.41.0" + "@rollup/rollup-linux-s390x-gnu" "4.41.0" + "@rollup/rollup-linux-x64-gnu" "4.41.0" + "@rollup/rollup-linux-x64-musl" "4.41.0" + "@rollup/rollup-win32-arm64-msvc" "4.41.0" + "@rollup/rollup-win32-ia32-msvc" "4.41.0" + "@rollup/rollup-win32-x64-msvc" "4.41.0" + fsevents "~2.3.2" + +run-parallel@^1.1.9: + version "1.2.0" + resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" + integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== + dependencies: + queue-microtask "^1.2.2" + +rxjs@^7.4.0: + version "7.8.2" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.2.tgz#955bc473ed8af11a002a2be52071bf475638607b" + integrity sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA== + dependencies: + tslib "^2.1.0" + +sass-embedded-android-arm64@1.89.0: + version "1.89.0" + resolved "https://registry.yarnpkg.com/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.89.0.tgz#4fddf67d525483dc49ab17d40cf284b0746d7321" + integrity sha512-pr4R3p5R+Ul9ZA5nzYbBJQFJXW6dMGzgpNBhmaToYDgDhmNX5kg0mZAUlGLHvisLdTiR6oEfDDr9QI6tnD2nqA== + +sass-embedded-android-arm@1.89.0: + version "1.89.0" + resolved "https://registry.yarnpkg.com/sass-embedded-android-arm/-/sass-embedded-android-arm-1.89.0.tgz#c59adf3011cf75348da880164ab8bea4ae58025b" + integrity sha512-s6jxkEZQQrtyIGZX6Sbcu7tEixFG2VkqFgrX11flm/jZex7KaxnZtFace+wnYAgHqzzYpx0kNzJUpT+GXxm8CA== + +sass-embedded-android-ia32@1.89.0: + version "1.89.0" + resolved "https://registry.yarnpkg.com/sass-embedded-android-ia32/-/sass-embedded-android-ia32-1.89.0.tgz#15543dbb173ea5d8994a8df93cef45e113357f76" + integrity sha512-GoNnNGYmp1F0ZMHqQbAurlQsjBMZKtDd5H60Ruq86uQFdnuNqQ9wHKJsJABxMnjfAn60IjefytM5PYTMcAmbfA== + +sass-embedded-android-riscv64@1.89.0: + version "1.89.0" + resolved "https://registry.yarnpkg.com/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.89.0.tgz#4ad5f70bcd3ce8e57780b95f038f4ee898b7f577" + integrity sha512-di+i4KkKAWTNksaQYTqBEERv46qV/tvv14TPswEfak7vcTQ2pj2mvV4KGjLYfU2LqRkX/NTXix9KFthrzFN51Q== + +sass-embedded-android-x64@1.89.0: + version "1.89.0" + resolved "https://registry.yarnpkg.com/sass-embedded-android-x64/-/sass-embedded-android-x64-1.89.0.tgz#a36fe99c14263a6825e25ce2ffbd69274356d674" + integrity sha512-1cRRDAnmAS1wLaxfFf6PCHu9sKW8FNxdM7ZkanwxO9mztrCu/uvfqTmaurY9+RaKvPus7sGYFp46/TNtl/wRjg== + +sass-embedded-darwin-arm64@1.89.0: + version "1.89.0" + resolved "https://registry.yarnpkg.com/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.89.0.tgz#fd9705bf1cdddf1317e61b8d6b4929d148e6ff02" + integrity sha512-EUNUzI0UkbQ6dASPyf09S3x7fNT54PjyD594ZGTY14Yh4qTuacIj27ckLmreAJNNu5QxlbhyYuOtz+XN5bMMxA== + +sass-embedded-darwin-x64@1.89.0: + version "1.89.0" + resolved "https://registry.yarnpkg.com/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.89.0.tgz#db431aef7395e68877808d0bb5d38c15d8818a41" + integrity sha512-23R8zSuB31Fq/MYpmQ38UR2C26BsYb66VVpJgWmWl/N+sgv/+l9ECuSPMbYNgM3vb9TP9wk9dgL6KkiCS5tAyg== + +sass-embedded-linux-arm64@1.89.0: + version "1.89.0" + resolved "https://registry.yarnpkg.com/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.89.0.tgz#ef9832f0854d7613cac2fd8b456a59ec1fb06554" + integrity sha512-g9Lp57qyx51ttKj0AN/edV43Hu1fBObvD7LpYwVfs6u3I95r0Adi90KujzNrUqXxJVmsfUwseY8kA8zvcRjhYA== + +sass-embedded-linux-arm@1.89.0: + version "1.89.0" + resolved "https://registry.yarnpkg.com/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.89.0.tgz#a2489a04ee3f41f3ce065ea92c3558c8ab601b05" + integrity sha512-KAzA1XD74d8/fiJXxVnLfFwfpmD2XqUJZz+DL6ZAPNLH1sb+yCP7brktaOyClDc/MBu61JERdHaJjIZhfX0Yqw== + +sass-embedded-linux-ia32@1.89.0: + version "1.89.0" + resolved "https://registry.yarnpkg.com/sass-embedded-linux-ia32/-/sass-embedded-linux-ia32-1.89.0.tgz#63bd3cdbd101dd5dfddf37e0d7f94478ef6c5408" + integrity sha512-5fxBeXyvBr3pb+vyrx9V6yd7QDRXkAPbwmFVVhjqshBABOXelLysEFea7xokh/tM8JAAQ4O8Ls3eW3Eojb477g== + +sass-embedded-linux-musl-arm64@1.89.0: + version "1.89.0" + resolved "https://registry.yarnpkg.com/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.89.0.tgz#eb81b110cf9dc1d0bb526899d0ecb2ad18816206" + integrity sha512-50oelrOtN64u15vJN9uJryIuT0+UPjyeoq0zdWbY8F7LM9294Wf+Idea+nqDUWDCj1MHndyPFmR1mjeuRouJhw== + +sass-embedded-linux-musl-arm@1.89.0: + version "1.89.0" + resolved "https://registry.yarnpkg.com/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.89.0.tgz#a741cc0f0aa7f9fa9594134ca7e7d6a38d1a3a76" + integrity sha512-0Q1JeEU4/tzH7fwAwarfIh+Swn3aXG/jPhVsZpbR1c1VzkeaPngmXdmLJcVXsdb35tjk84DuYcFtJlE1HYGw4Q== + +sass-embedded-linux-musl-ia32@1.89.0: + version "1.89.0" + resolved "https://registry.yarnpkg.com/sass-embedded-linux-musl-ia32/-/sass-embedded-linux-musl-ia32-1.89.0.tgz#eb90989aade26517235284ff42ffd08b62f2a92e" + integrity sha512-ILWqpTd+0RdsSw977iVAJf4CLetIbcQgLQf17ycS1N4StZKVRZs1bBfZhg/f/HU/4p5HondPAwepgJepZZdnFA== + +sass-embedded-linux-musl-riscv64@1.89.0: + version "1.89.0" + resolved "https://registry.yarnpkg.com/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.89.0.tgz#3ad425cc7842053760ba72c393c18c83b7be4ec9" + integrity sha512-n2V+Tdjj7SAuiuElJYhWiHjjB1YU0cuFvL1/m5K+ecdNStfHFWIzvBT6/vzQnBOWjI4eZECNVuQ8GwGWCufZew== + +sass-embedded-linux-musl-x64@1.89.0: + version "1.89.0" + resolved "https://registry.yarnpkg.com/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.89.0.tgz#261d302fe280805c70b547757f40e597cd6dce16" + integrity sha512-KOHJdouBK3SLJKZLnFYzuxs3dn+6jaeO3p4p1JUYAcVfndcvh13Sg2sLGfOfpg7Og6ws2Nnqnx0CyL26jPJ7ag== + +sass-embedded-linux-riscv64@1.89.0: + version "1.89.0" + resolved "https://registry.yarnpkg.com/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.89.0.tgz#7107b993a51b60fa536399e0872455952eafc9cd" + integrity sha512-0A/UWeKX6MYhVLWLkdX3NPKHO+mvIwzaf6TxGCy3vS3TODWaeDUeBhHShAr7YlOKv5xRGxf7Gx7FXCPV0mUyMA== + +sass-embedded-linux-x64@1.89.0: + version "1.89.0" + resolved "https://registry.yarnpkg.com/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.89.0.tgz#3f6212caa0f23d91b4421f65b3164529bd8f3e1a" + integrity sha512-dRBoOFPDWctHPYK3hTk3YzyX/icVrXiw7oOjbtpaDr6JooqIWBe16FslkWyvQzdmfOFy80raKVjgoqT7DsznkQ== + +sass-embedded-win32-arm64@1.89.0: + version "1.89.0" + resolved "https://registry.yarnpkg.com/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.89.0.tgz#a6648dd54f3def903ed88e9ac544057265addbf8" + integrity sha512-RnlVZ14hC/W7ubzvhqnbGfjU5PFNoFP/y5qycgCy+Mezb0IKbWvZ2Lyzux8TbL3OIjOikkNpfXoNQrX706WLAA== + +sass-embedded-win32-ia32@1.89.0: + version "1.89.0" + resolved "https://registry.yarnpkg.com/sass-embedded-win32-ia32/-/sass-embedded-win32-ia32-1.89.0.tgz#70d04e8b334e52c973b990b0c845a60af119831a" + integrity sha512-eFe9VMNG+90nuoE3eXDy+38+uEHGf7xcqalq5+0PVZfR+H9RlaEbvIUNflZV94+LOH8Jb4lrfuekhHgWDJLfSg== + +sass-embedded-win32-x64@1.89.0: + version "1.89.0" + resolved "https://registry.yarnpkg.com/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.89.0.tgz#43f94b341561354684901d8420ac1f2cdf1c1bec" + integrity sha512-AaGpr5R6MLCuSvkvDdRq49ebifwLcuGPk0/10hbYw9nh3jpy2/CylYubQpIpR4yPcuD1wFwFqufTXC3HJYGb0g== + +sass-embedded@^1.89.0: + version "1.89.0" + resolved "https://registry.yarnpkg.com/sass-embedded/-/sass-embedded-1.89.0.tgz#042575f94364ff4b7b142f8e2be003f348b62b86" + integrity sha512-EDrK1el9zdgJFpocCGlxatDWaP18tJBWoM1hxzo2KJBvjdmBichXI6O6KlQrigvQPO3uJ8DfmFmAAx7s7CG6uw== + dependencies: + "@bufbuild/protobuf" "^2.0.0" + buffer-builder "^0.2.0" + colorjs.io "^0.5.0" + immutable "^5.0.2" + rxjs "^7.4.0" + supports-color "^8.1.1" + sync-child-process "^1.0.2" + varint "^6.0.0" + optionalDependencies: + sass-embedded-android-arm "1.89.0" + sass-embedded-android-arm64 "1.89.0" + sass-embedded-android-ia32 "1.89.0" + sass-embedded-android-riscv64 "1.89.0" + sass-embedded-android-x64 "1.89.0" + sass-embedded-darwin-arm64 "1.89.0" + sass-embedded-darwin-x64 "1.89.0" + sass-embedded-linux-arm "1.89.0" + sass-embedded-linux-arm64 "1.89.0" + sass-embedded-linux-ia32 "1.89.0" + sass-embedded-linux-musl-arm "1.89.0" + sass-embedded-linux-musl-arm64 "1.89.0" + sass-embedded-linux-musl-ia32 "1.89.0" + sass-embedded-linux-musl-riscv64 "1.89.0" + sass-embedded-linux-musl-x64 "1.89.0" + sass-embedded-linux-riscv64 "1.89.0" + sass-embedded-linux-x64 "1.89.0" + sass-embedded-win32-arm64 "1.89.0" + sass-embedded-win32-ia32 "1.89.0" + sass-embedded-win32-x64 "1.89.0" + +sass@^1.69.5: + version "1.89.0" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.89.0.tgz#6df72360c5c3ec2a9833c49adafe57b28206752d" + integrity sha512-ld+kQU8YTdGNjOLfRWBzewJpU5cwEv/h5yyqlSeJcj6Yh8U4TDA9UA5FPicqDz/xgRPWRSYIQNiFks21TbA9KQ== + dependencies: + chokidar "^4.0.0" + immutable "^5.0.2" + source-map-js ">=0.6.2 <2.0.0" + optionalDependencies: + "@parcel/watcher" "^2.4.1" + +"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +source-map@~0.7.4: + version "0.7.4" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.4.tgz#a9bbe705c9d8846f4e08ff6765acf0f1b0898656" + integrity sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA== + +spark-md5@^3.0.1: + version "3.0.2" + resolved "https://registry.yarnpkg.com/spark-md5/-/spark-md5-3.0.2.tgz#7952c4a30784347abcee73268e473b9c0167e3fc" + integrity sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw== + +stimulus-vite-helpers@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/stimulus-vite-helpers/-/stimulus-vite-helpers-3.1.0.tgz#9216d703ac8d74befece4499ea738c18de408842" + integrity sha512-qy9vnNnu6e/1PArEndp456BuSKLQkBgc+vX2pedOHT0N4GSLQY0l5fuQ4ft56xZ8xSWqrfuYSR+GXXIPtoESww== + +supports-color@^8.1.1: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + +sync-child-process@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/sync-child-process/-/sync-child-process-1.0.2.tgz#45e7c72e756d1243e80b547ea2e17957ab9e367f" + integrity sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA== + dependencies: + sync-message-port "^1.0.0" + +sync-message-port@^1.0.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/sync-message-port/-/sync-message-port-1.1.3.tgz#6055c565ee8c81d2f9ee5aae7db757e6d9088c0c" + integrity sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg== + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +tslib@^2.1.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + +update-browserslist-db@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz#348377dd245216f9e7060ff50b15a1b740b75420" + integrity sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw== + dependencies: + escalade "^3.2.0" + picocolors "^1.1.1" + +util-deprecate@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== + +varint@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/varint/-/varint-6.0.0.tgz#9881eb0ce8feaea6512439d19ddf84bf551661d0" + integrity sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg== + +vite-plugin-environment@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/vite-plugin-environment/-/vite-plugin-environment-1.1.3.tgz#d01a04abb2f69730a4866c9c9db51d3dab74645b" + integrity sha512-9LBhB0lx+2lXVBEWxFZC+WO7PKEyE/ykJ7EPWCq95NEcCpblxamTbs5Dm3DLBGzwODpJMEnzQywJU8fw6XGGGA== + +vite-plugin-full-reload@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/vite-plugin-full-reload/-/vite-plugin-full-reload-1.2.0.tgz#bc4bfdc842abb4d24309ca802be8b955fce1c0c6" + integrity sha512-kz18NW79x0IHbxRSHm0jttP4zoO9P9gXh+n6UTwlNKnviTTEpOlum6oS9SmecrTtSr+muHEn5TUuC75UovQzcA== + dependencies: + picocolors "^1.0.0" + picomatch "^2.3.1" + +vite-plugin-manifest-sri@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/vite-plugin-manifest-sri/-/vite-plugin-manifest-sri-0.2.0.tgz#cb1cfd11692ee81f5d1194926cbea6d3a38b8599" + integrity sha512-Zt5jt19xTIJ91LOuQTCtNG7rTFc5OziAjBz2H5NdCGqaOD1nxrWExLhcKW+W4/q8/jOPCg/n5ncYEQmqCxiGQQ== + +vite-plugin-rails@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/vite-plugin-rails/-/vite-plugin-rails-0.5.0.tgz#fe29827b6f42abbc6e6537748963bb93871f468c" + integrity sha512-PR3zTHW96X8c7dRsuL2Mu1EAXXeO8fQjQ2KanwIC7EWgBST+D8AKjJyEUAr13IakrIYvd1cM3LcQUcrKmCMePg== + dependencies: + rollup-plugin-gzip "^3.1.0" + vite-plugin-environment "^1.1.3" + vite-plugin-full-reload "^1.1.0" + vite-plugin-manifest-sri "^0.2.0" + vite-plugin-ruby "^5.0.0" + vite-plugin-stimulus-hmr "^3.0.0" + +vite-plugin-ruby@^5.0.0, vite-plugin-ruby@^5.1.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/vite-plugin-ruby/-/vite-plugin-ruby-5.1.1.tgz#ecd72591ddb90a23613051005bd70a6410945129" + integrity sha512-I1dXJq2ywdvTD2Cz5LYNcYLujqQ3eUxPoCjruRdfm2QBtHBY15NEeb6x5HuPM3T5S+y8S3p9fwRsieQQCjk0gg== + dependencies: + debug "^4.3.4" + fast-glob "^3.3.2" + +vite-plugin-stimulus-hmr@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/vite-plugin-stimulus-hmr/-/vite-plugin-stimulus-hmr-3.0.0.tgz#60410a69486e86a8c1a769fe4b10039ac5f8d910" + integrity sha512-KElOiZOlaG4XilQQHrzK8M1u5UfK4EFfADJKQYbnmsUMifDOnPR6anVYgHAN95QyWJ67Q/rYWe5BB9M5OxocfQ== + dependencies: + debug "^4.3" + stimulus-vite-helpers "^3.0.0" + +vite@^5.0.0: + version "5.4.19" + resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.19.tgz#20efd060410044b3ed555049418a5e7d1998f959" + integrity sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA== + dependencies: + esbuild "^0.21.3" + postcss "^8.4.43" + rollup "^4.20.0" + optionalDependencies: + fsevents "~2.3.3"