initial public commit!!!

This commit is contained in:
24c02 2025-09-02 13:52:07 -04:00
commit a260c265f0
326 changed files with 15473 additions and 0 deletions

47
.dockerignore Normal file
View file

@ -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*

9
.gitattributes vendored Normal file
View file

@ -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

12
.github/dependabot.yml vendored Normal file
View file

@ -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

39
.github/workflows/ci.yml vendored Normal file
View file

@ -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

48
.gitignore vendored Normal file
View file

@ -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

8
.rubocop.yml Normal file
View file

@ -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

1
.ruby-version Normal file
View file

@ -0,0 +1 @@
3.4.4

98
Dockerfile Normal file
View file

@ -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=<value from config/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"]

61
Dockerfile.worker Normal file
View file

@ -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=<value from config/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"]

123
Gemfile Normal file
View file

@ -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

590
Gemfile.lock Normal file
View file

@ -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

3
Procfile.dev Normal file
View file

@ -0,0 +1,3 @@
vite: bin/vite dev
web: bin/rails s

29
README.md Normal file
View file

@ -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<whatever>", username: "<you>", 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.

6
Rakefile Normal file
View file

@ -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

View file

@ -0,0 +1 @@
/* Application styles */

View file

@ -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

24
app/components/base.rb Normal file
View file

@ -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

View file

@ -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

37
app/components/brand.rb Normal file
View file

@ -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. <b>do not upload any actual personal information here.<b>"
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

View file

@ -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

View file

@ -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

43
app/components/footer.rb Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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='<p>Error loading image</p>';"),
)
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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

27
app/components/window.rb Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,6 @@
module API
module External
class ApplicationController < ActionController::API
end
end
end

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,5 @@
module Backend
class NoAuthController < ApplicationController
skip_before_action :authenticate_user!
end
end

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

View file

@ -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

View file

@ -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. <a href='#{signin_onboarding_path}'>Sign in here</a> 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. <a href='#{signin_onboarding_path}'>Sign in here</a> 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,4 @@
module Webhooks
class ApplicationController < ActionController::API
end
end

View file

@ -0,0 +1,5 @@
@import "../stylesheets/application.scss";
body {
font-family: var(--preferred-font), system-ui;
}

View file

@ -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

View file

@ -0,0 +1,8 @@
@import "../stylesheets/backend.scss";
@import "../stylesheets/os9.css";
@import "../stylesheets/layout.css";
body {
font-family: var(--preferred-font), system-ui;
}

View file

@ -0,0 +1 @@
import "../js/click-to-copy";

View file

@ -0,0 +1,2 @@
import * as ActiveStorage from "@rails/activestorage"
ActiveStorage.start()

View file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View file

@ -0,0 +1,3 @@
import Alpine from 'alpinejs'
window.Alpine = Alpine
Alpine.start()

View file

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

View file

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

View file

@ -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";

View file

@ -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;
}

View file

@ -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;
}
}

View file

@ -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;
}

File diff suppressed because it is too large Load diff

View file

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

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -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;
}
}
}

View file

@ -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;
}

View file

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

View file

@ -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;
}
}
}

View file

@ -0,0 +1,2 @@
module AddressesHelper
end

View file

@ -0,0 +1,6 @@
module API::V1::ApplicationHelper
def scope(scope, &)
return unless current_scopes.include?(scope)
yield
end
end

View file

@ -0,0 +1,2 @@
module API::V1::IdentitiesHelper
end

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,2 @@
module Backend::AuditLogsHelper
end

View file

@ -0,0 +1,2 @@
module Backend::IdentitiesHelper
end

View file

@ -0,0 +1,2 @@
module Backend::SessionsHelper
end

View file

@ -0,0 +1,2 @@
module Backend::UsersHelper
end

View file

@ -0,0 +1,2 @@
module CredentialsHelper
end

View file

@ -0,0 +1,2 @@
module OnboardingHelper
end

View file

@ -0,0 +1,2 @@
module StaticPagesHelper
end

Some files were not shown because too many files have changed in this diff Show more