diff --git a/.gitignore b/.gitignore index 5bb633b..ea3b43e 100644 --- a/.gitignore +++ b/.gitignore @@ -40,9 +40,6 @@ # Ignore Playwright MCP data /.playwright-mcp/ -# Documentation site lives in separate repo (checkend-site) -/site/ - # Ignore Cursor & Claude IDE data /.cursor/ /.claude/ @@ -57,7 +54,4 @@ TODO.md API-KEYS.md INGESTION-API.md -# Ignore asdf version manager config -.tool-versions - .DS_Store diff --git a/.ruby-version b/.ruby-version index 2f9dd5f..5497891 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -ruby-4.0.0 +ruby-3.4.5 diff --git a/CHANGELOG.md b/CHANGELOG.md index 43f692e..43972b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,31 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Added - -#### Profile & Settings -- Profile settings page with card-based layout at `/settings/profile` -- Admin badge display for site administrators -- Avatar with user initials in sidebar navigation - -#### Password Security -- Password history tracking (prevents reuse of last 5 passwords) -- Real-time current password verification with debounce -- Client-side validation for new password (minimum 8 characters) -- Password confirmation matching validation -- Submit button disabled until all validations pass - -#### Session Management -- View all active sessions with device and browser detection -- Revoke individual sessions -- "Revoke All Other" to terminate all sessions except current -- Session details including IP address and last activity - -### Changed - -- Password change form now opens as slide-over drawer -- Updated checkboxes across app to use design system pattern with SVG checkmark - ## [1.0.0] - 2024-12-28 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index a903761..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,407 +0,0 @@ -# Contributing to Checkend - -Thank you for your interest in contributing to Checkend! This guide will help you get started with development and submit high-quality contributions. - -## Table of Contents - -- [Code of Conduct](#code-of-conduct) -- [Getting Started](#getting-started) - - [Prerequisites](#prerequisites) - - [Development Setup](#development-setup) - - [Database Configuration](#database-configuration) - - [Environment Variables](#environment-variables) -- [Development Workflow](#development-workflow) - - [Running the Application](#running-the-application) - - [Running Tests](#running-tests) - - [Code Style](#code-style) - - [Security Scanning](#security-scanning) -- [Architecture Overview](#architecture-overview) - - [Domain Models](#domain-models) - - [API Structure](#api-structure) - - [Key Technologies](#key-technologies) -- [Making Changes](#making-changes) - - [Branch Naming](#branch-naming) - - [Commit Messages](#commit-messages) - - [Pull Request Process](#pull-request-process) -- [Reporting Issues](#reporting-issues) -- [Feature Requests](#feature-requests) -- [Adding New Features](#adding-new-features) - ---- - -## Code of Conduct - -By participating in this project, you agree to maintain a respectful and inclusive environment. Be kind, constructive, and professional in all interactions. - ---- - -## Getting Started - -### Prerequisites - -| Requirement | Version | Notes | -|-------------|---------|-------| -| Ruby | 3.3+ | We recommend using [asdf](https://asdf-vm.com/) or [rbenv](https://github.com/rbenv/rbenv) | -| PostgreSQL | 14+ | Required for all databases | -| Git | 2.0+ | For version control | - -**Not required:** -- Node.js - We use Importmap for JavaScript (no bundler needed) -- Redis - We use Solid Queue/Cache/Cable (database-backed) - -### Development Setup - -```bash -# 1. Fork and clone the repository -git clone https://github.com/YOUR_USERNAME/checkend.git -cd checkend - -# 2. Install Ruby dependencies -bundle install - -# 3. Generate credentials (required for encryption) -bin/rails credentials:edit - -# Add the following to your credentials file: -# secret_key_base: (run `bin/rails secret` to generate) -# active_record_encryption: -# primary_key: (run `bin/rails db:encryption:init` to generate all three) -# deterministic_key: -# key_derivation_salt: - -# 4. Setup database -bin/rails db:prepare - -# 5. Run the test suite to verify setup -bin/rails test - -# 6. Start the development server -bin/dev -``` - -Visit `http://localhost:3000` to complete the onboarding wizard. - -### Database Configuration - -Checkend uses a multi-database setup with PostgreSQL: - -| Database | Purpose | Development Name | -|----------|---------|------------------| -| Primary | Main application data | `checkend_development` | -| Cache | Solid Cache storage | `checkend_development_cache` | -| Queue | Solid Queue jobs | `checkend_development_queue` | -| Cable | Action Cable messages | `checkend_development_cable` | - -All databases are created automatically by `bin/rails db:prepare`. - -### Environment Variables - -For development, credentials are managed via Rails encrypted credentials. For CI/testing, you can use environment variables: - -```bash -# Required for Active Record Encryption (CI only) -ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY= -ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY= -ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT= - -# Database (optional, defaults to socket connection) -DATABASE_URL=postgres://localhost/checkend_development -``` - ---- - -## Development Workflow - -### Running the Application - -```bash -# Start Rails server + Tailwind CSS watcher -bin/dev - -# Or run components separately: -bin/rails server # Rails only -bin/rails tailwindcss:watch # Tailwind watcher -``` - -The app runs at `http://localhost:3000`. - -### Running Tests - -```bash -# Run all tests -bin/rails test - -# Run a specific test file -bin/rails test test/models/user_test.rb - -# Run a specific test by line number -bin/rails test test/models/user_test.rb:42 - -# Run system tests (requires browser) -bin/rails test:system - -# Run tests with verbose output -bin/rails test -v -``` - -**Test Coverage Goals:** -- All models should have tests -- All controllers should have tests -- Critical user flows should have system tests - -### Code Style - -We use [Rubocop](https://rubocop.org/) with the `rubocop-rails-omakase` configuration (Rails default style). - -```bash -# Check for violations -bin/rubocop - -# Auto-fix safe violations -bin/rubocop -a - -# Auto-fix all violations (including unsafe) -bin/rubocop -A - -# Check specific files -bin/rubocop app/models/user.rb -``` - -**Before submitting a PR, ensure:** -```bash -bin/rubocop # No violations -``` - -### Security Scanning - -Run these before submitting PRs: - -```bash -# Static analysis for Rails security vulnerabilities -bin/brakeman --no-pager - -# Check for known vulnerabilities in gems -bin/bundler-audit - -# Check JavaScript dependencies -bin/importmap audit -``` - -All three should report no warnings or vulnerabilities. - ---- - -## Architecture Overview - -### Domain Models - -``` -User -├── owns Teams -├── belongs to Teams (through TeamMembers) -└── has Sessions, PasswordHistories, NotificationPreferences - -Team -├── has TeamMembers (Users with roles: admin, member) -├── has TeamInvitations -└── has TeamAssignments → Apps - -App (client application) -├── has ingestion_key (for API auth) -├── has notification settings (Slack, Discord, webhook, GitHub) -└── has Problems - -Problem (grouped error) -├── has fingerprint (unique per app) -├── has status (unresolved/resolved) -├── has Tags (many-to-many) -└── has Notices - -Notice (individual error occurrence) -├── has context, request, user_info (JSONB) -└── belongs to Backtrace - -Backtrace (deduplicated) -├── has fingerprint (hash of lines) -└── has lines (JSONB array) -``` - -### API Structure - -``` -/ingest/v1/errors # Error ingestion (SDK use) - - POST: Report an error - -/api/v1/ # Application API (API key auth) - ├── health # Health check - ├── apps # App management - │ └── :app_id/problems # Problem management - │ └── :problem_id/notices - │ └── :problem_id/tags - ├── teams # Team management - │ └── :team_id/members - └── users # User management (admin only) -``` - -### Key Technologies - -| Component | Technology | Purpose | -|-----------|------------|---------| -| Framework | Rails 8.1 | Web application | -| Database | PostgreSQL | Primary data store | -| CSS | Tailwind CSS | Styling (via tailwindcss-rails) | -| JavaScript | Hotwire (Turbo + Stimulus) | Frontend interactivity | -| JS Bundling | Importmap | ESM modules (no Node.js) | -| Background Jobs | Solid Queue | Database-backed job queue | -| Caching | Solid Cache | Database-backed cache | -| WebSockets | Solid Cable | Database-backed Action Cable | -| Notifications | Noticed gem | Multi-channel notifications | -| Deployment | Kamal | Docker-based deployment | - ---- - -## Making Changes - -### Branch Naming - -Use descriptive branch names with a prefix: - -``` -feature/add-slack-notifications -fix/problem-grouping-bug -docs/update-api-reference -refactor/extract-fingerprint-service -test/add-session-model-tests -``` - -### Commit Messages - -Write clear, concise commit messages: - -``` -Add Slack notification support for new problems - -- Integrate Slack webhook API -- Add slack_webhook_url to App model -- Create SlackNotifier class -- Add tests for notification delivery -``` - -**Guidelines:** -- Use present tense ("Add feature" not "Added feature") -- First line: 50 characters or less, summarize the change -- Body: Explain what and why (not how) -- Reference issues: "Fixes #123" or "Closes #456" - -### Pull Request Process - -1. **Create a feature branch** from `main` - ```bash - git checkout main - git pull origin main - git checkout -b feature/your-feature - ``` - -2. **Make your changes** with tests - -3. **Ensure all checks pass** - ```bash - bin/rails test # All tests pass - bin/rubocop # No linting errors - bin/brakeman --no-pager # No security warnings - bin/bundler-audit # No gem vulnerabilities - ``` - -4. **Push and create a PR** - ```bash - git push origin feature/your-feature - ``` - -5. **PR Description** should include: - - Summary of changes - - Why the change is needed - - How to test it - - Screenshots (for UI changes) - -6. **Address review feedback** promptly - -7. **Squash and merge** once approved - ---- - -## Reporting Issues - -When reporting bugs, please include: - -1. **Environment** - Ruby version, Rails version, OS -2. **Steps to reproduce** - Minimal steps to trigger the bug -3. **Expected behavior** - What should happen -4. **Actual behavior** - What actually happens -5. **Error messages** - Full stack traces if available -6. **Screenshots** - For UI issues - -Use the GitHub issue template when available. - ---- - -## Feature Requests - -We welcome feature requests! Please: - -1. **Search existing issues** to avoid duplicates -2. **Describe the problem** you're trying to solve -3. **Propose a solution** if you have one in mind -4. **Consider alternatives** you've thought about - -Tag your issue with `enhancement`. - ---- - -## Adding New Features - -### Before You Start - -1. **Check the roadmap** - See [ROADMAP.md](ROADMAP.md) for planned features -2. **Open an issue** - Discuss your idea before implementing -3. **Get feedback** - Ensure the feature aligns with project goals - -### Implementation Checklist - -- [ ] Add model/migration if needed -- [ ] Add controller actions -- [ ] Add views (follow existing Tailwind patterns) -- [ ] Add model tests -- [ ] Add controller tests -- [ ] Add system tests for user-facing features -- [ ] Update API documentation if applicable -- [ ] Run full test suite -- [ ] Run security scans - -### Adding a New Notification Channel - -Checkend uses the [Noticed gem](https://github.com/excid3/noticed) for notifications. To add a new channel: - -1. Add delivery method to `app/notifiers/new_problem_notifier.rb` -2. Add configuration fields to the `App` model -3. Add UI for configuration in app settings -4. Add tests for the new delivery method - -### Adding a New SDK - -See [ROADMAP.md](ROADMAP.md) for SDK guidelines. Each SDK should: - -1. Live in its own repository (`checkend-{language}`) -2. Follow the language's conventions -3. Support the full ingestion API -4. Include framework integrations where applicable -5. Have comprehensive documentation - ---- - -## Questions? - -- Open a [GitHub Discussion](https://github.com/furvur/checkend/discussions) for questions -- Check existing issues and PRs for similar topics -- Review [ROADMAP.md](ROADMAP.md) for project direction - -Thank you for contributing to Checkend! diff --git a/Dockerfile b/Dockerfile index 91731cd..ef456f7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ # For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html # Make sure RUBY_VERSION matches the Ruby version in .ruby-version -ARG RUBY_VERSION=4.0.0 +ARG RUBY_VERSION=3.4.5 FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base # Rails app lives here diff --git a/Gemfile b/Gemfile index 71aaecc..6938030 100644 --- a/Gemfile +++ b/Gemfile @@ -76,7 +76,7 @@ group :test do gem 'capybara' gem 'selenium-webdriver' # Pin minitest to 5.x for Rails 8.1 compatibility - gem 'minitest', '~> 5.25' + gem 'minitest', '~> 6.0' # HTTP stubbing for tests gem 'webmock' end diff --git a/Gemfile.lock b/Gemfile.lock index 5592e53..0b1ffd7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ GEM remote: https://rubygems.org/ specs: - action_text-trix (2.1.16) + action_text-trix (2.1.15) railties actioncable (8.1.1) actionpack (= 8.1.1) @@ -79,7 +79,7 @@ GEM public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) base64 (0.3.0) - bcrypt (3.1.21) + bcrypt (3.1.20) bigdecimal (4.0.1) bindex (0.8.1) bootsnap (1.20.1) @@ -115,13 +115,13 @@ GEM erubi (1.13.1) et-orbi (1.4.0) tzinfo - ffi (1.17.3-aarch64-linux-gnu) - ffi (1.17.3-aarch64-linux-musl) - ffi (1.17.3-arm-linux-gnu) - ffi (1.17.3-arm-linux-musl) - ffi (1.17.3-arm64-darwin) - ffi (1.17.3-x86_64-linux-gnu) - ffi (1.17.3-x86_64-linux-musl) + 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-linux-gnu) + ffi (1.17.2-x86_64-linux-musl) friendly_id (5.6.0) activerecord (>= 4.0.0) fugit (1.12.1) @@ -132,7 +132,7 @@ GEM groupdate (6.7.0) activesupport (>= 7.1) hashdiff (1.2.1) - i18n (1.14.8) + i18n (1.14.7) concurrent-ruby (~> 1.0) image_processing (1.14.0) mini_magick (>= 4.9.5, < 6) @@ -167,7 +167,8 @@ GEM mini_magick (5.3.1) logger mini_mime (1.1.5) - minitest (5.27.0) + minitest (6.0.1) + prism (~> 1.5) msgpack (1.8.0) net-imap (0.6.2) date @@ -179,19 +180,19 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.5) - nokogiri (1.19.0-aarch64-linux-gnu) + nokogiri (1.18.10-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.0-aarch64-linux-musl) + nokogiri (1.18.10-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.19.0-arm-linux-gnu) + nokogiri (1.18.10-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.19.0-arm-linux-musl) + nokogiri (1.18.10-arm-linux-musl) racc (~> 1.4) - nokogiri (1.19.0-arm64-darwin) + nokogiri (1.18.10-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.0-x86_64-linux-gnu) + nokogiri (1.18.10-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.0-x86_64-linux-musl) + nokogiri (1.18.10-x86_64-linux-musl) racc (~> 1.4) noticed (2.9.3) rails (>= 6.1.0) @@ -200,12 +201,12 @@ GEM parser (3.3.10.0) ast (~> 2.4.1) racc - pg (1.6.3) - pg (1.6.3-aarch64-linux) - pg (1.6.3-aarch64-linux-musl) - pg (1.6.3-arm64-darwin) - pg (1.6.3-x86_64-linux) - pg (1.6.3-x86_64-linux-musl) + pg (1.6.2) + pg (1.6.2-aarch64-linux) + pg (1.6.2-aarch64-linux-musl) + pg (1.6.2-arm64-darwin) + pg (1.6.2-x86_64-linux) + pg (1.6.2-x86_64-linux-musl) pp (0.6.3) prettyprint prettyprint (0.2.0) @@ -262,7 +263,7 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.3.1) - rdoc (7.0.3) + rdoc (7.0.1) erb psych (>= 4.0.0) tsort @@ -270,7 +271,7 @@ GEM reline (0.6.3) io-console (~> 0.5) rexml (3.4.4) - rubocop (1.82.1) + rubocop (1.82.0) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -281,9 +282,9 @@ GEM rubocop-ast (>= 1.48.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.49.0) + rubocop-ast (1.48.0) parser (>= 3.3.7.2) - prism (~> 1.7) + prism (~> 1.4) rubocop-performance (1.26.1) lint_roller (~> 1.1) rubocop (>= 1.75.0, < 2.0) @@ -380,7 +381,6 @@ PLATFORMS arm-linux-gnu arm-linux-musl arm64-darwin-24 - arm64-darwin-25 x86_64-linux x86_64-linux-gnu x86_64-linux-musl @@ -398,7 +398,7 @@ DEPENDENCIES image_processing (~> 1.2) importmap-rails jbuilder - minitest (~> 5.25) + minitest (~> 6.0) noticed (~> 2.0) pagy (~> 9.3) pg (~> 1.1) @@ -419,7 +419,7 @@ DEPENDENCIES webmock CHECKSUMS - action_text-trix (2.1.16) sha256=f645a2c21821b8449fd1d6770708f4031c91a2eedf9ef476e9be93c64e703a8a + action_text-trix (2.1.15) sha256=4bf9bbd8fa95954de3f0022dae0d927bce22c1bb31d5dc9c3766f8c145c109c1 actioncable (8.1.1) sha256=7262307e9693f09b299e281590110ce4b6ba7e4e4cee6da4b9d987eaf56f9139 actionmailbox (8.1.1) sha256=aa99703a9b2fa32c5a4a93bb21fef79e2935d8db4d1fd5ef0772847be5d43205 actionmailer (8.1.1) sha256=45755d7d4561363490ae82b17a5919bdef4dfe3bb400831819947c3a1d82afdf @@ -434,7 +434,7 @@ CHECKSUMS addressable (2.8.8) sha256=7c13b8f9536cf6364c03b9d417c19986019e28f7c00ac8132da4eb0fe393b057 ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b - bcrypt (3.1.21) sha256=5964613d750a42c7ee5dc61f7b9336fb6caca429ba4ac9f2011609946e4a2dcf + bcrypt (3.1.20) sha256=8410f8c7b3ed54a3c00cd2456bf13917d695117f033218e2483b2e40b0784099 bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7 bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e bootsnap (1.20.1) sha256=7ad62cda65c5157bcca0acfcc0ee11fcbb83d7d7a8a72d52ccd85e6ffc130b93 @@ -453,19 +453,19 @@ CHECKSUMS erb (6.0.1) sha256=28ecdd99c5472aebd5674d6061e3c6b0a45c049578b071e5a52c2a7f13c197e5 erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 et-orbi (1.4.0) sha256=6c7e3c90779821f9e3b324c5e96fda9767f72995d6ae435b96678a4f3e2de8bc - ffi (1.17.3-aarch64-linux-gnu) sha256=28ad573df26560f0aedd8a90c3371279a0b2bd0b4e834b16a2baa10bd7a97068 - ffi (1.17.3-aarch64-linux-musl) sha256=020b33b76775b1abacc3b7d86b287cef3251f66d747092deec592c7f5df764b2 - ffi (1.17.3-arm-linux-gnu) sha256=5bd4cea83b68b5ec0037f99c57d5ce2dd5aa438f35decc5ef68a7d085c785668 - ffi (1.17.3-arm-linux-musl) sha256=0d7626bb96265f9af78afa33e267d71cfef9d9a8eb8f5525344f8da6c7d76053 - ffi (1.17.3-arm64-darwin) sha256=0c690555d4cee17a7f07c04d59df39b2fba74ec440b19da1f685c6579bb0717f - ffi (1.17.3-x86_64-linux-gnu) sha256=3746b01f677aae7b16dc1acb7cb3cc17b3e35bdae7676a3f568153fb0e2c887f - ffi (1.17.3-x86_64-linux-musl) sha256=086b221c3a68320b7564066f46fed23449a44f7a1935f1fe5a245bd89d9aea56 + ffi (1.17.2-aarch64-linux-gnu) sha256=c910bd3cae70b76690418cce4572b7f6c208d271f323d692a067d59116211a1a + ffi (1.17.2-aarch64-linux-musl) sha256=69e6556b091d45df83e6c3b19d3c54177c206910965155a6ec98de5e893c7b7c + ffi (1.17.2-arm-linux-gnu) sha256=d4a438f2b40224ae42ec72f293b3ebe0ba2159f7d1bd47f8417e6af2f68dbaa5 + ffi (1.17.2-arm-linux-musl) sha256=977dfb7f3a6381206dbda9bc441d9e1f9366bf189a634559c3b7c182c497aaa3 + ffi (1.17.2-arm64-darwin) sha256=54dd9789be1d30157782b8de42d8f887a3c3c345293b57ffb6b45b4d1165f813 + ffi (1.17.2-x86_64-linux-gnu) sha256=05d2026fc9dbb7cfd21a5934559f16293815b7ce0314846fee2ac8efbdb823ea + ffi (1.17.2-x86_64-linux-musl) sha256=97c0eb3981414309285a64dc4d466bd149e981c279a56371ef811395d68cb95c friendly_id (5.6.0) sha256=28e221cd53fbd21586321164c1c6fd0c9ba8dde13969cb2363679f44726bb0c3 fugit (1.12.1) sha256=5898f478ede9b415f0804e42b8f3fd53f814bd85eebffceebdbc34e1107aaf68 globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11 groupdate (6.7.0) sha256=beaa8d5bf3856814681914a1d4a20e77436a2214b85d0017dc2ea5c355fb6777 hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1 - i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 + i18n (1.14.7) sha256=ceba573f8138ff2c0915427f1fc5bdf4aa3ab8ae88c8ce255eb3ecf0a11a5d0f image_processing (1.14.0) sha256=754cc169c9c262980889bec6bfd325ed1dafad34f85242b5a07b60af004742fb importmap-rails (2.2.2) sha256=729f5b1092f832780829ade1d0b46c7e53d91c556f06da7254da2977e93fe614 io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc @@ -481,30 +481,30 @@ CHECKSUMS matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b mini_magick (5.3.1) sha256=29395dfd76badcabb6403ee5aff6f681e867074f8f28ce08d78661e9e4a351c4 mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef - minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5 + minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732 net-imap (0.6.2) sha256=08caacad486853c61676cca0c0c47df93db02abc4a8239a8b67eb0981428acc6 net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 - nokogiri (1.19.0-aarch64-linux-gnu) sha256=11a97ecc3c0e7e5edcf395720b10860ef493b768f6aa80c539573530bc933767 - nokogiri (1.19.0-aarch64-linux-musl) sha256=eb70507f5e01bc23dad9b8dbec2b36ad0e61d227b42d292835020ff754fb7ba9 - nokogiri (1.19.0-arm-linux-gnu) sha256=572a259026b2c8b7c161fdb6469fa2d0edd2b61cd599db4bbda93289abefbfe5 - nokogiri (1.19.0-arm-linux-musl) sha256=23ed90922f1a38aed555d3de4d058e90850c731c5b756d191b3dc8055948e73c - nokogiri (1.19.0-arm64-darwin) sha256=0811dfd936d5f6dd3f6d32ef790568bf29b2b7bead9ba68866847b33c9cf5810 - nokogiri (1.19.0-x86_64-linux-gnu) sha256=f482b95c713d60031d48c44ce14562f8d2ce31e3a9e8dd0ccb131e9e5a68b58c - nokogiri (1.19.0-x86_64-linux-musl) sha256=1c4ca6b381622420073ce6043443af1d321e8ed93cc18b08e2666e5bd02ffae4 + nokogiri (1.18.10-aarch64-linux-gnu) sha256=7fb87235d729c74a2be635376d82b1d459230cc17c50300f8e4fcaabc6195344 + nokogiri (1.18.10-aarch64-linux-musl) sha256=7e74e58314297cc8a8f1b533f7212d1999dbe2639a9ee6d97b483ea2acc18944 + nokogiri (1.18.10-arm-linux-gnu) sha256=51f4f25ab5d5ba1012d6b16aad96b840a10b067b93f35af6a55a2c104a7ee322 + nokogiri (1.18.10-arm-linux-musl) sha256=1c6ea754e51cecc85c30ee8ab1e6aa4ce6b6e134d01717e9290e79374a9e00aa + nokogiri (1.18.10-arm64-darwin) sha256=c2b0de30770f50b92c9323fa34a4e1cf5a0af322afcacd239cd66ee1c1b22c85 + nokogiri (1.18.10-x86_64-linux-gnu) sha256=ff5ba26ba2dbce5c04b9ea200777fd225061d7a3930548806f31db907e500f72 + nokogiri (1.18.10-x86_64-linux-musl) sha256=0651fccf8c2ebbc2475c8b1dfd7ccac3a0a6d09f8a41b72db8c21808cb483385 noticed (2.9.3) sha256=9809d1100edc3ee208fbd88f4fe025a13e3b02e68c865bf705d197239ebcf3db pagy (9.4.0) sha256=db3f2e043f684155f18f78be62a81e8d033e39b9f97b1e1a8d12ad38d7bce738 parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 parser (3.3.10.0) sha256=ce3587fa5cc55a88c4ba5b2b37621b3329aadf5728f9eafa36bbd121462aabd6 - pg (1.6.3) sha256=1388d0563e13d2758c1089e35e973a3249e955c659592d10e5b77c468f628a99 - pg (1.6.3-aarch64-linux) sha256=0698ad563e02383c27510b76bf7d4cd2de19cd1d16a5013f375dd473e4be72ea - pg (1.6.3-aarch64-linux-musl) sha256=06a75f4ea04b05140146f2a10550b8e0d9f006a79cdaf8b5b130cde40e3ecc2c - pg (1.6.3-arm64-darwin) sha256=7240330b572e6355d7c75a7de535edb5dfcbd6295d9c7777df4d9dddfb8c0e5f - pg (1.6.3-x86_64-linux) sha256=5d9e188c8f7a0295d162b7b88a768d8452a899977d44f3274d1946d67920ae8d - pg (1.6.3-x86_64-linux-musl) sha256=9c9c90d98c72f78eb04c0f55e9618fe55d1512128e411035fe229ff427864009 + pg (1.6.2) sha256=58614afd405cc9c2c9e15bffe8432e0d6cfc58b722344ad4a47c73a85189c875 + pg (1.6.2-aarch64-linux) sha256=0503c6be5b0ca5ca3aaf91f2ed638f90843313cb81e8e7d7b60ad4bb62c3d131 + pg (1.6.2-aarch64-linux-musl) sha256=c4402447c56279bea80472770522e95c8a2ff49b7f3e534d0cdb01eb27fd6eb8 + pg (1.6.2-arm64-darwin) sha256=4d44500b28d5193b26674583d199a6484f80f1f2ea9cf54f7d7d06a1b7e316b6 + pg (1.6.2-x86_64-linux) sha256=525f438137f2d1411a1ebcc4208ec35cb526b5a3b285a629355c73208506a8ea + pg (1.6.2-x86_64-linux-musl) sha256=e5c8668ffeaf7a9c3458a3dcb002dffa6d8ee1fca9ae534ffef861d2b15644ca pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 prism (1.7.0) sha256=10062f734bf7985c8424c44fac382ac04a58124ea3d220ec3ba9fe4f2da65103 @@ -524,12 +524,12 @@ CHECKSUMS railties (8.1.1) sha256=fb0c7038b147bea41bf6697fa443ff1c5c47d3bb1eedd9ecf1bceeb90efcb868 rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c - rdoc (7.0.3) sha256=dfe3d0981d19b7bba71d9dbaeb57c9f4e3a7a4103162148a559c4fc687ea81f9 + rdoc (7.0.1) sha256=7ae1540c54eb8174f6549440dd2299276eac51deaa7d80ea7db9ea5b96559f53 regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4 reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 - rubocop (1.82.1) sha256=09f1a6a654a960eda767aebea33e47603080f8e9c9a3f019bf9b94c9cab5e273 - rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd + rubocop (1.82.0) sha256=237b7dc24952d7ec469a9593c7a5283315515e2e7dc24ac91532819c254fc4ec + rubocop-ast (1.48.0) sha256=22df9bbf3f7a6eccde0fad54e68547ae1e2a704bf8719e7c83813a99c05d2e76 rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 rubocop-rails (2.34.2) sha256=10ff246ee48b25ffeabddc5fee86d159d690bb3c7b9105755a9c7508a11d6e22 rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d diff --git a/LICENSE b/LICENSE index b8202e1..4a02b25 100644 --- a/LICENSE +++ b/LICENSE @@ -1,8 +1,32 @@ -Copyright (c) 2025, Simon Chiu. +GNU AFFERO GENERAL PUBLIC LICENSE +Version 3, 19 November 2007 -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +Copyright (c) 2025 Simon Chiu -1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -2. No licensee or downstream recipient may use the Software (including any modified or derivative versions) to directly compete with the original Licensor by offering it to third parties as a hosted, managed, or Software-as-a-Service (SaaS) product or cloud service where the primary value of the service is the functionality of the Software itself. +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +------------------------------------------------------------------------------- + +COMMERCIAL LICENSE + +For organizations that want to use Checkend without the obligations of the +AGPL (for example, to keep proprietary modifications private), a commercial +license is available. + +Contact: checkend@furvur.com + +------------------------------------------------------------------------------- + +The full text of the GNU Affero General Public License v3.0 is available at: +https://www.gnu.org/licenses/agpl-3.0.txt diff --git a/README.md b/README.md index 34feb90..e932ba4 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Checkend [![CI](https://github.com/furvur/checkend/actions/workflows/ci.yml/badge.svg)](https://github.com/furvur/checkend/actions/workflows/ci.yml) -[![License: O'Saasy](https://img.shields.io/badge/License-O'Saasy-blue.svg)](https://osaasy.dev/) +[![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) Self-hosted error monitoring for your applications. Track, group, and resolve errors with a clean web interface. @@ -117,16 +117,14 @@ bin/rails credentials:edit # deterministic_key: # key_derivation_salt: -# Setup database (creates and migrates, but does NOT seed) -bin/rails db:prepare +# Setup database +bin/rails db:setup # Start the server bin/dev ``` -Visit `http://localhost:3000` to complete the onboarding wizard. - -> **Note:** Use `db:prepare` (not `db:setup`) to avoid seeding demo data. For development with sample data, run `bin/rails db:seed` after setup. +Visit `http://localhost:3000` and create your account. ### Quick Start @@ -202,7 +200,7 @@ cd checkend docker compose up -d ``` -The interactive setup can install Docker automatically on Ubuntu/Debian, handles secret generation, and supports both direct SSL (Let's Encrypt) and reverse proxy configurations. +The interactive setup handles secret generation and supports both direct SSL (Let's Encrypt) and reverse proxy configurations. --- diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 32cf4a5..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,73 +0,0 @@ -# Security Policy - -## Supported Versions - -| Version | Supported | -| ------- | ------------------ | -| 1.x | :white_check_mark: | -| < 1.0 | :x: | - -## Reporting a Vulnerability - -We take security vulnerabilities seriously. If you discover a security issue, please report it responsibly. - -### How to Report - -**Do NOT open a public GitHub issue for security vulnerabilities.** - -Instead, please email us at: **security@furvur.com** - -Include the following information: -- Description of the vulnerability -- Steps to reproduce -- Potential impact -- Any suggested fixes (optional) - -### What to Expect - -1. **Acknowledgment**: We will acknowledge receipt within 48 hours -2. **Investigation**: We will investigate and validate the issue within 7 days -3. **Resolution**: We aim to release a fix within 30 days for critical issues -4. **Disclosure**: We will coordinate with you on public disclosure timing - -### Security Best Practices for Self-Hosting - -When deploying Checkend, ensure you: - -1. **Use HTTPS**: Always deploy behind SSL/TLS -2. **Secure credentials**: Use Rails encrypted credentials, never commit secrets -3. **Database security**: Use strong passwords, restrict network access -4. **Keep updated**: Regularly update Rails and gem dependencies -5. **Run security scans**: Use `bin/brakeman` and `bin/bundler-audit` regularly - -### Security Scanning - -We use the following tools to maintain security: - -```bash -# Static analysis for Rails vulnerabilities -bin/brakeman --no-pager - -# Check for known gem vulnerabilities -bin/bundler-audit - -# Check JavaScript dependencies -bin/importmap audit -``` - -These checks run automatically in CI on every pull request. - -## Security Features - -Checkend includes several security features: - -- **Password history**: Prevents reuse of last 5 passwords -- **Session management**: View and revoke active sessions -- **API key scoping**: Fine-grained API permissions -- **Encrypted credentials**: Sensitive data encrypted at rest -- **CSRF protection**: Built-in Rails CSRF tokens -- **SQL injection prevention**: ActiveRecord parameterized queries - -## Acknowledgments - -We appreciate security researchers who help keep Checkend secure. Contributors who report valid vulnerabilities will be acknowledged here (with permission). diff --git a/app/constraints/setup_required_constraint.rb b/app/constraints/setup_required_constraint.rb deleted file mode 100644 index fe308ed..0000000 --- a/app/constraints/setup_required_constraint.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -# Route constraint that matches when setup is needed or in progress. -# Setup is considered "complete" when a user has been created AND logged in (has sessions). -# This allows the multi-step wizard to continue even after the admin user is created. -class SetupRequiredConstraint - def self.matches?(request) - # Allow if no users exist (fresh install) - return true if User.count.zero? - - # Allow if users exist but no one has logged in yet (setup in progress) - # The complete step creates a session, so once that happens, setup is done - return true if Session.count.zero? - - # Allow authenticated site admins to view the complete page (read-only) - request.path == '/setup/complete' && authenticated_site_admin?(request) - end - - def self.authenticated_site_admin?(request) - session_id = request.cookie_jar.signed[:session_id] - return false unless session_id - - session = Session.find_by(id: session_id) - session&.user&.site_admin? - end -end diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 9884deb..dc1a326 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -18,12 +18,6 @@ def authenticated? end def require_authentication - # Redirect to setup wizard if no users exist (first boot) - if User.count.zero? && request.format.html? && !request.path.start_with?('/setup') - redirect_to setup_path - return - end - resume_session || request_authentication end diff --git a/app/controllers/settings/passwords_controller.rb b/app/controllers/settings/passwords_controller.rb index cfb0521..17e05e4 100644 --- a/app/controllers/settings/passwords_controller.rb +++ b/app/controllers/settings/passwords_controller.rb @@ -1,5 +1,5 @@ class Settings::PasswordsController < ApplicationController - before_action :set_breadcrumbs, only: [ :edit ] + before_action :set_breadcrumbs def edit end @@ -12,9 +12,9 @@ def update if Current.user.authenticate(params[:current_password]) if Current.user.update(password_params) - redirect_to settings_profile_path, notice: 'Password updated successfully.' + redirect_to edit_settings_password_path, notice: 'Password updated successfully.' else - flash.now[:alert] = Current.user.errors.full_messages.first || 'Password could not be updated.' + flash.now[:alert] = 'Password could not be updated.' render :edit, status: :unprocessable_entity end else @@ -23,14 +23,6 @@ def update end end - def verify - if Current.user.authenticate(params[:current_password]) - render json: { valid: true } - else - render json: { valid: false } - end - end - private def password_params @@ -38,7 +30,7 @@ def password_params end def set_breadcrumbs - add_breadcrumb 'Settings', settings_profile_path - add_breadcrumb 'Change Password' + add_breadcrumb 'Settings', edit_settings_password_path + add_breadcrumb 'Security' end end diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb deleted file mode 100644 index ee60973..0000000 --- a/app/controllers/settings/profiles_controller.rb +++ /dev/null @@ -1,12 +0,0 @@ -class Settings::ProfilesController < ApplicationController - before_action :set_breadcrumbs - - def show - end - - private - - def set_breadcrumbs - add_breadcrumb 'Settings', settings_profile_path - end -end diff --git a/app/controllers/settings/sessions_controller.rb b/app/controllers/settings/sessions_controller.rb deleted file mode 100644 index b30337c..0000000 --- a/app/controllers/settings/sessions_controller.rb +++ /dev/null @@ -1,23 +0,0 @@ -class Settings::SessionsController < ApplicationController - before_action :set_session, only: [ :destroy ] - - def destroy - if @session.current?(Current.session) - redirect_to settings_profile_path, alert: "You can't revoke your current session. Use sign out instead." - else - @session.destroy - redirect_to settings_profile_path, notice: 'Session revoked successfully.' - end - end - - def destroy_all_other - Current.user.sessions.where.not(id: Current.session.id).destroy_all - redirect_to settings_profile_path, notice: 'All other sessions have been revoked.' - end - - private - - def set_session - @session = Current.user.sessions.find(params[:id]) - end -end diff --git a/app/controllers/setup_controller.rb b/app/controllers/setup_controller.rb deleted file mode 100644 index 90d65f4..0000000 --- a/app/controllers/setup_controller.rb +++ /dev/null @@ -1,160 +0,0 @@ -# frozen_string_literal: true - -class SetupController < ApplicationController - layout 'auth' - - # Skip authentication for all setup actions - allow_unauthenticated_access - - # Skip sidebar loading (no user exists yet) - skip_before_action :load_sidebar_apps - - # Ensure setup is still needed (defense in depth beyond route constraint) - before_action :ensure_setup_required - - # Validate session data for steps that require previous steps - before_action :require_admin_in_session, only: %i[team create_team app create_app] - before_action :require_team_in_session, only: %i[app create_app] - before_action :require_setup_in_progress_or_admin, only: %i[complete] - - # Step 1: Create admin account - def index - @user = User.new - end - - def create_admin - @user = User.new(admin_params) - @user.site_admin = true - - if @user.save - session[:setup_admin_id] = @user.id - session[:setup_admin_password] = admin_params[:password] - redirect_to setup_team_path - else - render :index, status: :unprocessable_entity - end - end - - # Step 2: Create first team - def team - @team = Team.new - end - - def create_team - admin_user = User.find(session[:setup_admin_id]) - @team = Team.new(team_params) - @team.owner = admin_user - - if @team.save - @team.team_members.create!(user: admin_user, role: 'admin') - session[:setup_team_id] = @team.id - redirect_to setup_app_path - else - render :team, status: :unprocessable_entity - end - end - - # Step 3: Create first app - def app - @app = App.new - end - - def create_app - team = Team.find(session[:setup_team_id]) - @app = App.new(app_params) - - ActiveRecord::Base.transaction do - @app.save! - TeamAssignment.create!(team: team, app: @app) - session[:setup_app_id] = @app.id - end - - redirect_to setup_complete_path - rescue ActiveRecord::RecordInvalid - render :app, status: :unprocessable_entity - end - - # Step 4: Show completion with ingestion key - def complete - if setup_in_progress? - # Fresh setup flow - use session data - @user = User.find(session[:setup_admin_id]) - @team = Team.find(session[:setup_team_id]) - @app = App.find(session[:setup_app_id]) - @password = session[:setup_admin_password] - - # Start session for the admin user - start_new_session_for(@user) - - # Clear setup session data - session.delete(:setup_admin_id) - session.delete(:setup_team_id) - session.delete(:setup_app_id) - session.delete(:setup_admin_password) - else - # Admin viewing after setup - show first app - @user = Current.user - @team = @user.teams.first - @app = @user.accessible_apps.first - @viewing_after_setup = true - end - end - - private - - def ensure_setup_required - # Setup is complete when users exist AND at least one has logged in - # Exception: site admins can view the complete page - return if request.path == '/setup/complete' && authenticated_site_admin? - - redirect_to root_path if User.exists? && Session.exists? - end - - def setup_in_progress? - session[:setup_admin_id].present? && - session[:setup_team_id].present? && - session[:setup_app_id].present? - end - - def require_setup_in_progress_or_admin - return if setup_in_progress? - return if authenticated_site_admin? - - redirect_to setup_path, alert: 'Please complete the setup wizard.' - end - - def authenticated_site_admin? - resume_session - Current.user&.site_admin? - end - - def require_admin_in_session - return if session[:setup_admin_id] && User.exists?(session[:setup_admin_id]) - - redirect_to setup_path, alert: 'Please start from the beginning.' - end - - def require_team_in_session - return if session[:setup_team_id] && Team.exists?(session[:setup_team_id]) - - redirect_to setup_team_path, alert: 'Please create a team first.' - end - - def require_app_in_session - return if session[:setup_app_id] && App.exists?(session[:setup_app_id]) - - redirect_to setup_app_path, alert: 'Please create an app first.' - end - - def admin_params - params.require(:user).permit(:email_address, :password, :password_confirmation) - end - - def team_params - params.require(:team).permit(:name) - end - - def app_params - params.require(:app).permit(:name, :environment) - end -end diff --git a/app/controllers/user_notification_preferences_controller.rb b/app/controllers/user_notification_preferences_controller.rb index 44fdc69..386ca68 100644 --- a/app/controllers/user_notification_preferences_controller.rb +++ b/app/controllers/user_notification_preferences_controller.rb @@ -21,6 +21,7 @@ def update def set_app @app = accessible_apps.find_by!(slug: params[:app_id]) + raise ActiveRecord::RecordNotFound unless @app end def set_preference diff --git a/app/javascript/controllers/password_form_controller.js b/app/javascript/controllers/password_form_controller.js deleted file mode 100644 index 9f03a28..0000000 --- a/app/javascript/controllers/password_form_controller.js +++ /dev/null @@ -1,196 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - static targets = [ - "currentPassword", - "newPassword", - "confirmPassword", - "submitButton", - "currentPasswordIcon", - "newPasswordIcon", - "confirmPasswordIcon" - ] - static values = { - verifyUrl: String, - debounceMs: { type: Number, default: 500 }, - minLength: { type: Number, default: 8 } - } - - connect() { - this.currentPasswordVerified = false - this.checking = false - this.debounceTimer = null - this.updateSubmitButton() - } - - disconnect() { - if (this.debounceTimer) { - clearTimeout(this.debounceTimer) - } - } - - // Current password verification with debounce - checkCurrentPassword() { - const password = this.currentPasswordTarget.value - - if (this.debounceTimer) { - clearTimeout(this.debounceTimer) - } - - if (!password || password.length === 0) { - this.currentPasswordVerified = false - this.checking = false - this.showIcon(this.currentPasswordIconTarget, 'idle') - this.updateSubmitButton() - return - } - - this.checking = true - this.currentPasswordVerified = false - this.showIcon(this.currentPasswordIconTarget, 'checking') - this.updateSubmitButton() - - this.debounceTimer = setTimeout(() => { - this.verifyCurrentPassword(password) - }, this.debounceMsValue) - } - - async verifyCurrentPassword(password) { - try { - const response = await fetch(this.verifyUrlValue, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content - }, - body: JSON.stringify({ current_password: password }) - }) - - const data = await response.json() - - this.checking = false - this.currentPasswordVerified = data.valid - - if (data.valid) { - this.showIcon(this.currentPasswordIconTarget, 'valid') - } else { - this.showIcon(this.currentPasswordIconTarget, 'invalid') - } - } catch (error) { - console.error('Password verification error:', error) - this.checking = false - this.currentPasswordVerified = false - this.showIcon(this.currentPasswordIconTarget, 'error') - } - - this.updateSubmitButton() - } - - // New password validation - validateNewPassword() { - const password = this.newPasswordTarget.value - const confirmation = this.hasConfirmPasswordTarget ? this.confirmPasswordTarget.value : '' - - // Validate length - if (!password || password.length === 0) { - this.showIcon(this.newPasswordIconTarget, 'idle') - } else if (password.length < this.minLengthValue) { - this.showIcon(this.newPasswordIconTarget, 'invalid') - } else { - this.showIcon(this.newPasswordIconTarget, 'valid') - } - - // Also revalidate confirmation if it has content - if (confirmation.length > 0) { - this.validateConfirmPassword() - } - - this.updateSubmitButton() - } - - // Confirm password validation - validateConfirmPassword() { - const password = this.newPasswordTarget.value - const confirmation = this.confirmPasswordTarget.value - - if (!confirmation || confirmation.length === 0) { - this.showIcon(this.confirmPasswordIconTarget, 'idle') - } else if (confirmation !== password) { - this.showIcon(this.confirmPasswordIconTarget, 'invalid') - } else if (password.length >= this.minLengthValue) { - this.showIcon(this.confirmPasswordIconTarget, 'valid') - } else { - // Password matches but is too short - this.showIcon(this.confirmPasswordIconTarget, 'invalid') - } - - this.updateSubmitButton() - } - - // Check if all validations pass - isFormValid() { - if (!this.currentPasswordVerified || this.checking) { - return false - } - - const newPassword = this.newPasswordTarget.value - const confirmation = this.confirmPasswordTarget.value - - if (!newPassword || newPassword.length < this.minLengthValue) { - return false - } - - if (newPassword !== confirmation) { - return false - } - - return true - } - - // Icon display helper - showIcon(target, state) { - if (!target) return - - const icons = { - idle: '', - checking: ` - - - - - `, - valid: ` - - - - `, - invalid: ` - - - - `, - error: ` - - - - ` - } - - target.innerHTML = icons[state] || '' - } - - updateSubmitButton() { - if (this.hasSubmitButtonTarget) { - const shouldDisable = !this.isFormValid() - this.submitButtonTarget.disabled = shouldDisable - - if (shouldDisable) { - this.submitButtonTarget.classList.add('opacity-50', 'cursor-not-allowed') - this.submitButtonTarget.classList.remove('hover:bg-violet-500') - } else { - this.submitButtonTarget.classList.remove('opacity-50', 'cursor-not-allowed') - this.submitButtonTarget.classList.add('hover:bg-violet-500') - } - } - } -} diff --git a/app/models/password_history.rb b/app/models/password_history.rb deleted file mode 100644 index 35039f1..0000000 --- a/app/models/password_history.rb +++ /dev/null @@ -1,3 +0,0 @@ -class PasswordHistory < ApplicationRecord - belongs_to :user -end diff --git a/app/models/session.rb b/app/models/session.rb index e35cc5d..cf376fb 100644 --- a/app/models/session.rb +++ b/app/models/session.rb @@ -1,39 +1,3 @@ class Session < ApplicationRecord belongs_to :user - - def current?(current_session) - id == current_session&.id - end - - def device_name - return 'Unknown device' if user_agent.blank? - - case user_agent - when /iPhone/i then 'iPhone' - when /iPad/i then 'iPad' - when /Android/i then 'Android' - when /Macintosh|Mac OS/i then 'Mac' - when /Windows/i then 'Windows' - when /Linux/i then 'Linux' - else 'Unknown device' - end - end - - def browser_name - return 'Unknown browser' if user_agent.blank? - - # Order matters: Edge/Opera contain "Chrome" in UA, so check them first - case user_agent - when /Edg/i then 'Edge' - when /OPR|Opera/i then 'Opera' - when /Firefox/i then 'Firefox' - when /Chrome|CriOS/i then 'Chrome' - when /Safari/i then 'Safari' - else 'Unknown browser' - end - end - - def device_description - "#{device_name} • #{browser_name}" - end end diff --git a/app/models/user.rb b/app/models/user.rb index dbdb995..7cd0cc8 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,9 +1,6 @@ class User < ApplicationRecord - PASSWORD_HISTORY_LIMIT = 5 - has_secure_password has_many :sessions, dependent: :destroy - has_many :password_histories, dependent: :destroy has_many :notifications, as: :recipient, dependent: :destroy, class_name: 'Noticed::Notification' has_many :team_members, dependent: :destroy has_many :teams, through: :team_members @@ -14,9 +11,6 @@ class User < ApplicationRecord normalizes :email_address, with: ->(e) { e.strip.downcase } validates :email_address, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } - validate :password_not_recently_used, if: :password_digest_changed? - - before_update :save_password_to_history, if: :password_digest_changed? scope :site_admins, -> { where(site_admin: true) } @@ -53,37 +47,4 @@ def as_json(options = {}) 'updated_at' => updated_at&.iso8601 ).except('password_digest') end - - def password_previously_used?(new_password) - password_histories.order(created_at: :desc).limit(PASSWORD_HISTORY_LIMIT).any? do |history| - BCrypt::Password.new(history.password_digest).is_password?(new_password) - end - end - - private - - def password_not_recently_used - return unless password.present? - - # Check against current password (before the change) - if password_digest_was.present? && BCrypt::Password.new(password_digest_was).is_password?(password) - errors.add(:password, 'has been used recently. Please choose a different password.') - return - end - - # Check against password history - if password_previously_used?(password) - errors.add(:password, 'has been used recently. Please choose a different password.') - end - end - - def save_password_to_history - return unless password_digest_was.present? - - password_histories.create!(password_digest: password_digest_was) - - # Keep only the last N passwords - old_histories = password_histories.order(created_at: :desc).offset(PASSWORD_HISTORY_LIMIT) - old_histories.destroy_all if old_histories.exists? - end end diff --git a/app/views/api_keys/new.html.erb b/app/views/api_keys/new.html.erb index 489d178..fb3608d 100644 --- a/app/views/api_keys/new.html.erb +++ b/app/views/api_keys/new.html.erb @@ -28,24 +28,14 @@

<%= resource_name %>

-
diff --git a/app/views/apps/_form.html.erb b/app/views/apps/_form.html.erb index 7b342a6..032a291 100644 --- a/app/views/apps/_form.html.erb +++ b/app/views/apps/_form.html.erb @@ -35,35 +35,21 @@

Email Notifications

-
-
-
- <%= form.check_box :notify_on_new_problem, - class: "col-start-1 row-start-1 appearance-none rounded border border-gray-300 dark:border-zinc-600 bg-white dark:bg-zinc-700 checked:border-violet-500 checked:bg-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 focus:ring-offset-white dark:focus:ring-offset-zinc-900" %> - - - -
-
-
- <%= form.label :notify_on_new_problem, "New errors", class: "font-medium text-gray-700 dark:text-zinc-100" %> -

Get notified when a new error type is detected.

+
+ <%= form.check_box :notify_on_new_problem, + class: "h-4 w-4 rounded border-gray-300 dark:border-zinc-600 text-violet-600 focus:ring-violet-500 dark:bg-zinc-900 dark:checked:bg-violet-600 mt-0.5" %> +
+ <%= form.label :notify_on_new_problem, "New errors", class: "text-sm font-medium text-gray-700 dark:text-zinc-300" %> +

Get notified when a new error type is detected.

-
-
-
- <%= form.check_box :notify_on_reoccurrence, - class: "col-start-1 row-start-1 appearance-none rounded border border-gray-300 dark:border-zinc-600 bg-white dark:bg-zinc-700 checked:border-violet-500 checked:bg-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 focus:ring-offset-white dark:focus:ring-offset-zinc-900" %> - - - -
-
-
- <%= form.label :notify_on_reoccurrence, "Reoccurring errors", class: "font-medium text-gray-700 dark:text-zinc-100" %> -

Get notified when a resolved error occurs again.

+
+ <%= form.check_box :notify_on_reoccurrence, + class: "h-4 w-4 rounded border-gray-300 dark:border-zinc-600 text-violet-600 focus:ring-violet-500 dark:bg-zinc-900 dark:checked:bg-violet-600 mt-0.5" %> +
+ <%= form.label :notify_on_reoccurrence, "Reoccurring errors", class: "text-sm font-medium text-gray-700 dark:text-zinc-300" %> +

Get notified when a resolved error occurs again.

@@ -130,19 +116,12 @@

GitHub Integration

-
-
-
- <%= form.check_box :github_enabled, - class: "col-start-1 row-start-1 appearance-none rounded border border-gray-300 dark:border-zinc-600 bg-white dark:bg-zinc-700 checked:border-violet-500 checked:bg-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 focus:ring-offset-white dark:focus:ring-offset-zinc-900" %> - - - -
-
-
- <%= form.label :github_enabled, "Enable GitHub issue creation", class: "font-medium text-gray-700 dark:text-zinc-100" %> -

Automatically create GitHub issues when errors occur.

+
+ <%= form.check_box :github_enabled, + class: "h-4 w-4 rounded border-gray-300 dark:border-zinc-600 text-violet-600 focus:ring-violet-500 dark:bg-zinc-900 dark:checked:bg-violet-600 mt-0.5" %> +
+ <%= form.label :github_enabled, "Enable GitHub issue creation", class: "text-sm font-medium text-gray-700 dark:text-zinc-300" %> +

Automatically create GitHub issues when errors occur.

diff --git a/app/views/design/candidates.html.erb b/app/views/design/candidates.html.erb index a86d9fe..c66d2ac 100644 --- a/app/views/design/candidates.html.erb +++ b/app/views/design/candidates.html.erb @@ -4176,424 +4176,4 @@
- - -
-

Profile & Settings Page Designs

-

User profile and settings page accessible from the sidebar "Signed in as" section. Should include profile info, security settings, and account management.

- - -
-

Candidate 1: Simple Single Column

-

Classic stacked sections with clear visual hierarchy. Clean and straightforward.

- -
-
- -
-

Profile & Settings

-

Manage your account settings and preferences.

-
- - -
-

Profile

-
- - SK - -
-

skhchiu@gmail.com

-

Member since January 2025

-
-
-
-
- - -
-
-
- - -
-

Security

-
-
- - -
-
- - -
-
- - -
- -
-
- - -
-

Active Sessions

-
-
-
- - - -
-

macOS • Chrome

-

Current session • San Francisco, CA

-
-
- Active -
-
-
- - - -
-

iOS • Safari

-

Last active 2 days ago • New York, NY

-
-
- -
-
-
- - -
-

Danger Zone

-

Once you delete your account, there is no going back. Please be certain.

- -
-
-
- -
- Pros: Simple, familiar pattern, easy to scan, works well on mobile -
- Cons: Long page with lots of scrolling, sections feel disconnected -
-
- - -
-

Candidate 2: Two-Column with Sticky Sidebar

-

Profile summary on left with settings navigation, main content on right.

- -
-
-
- -
-
- -
- - SK - -

skhchiu@gmail.com

-

Member since Jan 2025

-
- - - -
-
- - -
-
-

Profile Settings

- -
-
- - -

This is the email you use to sign in.

-
- -
-

Change Password

-
-
- - -
-
-
- - -
-
- - -
-
-
-
- -
- -
-
-
-
-
-
-
- -
- Pros: Clear navigation, profile always visible, settings feel organized, scalable for more sections -
- Cons: More complex layout, may feel cramped on smaller screens -
-
- - -
-

Candidate 3: Card-Based Grid

-

Settings organized into distinct cards in a grid layout. Each card handles one concern.

- -
-
- -
- - SK - -
-

skhchiu@gmail.com

-

Manage your account settings

-
-
- - -
- -
-
-
- - - -
-
-

Email Address

-

Your account identifier

-
-
-

skhchiu@gmail.com

- -
- - -
-
-
- - - -
-
-

Password

-

Secure your account

-
-
-

Last changed 30 days ago

- -
- - -
-
-
- - - -
-
-

Active Sessions

-

Manage your devices

-
-
-

2 active sessions

- -
- - -
-
-
- - - -
-
-

Two-Factor Auth

-

Extra security layer

-
-
-
- -

Not enabled

-
- -
- - -
-
-
-
- - - -
-
-

Delete Account

-

Permanently remove your account and all data

-
-
- -
-
-
-
-
- -
- Pros: Clean visual separation, easy to scan, each card is focused, modern feel -
- Cons: Requires clicking into each card for actions, less efficient for power users -
-
- - -
-

Candidate 4: Compact All-in-One

-

Everything on one compact page with inline editing. Minimal clicks required.

- -
-
- -
-
- - SK - -
-

Account Settings

-

skhchiu@gmail.com

-
-
- Member since Jan 2025 -
- - -
- -
-
-

Email

-

skhchiu@gmail.com

-
- -
- - -
-
-

Password

-

Last changed 30 days ago

-
- -
- - -
-
-

Active Sessions

-

2 devices logged in

-
- -
- - -
-
-

Two-Factor Authentication

-

Not enabled

-
- -
- - -
-

Change Password

-
- - - -
- - -
-
-
- - -
-
-
-

Delete Account

-

This action cannot be undone

-
- -
-
-
-
-
- -
- Pros: Very compact, minimal scrolling, quick access to all settings, inline editing is efficient -
- Cons: Can feel cramped, inline forms may be confusing, less visual hierarchy -
-
- -
diff --git a/app/views/layouts/auth.html.erb b/app/views/layouts/auth.html.erb index 729917b..1bc2671 100644 --- a/app/views/layouts/auth.html.erb +++ b/app/views/layouts/auth.html.erb @@ -24,7 +24,7 @@ <%= render "shared/flash" %>
-
+
<%= link_to root_path, class: "inline-block text-white hover:text-violet-300 transition-colors" do %> diff --git a/app/views/problems/index.html.erb b/app/views/problems/index.html.erb index 6085201..6b3c566 100644 --- a/app/views/problems/index.html.erb +++ b/app/views/problems/index.html.erb @@ -194,14 +194,9 @@ <%# Bulk action bar %>
-
@@ -276,18 +271,13 @@
<%# Checkbox %>
-
- - - - -
+
<%# Problem info %> diff --git a/app/views/settings/passwords/edit.html.erb b/app/views/settings/passwords/edit.html.erb index e2db53d..0735682 100644 --- a/app/views/settings/passwords/edit.html.erb +++ b/app/views/settings/passwords/edit.html.erb @@ -1,65 +1,41 @@ -<%= turbo_frame_tag "modal" do %> - <%= render layout: "shared/slide_over", locals: { title: "Change Password", subtitle: "Update your password to keep your account secure." } do %> - <%= form_with url: settings_password_path, - method: :patch, - class: "flex flex-col h-full", - data: { - controller: "password-form", - password_form_verify_url_value: verify_settings_password_path - } do |form| %> - <%# Scrollable form content %> -
-
- <%= form.label :current_password, "Current Password", class: "block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-1.5" %> -
- <%= form.password_field :current_password, - required: true, - autocomplete: "current-password", - data: { password_form_target: "currentPassword", action: "input->password-form#checkCurrentPassword" }, - class: "block w-full px-3 py-2 pr-10 bg-white dark:bg-zinc-900 border border-gray-300 dark:border-zinc-700 rounded-lg text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-zinc-500 focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-colors" %> -
-
-
+
+

Security Settings

+

Update your password to keep your account secure.

-
- <%= form.label :password, "New Password", class: "block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-1.5" %> -
- <%= form.password_field :password, - required: true, - autocomplete: "new-password", - minlength: 8, - data: { password_form_target: "newPassword", action: "input->password-form#validateNewPassword" }, - class: "block w-full px-3 py-2 pr-10 bg-white dark:bg-zinc-900 border border-gray-300 dark:border-zinc-700 rounded-lg text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-zinc-500 focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-colors" %> -
-
-

Minimum 8 characters. Cannot reuse your last 5 passwords.

-
+
+

Change Password

-
- <%= form.label :password_confirmation, "Confirm New Password", class: "block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-1.5" %> -
- <%= form.password_field :password_confirmation, - required: true, - autocomplete: "new-password", - data: { password_form_target: "confirmPassword", action: "input->password-form#validateConfirmPassword" }, - class: "block w-full px-3 py-2 pr-10 bg-white dark:bg-zinc-900 border border-gray-300 dark:border-zinc-700 rounded-lg text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-zinc-500 focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-colors" %> -
-
-
+ <%= form_with url: settings_password_path, method: :patch, class: "space-y-5" do |form| %> +
+ <%= form.label :current_password, "Current Password", class: "block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-1.5" %> + <%= form.password_field :current_password, + required: true, + autocomplete: "current-password", + class: "block w-full px-3 py-2 bg-white dark:bg-zinc-900 border border-gray-300 dark:border-zinc-700 rounded-lg text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-zinc-500 focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-colors" %>
- <%# Sticky footer with action buttons %> -
- +
+ <%= form.label :password, "New Password", class: "block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-1.5" %> + <%= form.password_field :password, + required: true, + autocomplete: "new-password", + minlength: 8, + class: "block w-full px-3 py-2 bg-white dark:bg-zinc-900 border border-gray-300 dark:border-zinc-700 rounded-lg text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-zinc-500 focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-colors" %> +

Minimum 8 characters

+
+ +
+ <%= form.label :password_confirmation, "Confirm New Password", class: "block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-1.5" %> + <%= form.password_field :password_confirmation, + required: true, + autocomplete: "new-password", + class: "block w-full px-3 py-2 bg-white dark:bg-zinc-900 border border-gray-300 dark:border-zinc-700 rounded-lg text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-zinc-500 focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-colors" %> +
+ +
<%= form.submit "Update Password", - data: { password_form_target: "submitButton" }, - disabled: true, - class: "px-4 py-2 bg-violet-600 text-white rounded-lg font-medium focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-900 transition-colors cursor-pointer opacity-50 cursor-not-allowed" %> + class: "px-4 py-2 bg-violet-600 text-white rounded-lg font-medium hover:bg-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800 transition-colors" %>
<% end %> - <% end %> -<% end %> +
+
diff --git a/app/views/settings/profiles/show.html.erb b/app/views/settings/profiles/show.html.erb deleted file mode 100644 index 70ed04e..0000000 --- a/app/views/settings/profiles/show.html.erb +++ /dev/null @@ -1,113 +0,0 @@ -<%= turbo_frame_tag "modal" %> - -
- <%# Header with Profile %> -
- <% initials = Current.user.email_address.first(2).upcase %> - - <%= initials %> - -
-
-

<%= Current.user.email_address %>

- <% if Current.user.site_admin? %> - - - - - Admin - - <% end %> -
-

Manage your account settings

-
-
- - <%# Cards Grid %> -
- <%# Email Card %> -
-
-
- - - -
-
-

Email Address

-

Your account identifier

-
-
-

<%= Current.user.email_address %>

-

Contact support to change your email

-
- - <%# Password Card %> - <%= link_to edit_settings_password_path, data: { turbo_frame: "modal" }, class: "block bg-white dark:bg-zinc-800 rounded-lg border border-gray-200 dark:border-zinc-700 p-5 shadow-sm dark:shadow-zinc-700/50 hover:border-violet-300 dark:hover:border-violet-500/50 transition-colors group" do %> -
-
- - - -
-
-

Password

-

Secure your account

-
-
-

Update your password regularly

- Change password → - <% end %> - - <%# Sessions Card - Full Width %> -
-
-
-
- - - -
-
-

Active Sessions

-

Devices where you're currently signed in

-
-
- <% if Current.user.sessions.count > 1 %> - <%= button_to "Revoke All Other", destroy_all_other_settings_sessions_path, method: :delete, data: { turbo_confirm: "Are you sure you want to revoke all other sessions?" }, class: "text-sm text-pink-600 dark:text-pink-400 hover:text-pink-500 dark:hover:text-pink-300 font-medium" %> - <% end %> -
- -
- <% Current.user.sessions.order(created_at: :desc).each do |session| %> -
-
- <% if session.current?(Current.session) %> - - <% else %> - - <% end %> -
-

- <%= session.device_description %> - <% if session.current?(Current.session) %> - (Current) - <% end %> -

-

- <% if session.ip_address.present? %> - <%= session.ip_address %> • - <% end %> - Last active <%= time_ago_in_words(session.updated_at) %> ago -

-
-
- <% unless session.current?(Current.session) %> - <%= button_to "Revoke", settings_session_path(session), method: :delete, data: { turbo_confirm: "Revoke this session?" }, class: "text-sm text-pink-600 dark:text-pink-400 hover:text-pink-500 dark:hover:text-pink-300 font-medium" %> - <% end %> -
- <% end %> -
-
-
-
diff --git a/app/views/settings/smtp/edit.html.erb b/app/views/settings/smtp/edit.html.erb index 4db57c1..1907bc4 100644 --- a/app/views/settings/smtp/edit.html.erb +++ b/app/views/settings/smtp/edit.html.erb @@ -30,20 +30,11 @@
<% end %> -
-
-
- <%= form.check_box :enabled, class: "col-start-1 row-start-1 appearance-none rounded border border-gray-300 dark:border-zinc-600 bg-white dark:bg-zinc-700 checked:border-violet-500 checked:bg-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 focus:ring-offset-white dark:focus:ring-offset-zinc-900" %> - - - -
-
-
- <%= form.label :enabled, "Enable SMTP", class: "font-medium text-gray-700 dark:text-zinc-100" %> -

Enable SMTP to send email notifications.

-
+
+ <%= form.check_box :enabled, class: "h-4 w-4 text-violet-600 focus:ring-violet-500 border-gray-300 dark:border-zinc-600 rounded" %> + <%= form.label :enabled, "Enable SMTP", class: "ml-2 block text-sm font-medium text-gray-700 dark:text-zinc-300" %>
+

Enable SMTP to send email notifications.

<%= form.label :address, "SMTP Server Address", class: "block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-1.5" %> @@ -95,20 +86,11 @@ class: "block w-full px-3 py-2 bg-white dark:bg-zinc-900 border border-gray-300 dark:border-zinc-700 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-colors" %>
-
-
-
- <%= form.check_box :enable_starttls_auto, class: "col-start-1 row-start-1 appearance-none rounded border border-gray-300 dark:border-zinc-600 bg-white dark:bg-zinc-700 checked:border-violet-500 checked:bg-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 focus:ring-offset-white dark:focus:ring-offset-zinc-900" %> - - - -
-
-
- <%= form.label :enable_starttls_auto, "Enable STARTTLS Auto", class: "font-medium text-gray-700 dark:text-zinc-100" %> -

Automatically use STARTTLS when available.

-
+
+ <%= form.check_box :enable_starttls_auto, class: "h-4 w-4 text-violet-600 focus:ring-violet-500 border-gray-300 dark:border-zinc-600 rounded" %> + <%= form.label :enable_starttls_auto, "Enable STARTTLS Auto", class: "ml-2 block text-sm font-medium text-gray-700 dark:text-zinc-300" %>
+

Automatically use STARTTLS when available.

<%= form.submit "Save Configuration", diff --git a/app/views/setup/_step_indicator.html.erb b/app/views/setup/_step_indicator.html.erb deleted file mode 100644 index c2be2bf..0000000 --- a/app/views/setup/_step_indicator.html.erb +++ /dev/null @@ -1,38 +0,0 @@ -<%# Step indicator for setup wizard %> -<% steps = [ - { number: 1, name: 'Account' }, - { number: 2, name: 'Team' }, - { number: 3, name: 'App' }, - { number: 4, name: 'Complete' } -] %> - - diff --git a/app/views/setup/app.html.erb b/app/views/setup/app.html.erb deleted file mode 100644 index 49e26ac..0000000 --- a/app/views/setup/app.html.erb +++ /dev/null @@ -1,49 +0,0 @@ -<%= render 'step_indicator', current_step: 3 %> - -

Create Your First App

-

Apps represent the applications you want to monitor for errors.

- -<%= form_with model: @app, url: setup_create_app_path, class: "space-y-5" do |form| %> -
- <%= form.label :name, "App name", class: "block text-sm font-medium text-zinc-300 mb-1.5" %> -
-
- - - -
- <%= form.text_field :name, - required: true, - autofocus: true, - placeholder: "My Rails App", - class: "block w-full pl-10 pr-3 py-2 bg-zinc-900 border border-zinc-700 rounded text-white placeholder-zinc-500 focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-colors" %> -
- <% if @app.errors[:name].any? %> -

<%= @app.errors[:name].first %>

- <% end %> -
- -
- <%= form.label :environment, "Environment", class: "block text-sm font-medium text-zinc-300 mb-1.5" %> -
- <%= form.select :environment, - options_for_select([['Production', 'production'], ['Staging', 'staging'], ['Development', 'development']], 'production'), - {}, - class: "appearance-none block w-full px-3 py-2 pr-9 bg-zinc-900 border border-zinc-700 rounded text-white focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-colors" %> -
- - - -
-
-

Optional. Helps organize apps by deployment environment.

-
- -
- <%= link_to setup_team_path, class: "text-sm text-zinc-400 hover:text-violet-400 transition-colors" do %> - ← Back - <% end %> - <%= form.submit "Create App", - class: "px-4 py-2 bg-violet-600 text-white rounded font-medium hover:bg-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 focus:ring-offset-zinc-800 transition-colors cursor-pointer" %> -
-<% end %> diff --git a/app/views/setup/complete.html.erb b/app/views/setup/complete.html.erb deleted file mode 100644 index 4c13983..0000000 --- a/app/views/setup/complete.html.erb +++ /dev/null @@ -1,121 +0,0 @@ -<% content_for :container_width, 'max-w-lg' %> - -<% unless @viewing_after_setup %> - <%= render 'step_indicator', current_step: 4 %> -<% end %> - -
-
- - - -
- <% if @viewing_after_setup %> -

Setup Information

-

Reference your ingestion key and setup instructions below.

- <% else %> -

Setup Complete!

-

Your Checkend instance is ready to receive errors.

- <% end %> -
- -
-
-

Your Ingestion Key

-
- <%= @app.ingestion_key %> - -
-

Use this key in your client application's Checkend-Ingestion-Key header.

-
- -
-
-

Send a Test Error

- -
-

Use curl to send a test error:

-
curl -X POST <%= request.base_url %>/ingest/v1/errors \
-  -H "Content-Type: application/json" \
-  -H "Checkend-Ingestion-Key: <%= @app.ingestion_key %>" \
-  -d '{"error":{"class":"TestError","message":"This is a test error","backtrace":["app/test.rb:1"]}}'
-

Official SDKs available for Ruby, JavaScript, Python, Go, PHP, Elixir, Java, and .NET.

-
- -
-

Summary

-
-
-
Admin Account
-
<%= @user.email_address %>
-
- <% if @password.present? %> -
-
Password
-
<%= '*' * [@password.length, 12].min %>
-
- <% end %> -
-
Team
-
<%= @team.name %>
-
-
-
App
-
<%= @app.name %>
-
-
-
-
- -
- <%= link_to "Go to Dashboard", - root_path, - class: "block w-full text-center px-4 py-2 bg-violet-600 text-white rounded font-medium hover:bg-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 focus:ring-offset-zinc-800 transition-colors" %> -
- - diff --git a/app/views/setup/index.html.erb b/app/views/setup/index.html.erb deleted file mode 100644 index cf4efdc..0000000 --- a/app/views/setup/index.html.erb +++ /dev/null @@ -1,69 +0,0 @@ -<%= render 'step_indicator', current_step: 1 %> - -

Welcome to Checkend

-

Let's set up your error monitoring instance. First, create your admin account.

- -<%= form_with model: @user, url: setup_admin_path, class: "space-y-5" do |form| %> -
- <%= form.label :email_address, "Email address", class: "block text-sm font-medium text-zinc-300 mb-1.5" %> -
-
- - - -
- <%= form.email_field :email_address, - required: true, - autofocus: true, - autocomplete: "email", - placeholder: "admin@example.com", - class: "block w-full pl-10 pr-3 py-2 bg-zinc-900 border border-zinc-700 rounded text-white placeholder-zinc-500 focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-colors" %> -
- <% if @user.errors[:email_address].any? %> -

<%= @user.errors[:email_address].first %>

- <% end %> -
- -
- <%= form.label :password, "Password", class: "block text-sm font-medium text-zinc-300 mb-1.5" %> -
-
- - - -
- <%= form.password_field :password, - required: true, - autocomplete: "new-password", - placeholder: "Create a secure password", - class: "block w-full pl-10 pr-3 py-2 bg-zinc-900 border border-zinc-700 rounded text-white placeholder-zinc-500 focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-colors" %> -
- <% if @user.errors[:password].any? %> -

<%= @user.errors[:password].first %>

- <% end %> -
- -
- <%= form.label :password_confirmation, "Confirm password", class: "block text-sm font-medium text-zinc-300 mb-1.5" %> -
-
- - - -
- <%= form.password_field :password_confirmation, - required: true, - autocomplete: "new-password", - placeholder: "Confirm your password", - class: "block w-full pl-10 pr-3 py-2 bg-zinc-900 border border-zinc-700 rounded text-white placeholder-zinc-500 focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-colors" %> -
- <% if @user.errors[:password_confirmation].any? %> -

<%= @user.errors[:password_confirmation].first %>

- <% end %> -
- -
- <%= form.submit "Create Admin Account", - class: "w-full px-4 py-2 bg-violet-600 text-white rounded font-medium hover:bg-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 focus:ring-offset-zinc-800 transition-colors cursor-pointer" %> -
-<% end %> diff --git a/app/views/setup/team.html.erb b/app/views/setup/team.html.erb deleted file mode 100644 index 76cecb6..0000000 --- a/app/views/setup/team.html.erb +++ /dev/null @@ -1,33 +0,0 @@ -<%= render 'step_indicator', current_step: 2 %> - -

Create Your Team

-

Teams help organize access to your apps. You can invite team members later.

- -<%= form_with model: @team, url: setup_create_team_path, class: "space-y-5" do |form| %> -
- <%= form.label :name, "Team name", class: "block text-sm font-medium text-zinc-300 mb-1.5" %> -
-
- - - -
- <%= form.text_field :name, - required: true, - autofocus: true, - placeholder: "Engineering Team", - class: "block w-full pl-10 pr-3 py-2 bg-zinc-900 border border-zinc-700 rounded text-white placeholder-zinc-500 focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-colors" %> -
- <% if @team.errors[:name].any? %> -

<%= @team.errors[:name].first %>

- <% end %> -
- -
- <%= link_to setup_path, class: "text-sm text-zinc-400 hover:text-violet-400 transition-colors" do %> - ← Back - <% end %> - <%= form.submit "Create Team", - class: "px-4 py-2 bg-violet-600 text-white rounded font-medium hover:bg-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 focus:ring-offset-zinc-800 transition-colors cursor-pointer" %> -
-<% end %> diff --git a/app/views/shared/_sidebar.html.erb b/app/views/shared/_sidebar.html.erb index e338840..2b8bac1 100644 --- a/app/views/shared/_sidebar.html.erb +++ b/app/views/shared/_sidebar.html.erb @@ -135,19 +135,18 @@
- <%# User info - clickable link to profile %> - <%= link_to settings_profile_path, class: "group flex items-center gap-3 px-2 py-3 rounded-md hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors" do %> - <% initials = Current.user.email_address.first(2).upcase %> - - <%= initials %> - -
-

Signed in as

-

<%= Current.user.email_address %>

-
- - + <%# User info %> +
+

Signed in as

+

<%= Current.user.email_address %>

+
+ + <%# Security link %> + <%= link_to edit_settings_password_path, class: "group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 hover:text-violet-600 dark:hover:text-violet-400 transition-colors" do %> + + + Security <% end %> <%# Sign out %> diff --git a/app/views/user_notification_preferences/edit.html.erb b/app/views/user_notification_preferences/edit.html.erb index 82c4514..4c47f64 100644 --- a/app/views/user_notification_preferences/edit.html.erb +++ b/app/views/user_notification_preferences/edit.html.erb @@ -7,35 +7,21 @@
<%= form_with model: [@app, @preference], url: app_user_notification_preference_path(@app), method: :patch, class: "space-y-6" do |form| %>
-
-
-
- <%= form.check_box :notify_on_new_problem, - class: "col-start-1 row-start-1 appearance-none rounded border border-gray-300 dark:border-zinc-600 bg-white dark:bg-zinc-700 checked:border-violet-500 checked:bg-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 focus:ring-offset-white dark:focus:ring-offset-zinc-900" %> - - - -
-
-
- <%= form.label :notify_on_new_problem, "New errors", class: "font-medium text-gray-700 dark:text-zinc-100" %> -

Get notified when a new error type is detected.

+
+ <%= form.check_box :notify_on_new_problem, + class: "h-4 w-4 rounded border-gray-300 dark:border-zinc-600 text-violet-600 focus:ring-violet-500 dark:bg-zinc-900 dark:checked:bg-violet-600 mt-0.5" %> +
+ <%= form.label :notify_on_new_problem, "New errors", class: "text-sm font-medium text-gray-700 dark:text-zinc-300" %> +

Get notified when a new error type is detected.

-
-
-
- <%= form.check_box :notify_on_reoccurrence, - class: "col-start-1 row-start-1 appearance-none rounded border border-gray-300 dark:border-zinc-600 bg-white dark:bg-zinc-700 checked:border-violet-500 checked:bg-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 focus:ring-offset-white dark:focus:ring-offset-zinc-900" %> - - - -
-
-
- <%= form.label :notify_on_reoccurrence, "Reoccurring errors", class: "font-medium text-gray-700 dark:text-zinc-100" %> -

Get notified when a resolved error occurs again.

+
+ <%= form.check_box :notify_on_reoccurrence, + class: "h-4 w-4 rounded border-gray-300 dark:border-zinc-600 text-violet-600 focus:ring-violet-500 dark:bg-zinc-900 dark:checked:bg-violet-600 mt-0.5" %> +
+ <%= form.label :notify_on_reoccurrence, "Reoccurring errors", class: "text-sm font-medium text-gray-700 dark:text-zinc-300" %> +

Get notified when a resolved error occurs again.

diff --git a/app/views/users/_form.html.erb b/app/views/users/_form.html.erb index 3cde81f..14d62b9 100644 --- a/app/views/users/_form.html.erb +++ b/app/views/users/_form.html.erb @@ -8,19 +8,12 @@ <%= form.errors_on(:email_address) %>
-
-
-
- <%= form.check_box :site_admin, class: "col-start-1 row-start-1 appearance-none rounded border border-gray-300 dark:border-zinc-600 bg-white dark:bg-zinc-700 checked:border-violet-500 checked:bg-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 focus:ring-offset-white dark:focus:ring-offset-zinc-900" %> - - - -
-
-
- <%= form.label :site_admin, "Site Administrator", class: "font-medium text-gray-700 dark:text-zinc-100" %> -

Grant this user site administrator privileges.

+
+
+ <%= form.check_box :site_admin, class: "h-4 w-4 text-violet-600 focus:ring-violet-500 border-gray-300 dark:border-zinc-600 rounded" %> + <%= form.label :site_admin, "Site Administrator", class: "ml-2 block text-sm text-gray-700 dark:text-zinc-300" %>
+

Grant this user site administrator privileges.

diff --git a/config/initializers/noticed_delivery_methods.rb b/config/initializers/noticed_delivery_methods.rb index dd62683..b4d8af3 100644 --- a/config/initializers/noticed_delivery_methods.rb +++ b/config/initializers/noticed_delivery_methods.rb @@ -2,5 +2,5 @@ Rails.application.config.to_prepare do require_relative '../../lib/noticed/delivery_methods/discord_delivery' require_relative '../../lib/noticed/delivery_methods/webhook_delivery' - require_relative '../../lib/noticed/delivery_methods/git_hub_delivery' + require_relative '../../lib/noticed/delivery_methods/github_delivery' end diff --git a/config/routes.rb b/config/routes.rb index d2460f4..ec477e8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,15 +1,4 @@ Rails.application.routes.draw do - # Setup wizard (only accessible when no users exist) - constraints SetupRequiredConstraint do - get 'setup', to: 'setup#index', as: :setup - post 'setup/admin', to: 'setup#create_admin', as: :setup_admin - get 'setup/team', to: 'setup#team', as: :setup_team - post 'setup/team', to: 'setup#create_team', as: :setup_create_team - get 'setup/app', to: 'setup#app', as: :setup_app - post 'setup/app', to: 'setup#create_app', as: :setup_create_app - get 'setup/complete', to: 'setup#complete', as: :setup_complete - end - # Ingestion API routes namespace :ingest do namespace :v1 do @@ -57,15 +46,7 @@ # User settings namespace :settings do - resource :profile, only: [ :show ] - resource :password, only: [ :edit, :update ] do - post :verify, on: :collection - end - resources :sessions, only: [ :destroy ] do - collection do - delete :destroy_all_other - end - end + resource :password, only: [ :edit, :update ] resource :smtp, only: [ :show, :edit, :update ], controller: 'smtp' do post :test_connection, on: :collection end diff --git a/db/migrate/20251230205057_create_password_histories.rb b/db/migrate/20251230205057_create_password_histories.rb deleted file mode 100644 index 2b21e2c..0000000 --- a/db/migrate/20251230205057_create_password_histories.rb +++ /dev/null @@ -1,10 +0,0 @@ -class CreatePasswordHistories < ActiveRecord::Migration[8.1] - def change - create_table :password_histories do |t| - t.references :user, null: false, foreign_key: true - t.string :password_digest - - t.timestamps - end - end -end diff --git a/db/schema.rb b/db/schema.rb index 7e41100..2d1d998 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2025_12_30_205057) do +ActiveRecord::Schema[8.1].define(version: 2025_12_24_023348) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -93,14 +93,6 @@ t.index ["problem_id"], name: "index_notices_on_problem_id" end - create_table "password_histories", force: :cascade do |t| - t.datetime "created_at", null: false - t.string "password_digest" - t.datetime "updated_at", null: false - t.bigint "user_id", null: false - t.index ["user_id"], name: "index_password_histories_on_user_id" - end - create_table "problem_tags", force: :cascade do |t| t.datetime "created_at", null: false t.bigint "problem_id", null: false @@ -228,7 +220,6 @@ add_foreign_key "notices", "backtraces" add_foreign_key "notices", "problems" - add_foreign_key "password_histories", "users" add_foreign_key "problem_tags", "problems" add_foreign_key "problem_tags", "tags" add_foreign_key "problems", "apps" diff --git a/db/seeds.rb b/db/seeds.rb index 050ca3a..a0650a5 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,13 +1,6 @@ -# Seeds demo data for development and testing only. -# Production uses the onboarding wizard at /setup for initial configuration. -# -# Run with: bin/rails db:seed - -# Prevent seeding in production - use the onboarding wizard instead -if Rails.env.production? - puts 'Seeding is disabled in production. Use the onboarding wizard at /setup instead.' - exit 0 -end +# This file should ensure the existence of records required to run the application in every environment (production, +# development, test). The code here should be idempotent so that it can be executed at any point in every environment. +# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). # Base date for all timestamps - ensures reproducibility base_date = Time.zone.parse("2024-12-20 00:00:00") diff --git a/lib/noticed/delivery_methods/git_hub_delivery.rb b/lib/noticed/delivery_methods/github_delivery.rb similarity index 100% rename from lib/noticed/delivery_methods/git_hub_delivery.rb rename to lib/noticed/delivery_methods/github_delivery.rb diff --git a/site/src/pages/docs/api/authentication.astro b/site/src/pages/docs/api/authentication.astro new file mode 100644 index 0000000..9e410f0 --- /dev/null +++ b/site/src/pages/docs/api/authentication.astro @@ -0,0 +1,90 @@ +--- +import DocsLayout from '../../../layouts/DocsLayout.astro' +import CodeBlock from '../../../components/CodeBlock.astro' +import Callout from '../../../components/Callout.astro' +--- + + +
+
+

Authentication

+

+ Learn how to authenticate with the Checkend ingestion API. +

+
+ +
+

Ingestion Keys

+

+ Checkend uses ingestion keys to authenticate requests. Each app in your Checkend instance has its own ingestion key. +

+

+ You can find your ingestion key in the Checkend dashboard under Apps → Your App → Settings. +

+
+ +
+

Using the Ingestion Key

+

+ Include your ingestion key in the Checkend-Ingestion-Key header: +

+ + + This header follows RFC 6648 conventions by using a vendor-prefixed header name (Checkend-) instead of the deprecated X- prefix. + +
+ +
+

Security Best Practices

+
    +
  • + + Keep ingestion keys secret — Never commit ingestion keys to version control +
  • +
  • + + Use environment variables — Store keys in environment variables, not in code +
  • +
  • + + Rotate keys — Regenerate keys periodically or if you suspect they've been compromised +
  • +
  • + + Use HTTPS — Always send requests over HTTPS in production +
  • +
+
+ + + If you provide an invalid or missing ingestion key, the ingestion API will return a 401 Unauthorized response. + + +
+

Error Responses

+
+ + + + + + + + + + + + + + + + + +
StatusDescription
401Ingestion key is missing or invalid
403Ingestion key doesn't have permission for this action
+
+
+
+
diff --git a/site/src/pages/docs/api/errors.astro b/site/src/pages/docs/api/errors.astro new file mode 100644 index 0000000..4e0d6d5 --- /dev/null +++ b/site/src/pages/docs/api/errors.astro @@ -0,0 +1,146 @@ +--- +import DocsLayout from '../../../layouts/DocsLayout.astro' +import CodeBlock from '../../../components/CodeBlock.astro' +import Callout from '../../../components/Callout.astro' +--- + + +
+
+

Errors Endpoint

+

+ Ingestion reference for the errors endpoint. +

+
+ +
+
+ POST + /ingest/v1/errors +
+

+ Report a new error to Checkend. The error will be grouped with similar errors based on its fingerprint. +

+
+ +
+

Request Headers

+
+ + + + + + + + + + + + + + + + + +
HeaderValue
Content-Typeapplication/json
Checkend-Ingestion-KeyYour app's ingestion key
+
+
+ +
+

Request Body

+ +
+ +
+

Example Request

+ +
+ +
+

Response

+ +

Success (201 Created)

+ + +

Error (422 Unprocessable Entity)

+ +
+ +
+

Response Codes

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
StatusDescription
201Error created successfully
401Invalid or missing ingestion key
422Validation error (missing required fields)
500Internal server error
+
+
+ + + Currently, there are no rate limits on the ingestion API. However, we recommend implementing client-side rate limiting + to avoid overwhelming your Checkend instance during error spikes. + +
+
diff --git a/site/src/pages/docs/error-grouping.astro b/site/src/pages/docs/error-grouping.astro new file mode 100644 index 0000000..e94387f --- /dev/null +++ b/site/src/pages/docs/error-grouping.astro @@ -0,0 +1,92 @@ +--- +import DocsLayout from '../../layouts/DocsLayout.astro' +import CodeBlock from '../../components/CodeBlock.astro' +import Callout from '../../components/Callout.astro' +--- + + +
+
+

Error Grouping

+

+ Understand how Checkend groups similar errors together. +

+
+ +
+

Automatic Fingerprinting

+

+ By default, Checkend generates a fingerprint for each error based on: +

+
    +
  • + + The error class name (e.g., NoMethodError) +
  • +
  • + + The first line of the backtrace from your application code +
  • +
+

+ This means errors with the same class occurring at the same location are grouped into a single "problem." +

+
+ +
+

Custom Fingerprints

+

+ Sometimes you may want to group errors differently. You can provide a custom fingerprint when sending errors: +

+ + + All errors with the same custom fingerprint will be grouped together, regardless of their class or backtrace. + +
+ +
+

Use Cases for Custom Fingerprints

+
    +
  • + Group by error type +

    Group all payment failures together regardless of the specific reason.

    +
  • +
  • + Group by feature +

    Group all errors from a specific feature like "checkout" or "user-registration".

    +
  • +
  • + Separate environments +

    Include environment in the fingerprint to separate staging from production errors.

    +
  • +
+
+ +
+

Problems vs Notices

+

+ In Checkend terminology: +

+
    +
  • + + Notice — A single error occurrence +
  • +
  • + + Problem — A group of similar notices (based on fingerprint) +
  • +
+

+ When you resolve a problem, you're marking all related notices as resolved. If a new notice comes in with the same fingerprint, the problem will be reopened. +

+
+
+
diff --git a/site/src/pages/docs/quickstart.astro b/site/src/pages/docs/quickstart.astro new file mode 100644 index 0000000..01bfe76 --- /dev/null +++ b/site/src/pages/docs/quickstart.astro @@ -0,0 +1,112 @@ +--- +import DocsLayout from '../../layouts/DocsLayout.astro' +import CodeBlock from '../../components/CodeBlock.astro' +import Callout from '../../components/Callout.astro' +--- + + +
+
+

Quickstart

+

+ Get Checkend running in under 5 minutes. +

+
+ +
+

1. Clone the repository

+

+ Start by cloning the Checkend repository from GitHub: +

+ +
+ +
+

2. Install dependencies

+

+ Install Ruby gems using Bundler: +

+ +
+ +
+

3. Set up the database

+

+ Create and migrate the database: +

+ + + Make sure PostgreSQL is running and you have the correct database credentials in your environment. + +
+ +
+

4. Start the server

+

+ Launch the development server: +

+ +

+ Visit http://localhost:3000 to access the Checkend dashboard. +

+
+ +
+

5. Create your first app

+

+ In the dashboard, create a new app to get your ingestion key. You'll use this key to send errors to Checkend. +

+

+ After creating the app, you can optionally assign it to a team to collaborate with others. See the + Teams documentation + for more information. +

+
+ +
+

6. Send a test error

+

+ Use curl to send a test error to your Checkend instance: +

+ + + You should now see the error appear in your Checkend dashboard. Check out the + Sending Errors + guide for more details on the ingestion API. + +
+ + + +
+
diff --git a/site/src/pages/docs/sending-errors.astro b/site/src/pages/docs/sending-errors.astro new file mode 100644 index 0000000..f790408 --- /dev/null +++ b/site/src/pages/docs/sending-errors.astro @@ -0,0 +1,154 @@ +--- +import DocsLayout from '../../layouts/DocsLayout.astro' +import CodeBlock from '../../components/CodeBlock.astro' +import Callout from '../../components/Callout.astro' +--- + + +
+
+

Sending Errors

+

+ Learn how to send errors from your applications to Checkend. +

+
+ +
+

Basic Request

+

+ Send errors to Checkend using a simple POST request to the /ingest/v1/errors endpoint. +

+ +
+ +
+

Request Body

+

+ The request body accepts the following fields: +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDescription
error_classstringRequiredThe error class name
error_messagestringRequiredThe error message
backtracearrayOptionalArray of backtrace lines
contextobjectOptionalCustom context data
requestobjectOptionalRequest information (url, method, params)
user_infoobjectOptionalUser information (id, email, name)
fingerprintstringOptionalCustom fingerprint for grouping
+
+
+ +
+

Full Example

+

+ Here's an example with all optional fields: +

+ +
+ + + By default, errors are grouped by their class name and the first line of the backtrace. You can provide a custom + fingerprint to control how errors are grouped. + See the Error Grouping guide for details. + + + + +
+
diff --git a/site/src/pages/docs/teams.astro b/site/src/pages/docs/teams.astro new file mode 100644 index 0000000..14a54fc --- /dev/null +++ b/site/src/pages/docs/teams.astro @@ -0,0 +1,410 @@ +--- +import DocsLayout from '../../layouts/DocsLayout.astro' +import Callout from '../../components/Callout.astro' +--- + + +
+
+

Teams

+

+ Collaborate with your team members on monitoring and resolving errors across your applications. +

+
+ +
+

Overview

+

+ Teams allow multiple users to collaborate on monitoring and managing errors. With teams, you can: +

+
    +
  • + + Share access to apps with team members +
  • +
  • + + Control who can manage team settings and app assignments +
  • +
  • + + Organize your monitoring setup by project, department, or feature area +
  • +
  • + + Invite team members via email with secure invitation links +
  • +
+ + Teams are optional. You can use Checkend individually, but teams make it easy to collaborate when working with others. + +
+ +
+

Creating a Team

+

+ To create a new team: +

+
    +
  1. + 1 + Navigate to the Teams page from the main navigation +
  2. +
  3. + 2 + Click the New Team button +
  4. +
  5. + 3 + Enter a team name and click Create Team +
  6. +
+

+ When you create a team, you automatically become the team owner and are added as an admin member. You can then invite others or add existing users to the team. +

+
+ +
+

Team Roles

+

+ Teams have two role levels with different permissions: +

+
+
+

Admin

+

+ Team admins can: +

+
    +
  • + + Add and remove team members +
  • +
  • + + Change member roles +
  • +
  • + + Send team invitations +
  • +
  • + + Assign apps to the team +
  • +
  • + + Remove team assignments from apps +
  • +
  • + + Edit team name +
  • +
+
+
+

Member

+

+ Team members can: +

+
    +
  • + + View all apps assigned to the team +
  • +
  • + + View and manage problems for team apps +
  • +
  • + + Resolve and unresolve errors +
  • +
  • + + Add and remove tags +
  • +
+
+
+ + At least one admin must remain in the team. You cannot remove the last admin from a team. + +
+ +
+

Managing Team Members

+

+ Team admins can manage team members from the team detail page. +

+ +

Adding Members Directly

+

+ To add an existing user to your team: +

+
    +
  1. + 1 + Go to the team detail page and click Manage members +
  2. +
  3. + 2 + Enter the user's email address +
  4. +
  5. + 3 + Select their role (Admin or Member) +
  6. +
  7. + 4 + Click Add Member +
  8. +
+ + The user must already have a Checkend account. If they don't have an account, use team invitations instead. + + +

Changing Member Roles

+

+ Team admins can change a member's role: +

+
    +
  1. + 1 + Go to the team members page +
  2. +
  3. + 2 + Find the member you want to update +
  4. +
  5. + 3 + Select the new role from the dropdown and save +
  6. +
+ +

Removing Members

+

+ To remove a member from the team: +

+
    +
  1. + 1 + Go to the team members page +
  2. +
  3. + 2 + Click Remove next to the member you want to remove +
  4. +
  5. + 3 + Confirm the removal +
  6. +
+ + Removing a member from a team will revoke their access to all apps assigned to that team. They will no longer be able to view or manage those apps. + +
+ +
+

Team Invitations

+

+ Team invitations allow you to invite users who don't yet have a Checkend account, or send secure invitation links to existing users. +

+ +

Sending Invitations

+

+ To send a team invitation: +

+
    +
  1. + 1 + Go to the team detail page and click Team Invitations +
  2. +
  3. + 2 + Enter the email address of the person you want to invite +
  4. +
  5. + 3 + Click Send Invitation +
  6. +
+

+ The invited user will receive an email with a secure invitation link. Invitations expire after 7 days. +

+ +

Accepting Invitations

+

+ When someone receives a team invitation: +

+
    +
  1. + 1 + They click the invitation link in the email +
  2. +
  3. + 2 + If not already signed in, they'll be prompted to sign in or create an account +
  4. +
  5. + 3 + Once authenticated, they're automatically added to the team as a member +
  6. +
+ + The email address used to accept the invitation must match the email address the invitation was sent to. + + +

Canceling Invitations

+

+ To cancel a pending invitation, go to the team invitations page and click Cancel next to the invitation. +

+
+ +
+

Assigning Apps to Teams

+

+ Apps can be assigned to one or more teams. All members of assigned teams will have access to the app. +

+ +

During App Creation

+

+ When you create a new app, you'll see a setup wizard that allows you to assign it to a team: +

+
    +
  1. + 1 + After creating the app, you'll see the setup wizard +
  2. +
  3. + 2 + Select a team from the dropdown (only teams where you're an admin are shown) +
  4. +
  5. + 3 + Click Assign Team or skip to assign later +
  6. +
+ +

From the App Page

+

+ You can also assign teams to an existing app: +

+
    +
  1. + 1 + Go to the app detail page +
  2. +
  3. + 2 + In the Teams section, select a team from the dropdown +
  4. +
  5. + 3 + Click Assign +
  6. +
+ + Only team admins can assign apps to teams. You must be an admin of the team you're trying to assign the app to. + + +

Removing Team Assignments

+

+ To remove a team assignment from an app: +

+
    +
  1. + 1 + Go to the app detail page +
  2. +
  3. + 2 + In the Teams section, click Remove next to the team you want to remove +
  4. +
  5. + 3 + Confirm the removal +
  6. +
+ + If you remove all team assignments from an app, only the app creator will have access to it. Make sure at least one team has access, or the app will become inaccessible to team members. + +
+ +
+

Access Control

+

+ Team membership determines what apps you can access: +

+
    +
  • + + You can access all apps assigned to teams you're a member of +
  • +
  • + + Apps with no team assignments are only accessible to their creator (for a short grace period after creation) +
  • +
  • + + Team admins can assign apps to teams, while members can only view and manage problems +
  • +
+

+ When you're a member of multiple teams, you'll see all apps from all your teams in the apps list. +

+
+ +
+

Team Owner

+

+ The team owner is the user who created the team. The owner has special privileges: +

+
    +
  • + + Can delete the team (which removes all team assignments) +
  • +
  • + + Automatically has admin role and cannot be removed from the team +
  • +
  • + + Can edit the team name +
  • +
+
+ +
+

Best Practices

+

+ Here are some tips for organizing your teams effectively: +

+
    +
  • + + Organize by project: Create teams for different projects or products +
  • +
  • + + Organize by department: Create teams for engineering, QA, or operations +
  • +
  • + + Use multiple teams: Assign apps to multiple teams if they're relevant to different groups +
  • +
  • + + Limit admin access: Only grant admin role to users who need to manage team settings +
  • +
  • + + Use invitations: Invite new users via email invitations for a smoother onboarding experience +
  • +
+
+
+
+ diff --git a/test/controllers/settings/passwords_controller_test.rb b/test/controllers/settings/passwords_controller_test.rb index 3af4394..520172e 100644 --- a/test/controllers/settings/passwords_controller_test.rb +++ b/test/controllers/settings/passwords_controller_test.rb @@ -15,9 +15,8 @@ class Settings::PasswordsControllerTest < ActionDispatch::IntegrationTest test 'edit shows password form' do get edit_settings_password_path assert_response :success + assert_select 'h1', 'Security Settings' assert_select 'form[action=?]', settings_password_path - assert_select 'input[name="current_password"]' - assert_select 'input[name="password"]' end test 'update requires authentication' do @@ -37,7 +36,7 @@ class Settings::PasswordsControllerTest < ActionDispatch::IntegrationTest password_confirmation: 'newpassword123' } - assert_redirected_to settings_profile_path + assert_redirected_to edit_settings_password_path assert_equal 'Password updated successfully.', flash[:notice] # Verify password was actually changed @@ -68,7 +67,7 @@ class Settings::PasswordsControllerTest < ActionDispatch::IntegrationTest } assert_response :unprocessable_entity - assert_match(/doesn't match/, flash[:alert]) + assert_equal 'Password could not be updated.', flash[:alert] # Verify password was not changed @user.reload @@ -89,61 +88,4 @@ class Settings::PasswordsControllerTest < ActionDispatch::IntegrationTest @user.reload assert @user.authenticate('password') end - - test 'update prevents password reuse' do - # First change the password - patch settings_password_path, params: { - current_password: 'password', - password: 'newpassword123', - password_confirmation: 'newpassword123' - } - assert_redirected_to settings_profile_path - - # Try to change back to the old password - patch settings_password_path, params: { - current_password: 'newpassword123', - password: 'password', - password_confirmation: 'password' - } - - assert_response :unprocessable_entity - assert_match(/has been used recently/, flash[:alert]) - end - - # Verify endpoint tests - test 'verify requires authentication' do - sign_out - post verify_settings_password_path, params: { current_password: 'password' }, as: :json - assert_redirected_to new_session_path - end - - test 'verify returns valid true for correct password' do - post verify_settings_password_path, - params: { current_password: 'password' }, - as: :json - - assert_response :success - json = JSON.parse(response.body) - assert json['valid'] - end - - test 'verify returns valid false for incorrect password' do - post verify_settings_password_path, - params: { current_password: 'wrongpassword' }, - as: :json - - assert_response :success - json = JSON.parse(response.body) - assert_not json['valid'] - end - - test 'verify returns valid false for empty password' do - post verify_settings_password_path, - params: { current_password: '' }, - as: :json - - assert_response :success - json = JSON.parse(response.body) - assert_not json['valid'] - end end diff --git a/test/controllers/settings/profile_controller_test.rb b/test/controllers/settings/profile_controller_test.rb deleted file mode 100644 index 1e8487f..0000000 --- a/test/controllers/settings/profile_controller_test.rb +++ /dev/null @@ -1,35 +0,0 @@ -require 'test_helper' - -class Settings::ProfileControllerTest < ActionDispatch::IntegrationTest - setup do - @user = users(:one) - sign_in_as(@user) - end - - test 'should get show when logged in' do - get settings_profile_path - assert_response :success - end - - test 'should redirect to login when not logged in' do - sign_out - get settings_profile_path - assert_redirected_to new_session_path - end - - test 'should display user email' do - get settings_profile_path - assert_select 'h1', text: @user.email_address - end - - test 'should display user initials in avatar' do - get settings_profile_path - initials = @user.email_address.first(2).upcase - assert_match initials, response.body - end - - test 'should link to password change page' do - get settings_profile_path - assert_select "a[href='#{edit_settings_password_path}']" - end -end diff --git a/test/controllers/settings/sessions_controller_test.rb b/test/controllers/settings/sessions_controller_test.rb deleted file mode 100644 index 854fe55..0000000 --- a/test/controllers/settings/sessions_controller_test.rb +++ /dev/null @@ -1,67 +0,0 @@ -require 'test_helper' - -class Settings::SessionsControllerTest < ActionDispatch::IntegrationTest - setup do - @user = users(:one) - sign_in_as(@user) - end - - test 'destroy requires authentication' do - other_session = @user.sessions.create!(user_agent: 'Other Browser', ip_address: '192.168.1.100') - sign_out - delete settings_session_path(other_session) - assert_redirected_to new_session_path - end - - test 'destroy revokes other session' do - # Create another session for the user - other_session = @user.sessions.create!(user_agent: 'Other Browser', ip_address: '192.168.1.100') - - assert_difference -> { @user.sessions.count }, -1 do - delete settings_session_path(other_session) - end - - assert_redirected_to settings_profile_path - assert_equal 'Session revoked successfully.', flash[:notice] - end - - test 'destroy prevents revoking current session' do - current_session = @user.sessions.order(created_at: :desc).first - - assert_no_difference -> { @user.sessions.count } do - delete settings_session_path(current_session) - end - - assert_redirected_to settings_profile_path - assert_match(/can't revoke your current session/i, flash[:alert]) - end - - test 'destroy_all_other requires authentication' do - sign_out - delete destroy_all_other_settings_sessions_path - assert_redirected_to new_session_path - end - - test 'destroy_all_other revokes all other sessions' do - # Create multiple other sessions - @user.sessions.create!(user_agent: 'Other Browser 1', ip_address: '192.168.1.100') - @user.sessions.create!(user_agent: 'Other Browser 2', ip_address: '192.168.1.101') - - initial_count = @user.sessions.count - assert initial_count >= 3 # current + 2 others - - delete destroy_all_other_settings_sessions_path - - assert_redirected_to settings_profile_path - assert_equal 'All other sessions have been revoked.', flash[:notice] - assert_equal 1, @user.sessions.reload.count # only current session remains - end - - test 'destroy cannot revoke another users session' do - other_user = users(:two) - other_session = other_user.sessions.create!(user_agent: 'Other User Browser', ip_address: '10.0.0.1') - - delete settings_session_path(other_session) - assert_response :not_found - end -end diff --git a/test/controllers/setup_controller_test.rb b/test/controllers/setup_controller_test.rb deleted file mode 100644 index 43793bf..0000000 --- a/test/controllers/setup_controller_test.rb +++ /dev/null @@ -1,300 +0,0 @@ -# frozen_string_literal: true - -require 'test_helper' - -class SetupControllerTest < ActionDispatch::IntegrationTest - # Disable fixtures for this test class since we need to test with no users - self.use_transactional_tests = true - - setup do - # Clear all related data to enable setup mode - TeamMember.delete_all - TeamAssignment.delete_all - TeamInvitation.delete_all - Session.delete_all - UserNotificationPreference.delete_all - Team.delete_all - User.delete_all - end - - test 'index shows setup form when no users exist' do - get setup_path - assert_response :success - assert_select 'h1', text: /Welcome to Checkend/ - end - - test 'index returns 404 when setup is complete' do - # Setup is complete when a user exists AND has logged in (has sessions) - user = User.create!(email_address: 'test@example.com', password: 'password123') - user.sessions.create! - - # Route constraint blocks access, resulting in 404 - get '/setup' - assert_response :not_found - end - - test 'create_admin creates site admin user' do - assert_difference 'User.count', 1 do - post setup_admin_path, params: { - user: { - email_address: 'admin@example.com', - password: 'password123', - password_confirmation: 'password123' - } - } - end - - assert_redirected_to setup_team_path - - user = User.last - assert user.site_admin? - assert_equal 'admin@example.com', user.email_address - end - - test 'create_admin shows errors for invalid data' do - post setup_admin_path, params: { - user: { - email_address: '', - password: 'short', - password_confirmation: 'different' - } - } - - assert_response :unprocessable_entity - assert_equal 0, User.count - end - - test 'team step requires admin in session' do - get setup_team_path - assert_redirected_to setup_path - end - - test 'team step shows form when admin exists in session' do - create_admin_via_wizard - - get setup_team_path - assert_response :success - assert_select 'h1', text: /Create Your Team/ - end - - test 'create_team creates team with admin as owner' do - create_admin_via_wizard - - assert_difference %w[Team.count TeamMember.count], 1 do - post setup_create_team_path, params: { - team: { name: 'Engineering' } - } - end - - assert_redirected_to setup_app_path - - team = Team.last - assert_equal 'Engineering', team.name - assert_equal User.last, team.owner - - team_member = team.team_members.find_by(user: User.last) - assert team_member.present? - assert_equal 'admin', team_member.role - end - - test 'app step requires admin and team in session' do - get setup_app_path - assert_redirected_to setup_path - - create_admin_via_wizard - get setup_app_path - assert_redirected_to setup_team_path - end - - test 'app step shows form when admin and team exist in session' do - create_admin_via_wizard - create_team_via_wizard - - get setup_app_path - assert_response :success - assert_select 'h1', text: /Create Your First App/ - end - - test 'create_app creates app and assigns to team' do - create_admin_via_wizard - create_team_via_wizard - - assert_difference %w[App.count TeamAssignment.count], 1 do - post setup_create_app_path, params: { - app: { name: 'My App', environment: 'production' } - } - end - - assert_redirected_to setup_complete_path - - app = App.last - assert_equal 'My App', app.name - assert_equal 'production', app.environment - assert app.ingestion_key.present? - - team_assignment = TeamAssignment.last - assert_equal Team.last, team_assignment.team - assert_equal app, team_assignment.app - end - - test 'complete step requires setup in progress or admin' do - # Without any setup, redirects to setup start - get setup_complete_path - assert_redirected_to setup_path - - # Partial setup also redirects to setup start - create_admin_via_wizard - get setup_complete_path - assert_redirected_to setup_path - - create_team_via_wizard - get setup_complete_path - assert_redirected_to setup_path - end - - test 'site admin can view complete page after setup' do - # Complete the full setup first - create_admin_via_wizard - create_team_via_wizard - create_app_via_wizard - get setup_complete_path - assert_response :success - - # Now we're logged in as admin, try viewing again - get '/setup/complete' - assert_response :success - assert_select 'h1', text: /Setup Information/ - end - - test 'complete step shows ingestion key and logs user in' do - create_admin_via_wizard - create_team_via_wizard - create_app_via_wizard - - get setup_complete_path - assert_response :success - - # User should be logged in - assert cookies[:session_id].present? - - # Should show ingestion key - app = App.last - assert_select 'code', text: app.ingestion_key - end - - test 'non-setup routes redirect to setup when no users exist' do - get root_path - assert_redirected_to setup_path - end - - test 'API routes still work when no users exist' do - # API routes should not redirect to setup - post ingest_v1_errors_path, - params: { error_class: 'Test', error_message: 'Test' }.to_json, - headers: { - 'Content-Type' => 'application/json', - 'Checkend-Ingestion-Key' => 'invalid' - } - assert_response :unauthorized # Not redirected to setup - end - - test 'health check works when no users exist' do - get rails_health_check_path - assert_response :success - end - - test 'full wizard flow completes successfully' do - # Record initial counts - initial_user_count = User.count - initial_team_count = Team.count - initial_app_count = App.count - - # Step 1: Create admin - get setup_path - assert_response :success - - post setup_admin_path, params: { - user: { - email_address: 'wizard_admin@example.com', - password: 'securepassword123', - password_confirmation: 'securepassword123' - } - } - assert_redirected_to setup_team_path - - # Step 2: Create team - get setup_team_path - assert_response :success - - post setup_create_team_path, params: { - team: { name: 'Wizard Team' } - } - assert_redirected_to setup_app_path - - # Step 3: Create app - get setup_app_path - assert_response :success - - post setup_create_app_path, params: { - app: { name: 'Wizard App', environment: 'production' } - } - assert_redirected_to setup_complete_path - - # Step 4: Complete - get setup_complete_path - assert_response :success - - # Verify resources were created (compare to initial counts) - assert_equal initial_user_count + 1, User.count - assert_equal initial_team_count + 1, Team.count - assert_equal initial_app_count + 1, App.count - - # Verify the created user - user = User.find_by(email_address: 'wizard_admin@example.com') - assert user.present? - assert user.site_admin? - - # Verify team was created and user is owner/admin - team = Team.find_by(name: 'Wizard Team') - assert team.present? - assert_equal user, team.owner - assert team.team_members.exists?(user: user, role: 'admin') - - # Verify app was created and assigned to team - app = App.find_by(name: 'Wizard App') - assert app.present? - assert app.ingestion_key.present? - assert TeamAssignment.exists?(team: team, app: app) - - # User should be logged in - assert cookies[:session_id].present? - - # Setup routes should now 404 - get '/setup' - assert_response :not_found - end - - private - - def create_admin_via_wizard - post setup_admin_path, params: { - user: { - email_address: 'admin@example.com', - password: 'password123', - password_confirmation: 'password123' - } - } - end - - def create_team_via_wizard - post setup_create_team_path, params: { - team: { name: 'Test Team' } - } - end - - def create_app_via_wizard - post setup_create_app_path, params: { - app: { name: 'Test App', environment: 'production' } - } - end -end diff --git a/test/controllers/user_notification_preferences_controller_test.rb b/test/controllers/user_notification_preferences_controller_test.rb deleted file mode 100644 index 04fa967..0000000 --- a/test/controllers/user_notification_preferences_controller_test.rb +++ /dev/null @@ -1,179 +0,0 @@ -require 'test_helper' - -class UserNotificationPreferencesControllerTest < ActionDispatch::IntegrationTest - setup do - @user = users(:one) - @other_user = users(:two) - @app = apps(:one) - @team = teams(:one) - # Set up team access - @team.team_members.find_or_create_by!(user: @user, role: 'admin') - @team.team_assignments.find_or_create_by!(app: @app) - sign_in_as(@user) - end - - # Authentication tests - test 'edit requires authentication' do - sign_out - get edit_app_user_notification_preference_path(@app) - assert_redirected_to new_session_path - end - - test 'update requires authentication' do - sign_out - patch app_user_notification_preference_path(@app), params: { - user_notification_preference: { notify_on_new_problem: true } - } - assert_redirected_to new_session_path - end - - # Edit tests - test 'edit shows form for accessible app' do - get edit_app_user_notification_preference_path(@app) - assert_response :success - assert_select 'form' - end - - test 'edit returns not found for inaccessible app' do - other_app = apps(:two) - get edit_app_user_notification_preference_path(other_app) - assert_response :not_found - end - - test 'edit initializes new preference if none exists' do - assert_nil @user.user_notification_preferences.find_by(app: @app) - - get edit_app_user_notification_preference_path(@app) - assert_response :success - end - - test 'edit loads existing preference' do - preference = @user.user_notification_preferences.create!( - app: @app, - notify_on_new_problem: true, - notify_on_reoccurrence: false - ) - - get edit_app_user_notification_preference_path(@app) - assert_response :success - end - - # Update tests - test 'update creates new preference with valid params' do - assert_difference('UserNotificationPreference.count', 1) do - patch app_user_notification_preference_path(@app), params: { - user_notification_preference: { - notify_on_new_problem: true, - notify_on_reoccurrence: true - } - } - end - - assert_redirected_to app_path(@app) - follow_redirect! - assert_match 'Notification preferences updated successfully', response.body - - preference = @user.user_notification_preferences.find_by(app: @app) - assert preference.notify_on_new_problem - assert preference.notify_on_reoccurrence - end - - test 'update modifies existing preference' do - preference = @user.user_notification_preferences.create!( - app: @app, - notify_on_new_problem: false, - notify_on_reoccurrence: false - ) - - assert_no_difference('UserNotificationPreference.count') do - patch app_user_notification_preference_path(@app), params: { - user_notification_preference: { - notify_on_new_problem: true, - notify_on_reoccurrence: true - } - } - end - - assert_redirected_to app_path(@app) - - preference.reload - assert preference.notify_on_new_problem - assert preference.notify_on_reoccurrence - end - - test 'update can disable notifications' do - preference = @user.user_notification_preferences.create!( - app: @app, - notify_on_new_problem: true, - notify_on_reoccurrence: true - ) - - patch app_user_notification_preference_path(@app), params: { - user_notification_preference: { - notify_on_new_problem: false, - notify_on_reoccurrence: false - } - } - - assert_redirected_to app_path(@app) - - preference.reload - assert_not preference.notify_on_new_problem - assert_not preference.notify_on_reoccurrence - end - - test 'update returns not found for inaccessible app' do - other_app = apps(:two) - - patch app_user_notification_preference_path(other_app), params: { - user_notification_preference: { notify_on_new_problem: true } - } - - assert_response :not_found - end - - # Access control tests - test 'different users have separate preferences for same app' do - # Create preference for first user - @user.user_notification_preferences.create!( - app: @app, - notify_on_new_problem: true, - notify_on_reoccurrence: false - ) - - # Sign in as other user who also has access - @team.team_members.find_or_create_by!(user: @other_user, role: 'member') - sign_in_as(@other_user) - - # Other user should be able to create their own preference - assert_difference('UserNotificationPreference.count', 1) do - patch app_user_notification_preference_path(@app), params: { - user_notification_preference: { - notify_on_new_problem: false, - notify_on_reoccurrence: true - } - } - end - - assert_redirected_to app_path(@app) - - # Verify preferences are separate - user_one_pref = @user.user_notification_preferences.find_by(app: @app) - user_two_pref = @other_user.user_notification_preferences.find_by(app: @app) - - assert user_one_pref.notify_on_new_problem - assert_not user_one_pref.notify_on_reoccurrence - - assert_not user_two_pref.notify_on_new_problem - assert user_two_pref.notify_on_reoccurrence - end - - # Breadcrumb tests - test 'edit displays correct breadcrumbs' do - get edit_app_user_notification_preference_path(@app) - assert_response :success - assert_match 'Apps', response.body - assert_match @app.name, response.body - assert_match 'Notification Preferences', response.body - end -end diff --git a/test/delivery_methods/git_hub_delivery_test.rb b/test/delivery_methods/github_delivery_test.rb similarity index 100% rename from test/delivery_methods/git_hub_delivery_test.rb rename to test/delivery_methods/github_delivery_test.rb diff --git a/test/fixtures/password_histories.yml b/test/fixtures/password_histories.yml deleted file mode 100644 index 7c0f3ea..0000000 --- a/test/fixtures/password_histories.yml +++ /dev/null @@ -1,3 +0,0 @@ -# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html - -# Empty - password histories are created dynamically during tests diff --git a/test/fixtures/sessions.yml b/test/fixtures/sessions.yml deleted file mode 100644 index 67abf84..0000000 --- a/test/fixtures/sessions.yml +++ /dev/null @@ -1,11 +0,0 @@ -# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html - -one: - user: one - ip_address: 192.168.1.1 - user_agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 - -two: - user: two - ip_address: 10.0.0.1 - user_agent: Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1 diff --git a/test/fixtures/user_notification_preferences.yml b/test/fixtures/user_notification_preferences.yml deleted file mode 100644 index 2597dc8..0000000 --- a/test/fixtures/user_notification_preferences.yml +++ /dev/null @@ -1,4 +0,0 @@ -# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html - -# Fixtures are created dynamically in tests as needed -# since preferences are user+app specific diff --git a/test/mailers/passwords_mailer_test.rb b/test/mailers/passwords_mailer_test.rb deleted file mode 100644 index a1244cc..0000000 --- a/test/mailers/passwords_mailer_test.rb +++ /dev/null @@ -1,43 +0,0 @@ -require 'test_helper' - -class PasswordsMailerTest < ActionMailer::TestCase - setup do - @user = users(:one) - end - - test 'reset email is sent to user' do - email = PasswordsMailer.reset(@user) - - assert_emails 1 do - email.deliver_now - end - - assert_equal [ @user.email_address ], email.to - assert_equal 'Reset your password', email.subject - end - - test 'reset email contains password reset link' do - email = PasswordsMailer.reset(@user) - - assert_match 'password reset page', email.html_part.body.to_s - assert_match %r{/passwords/[^/]+/edit}, email.html_part.body.to_s - end - - test 'reset email contains expiration notice' do - email = PasswordsMailer.reset(@user) - - assert_match 'expire', email.html_part.body.to_s - end - - test 'reset email has text part with reset link' do - email = PasswordsMailer.reset(@user) - - assert_match %r{/passwords/[^/]+/edit}, email.text_part.body.to_s - end - - test 'reset email from address is set' do - email = PasswordsMailer.reset(@user) - - assert_equal [ 'noreply@checkend.local' ], email.from - end -end diff --git a/test/mailers/team_invitations_mailer_test.rb b/test/mailers/team_invitations_mailer_test.rb deleted file mode 100644 index b546b3c..0000000 --- a/test/mailers/team_invitations_mailer_test.rb +++ /dev/null @@ -1,56 +0,0 @@ -require 'test_helper' - -class TeamInvitationsMailerTest < ActionMailer::TestCase - setup do - @team_invitation = team_invitations(:one) - @team = @team_invitation.team - @inviter = @team_invitation.invited_by - end - - test 'invite email is sent to invitee' do - email = TeamInvitationsMailer.invite(@team_invitation) - - assert_emails 1 do - email.deliver_now - end - - assert_equal [ @team_invitation.email ], email.to - end - - test 'invite email subject includes team name' do - email = TeamInvitationsMailer.invite(@team_invitation) - - assert_equal "You've been invited to join #{@team.name} on Checkend", email.subject - end - - test 'invite email body contains team name' do - email = TeamInvitationsMailer.invite(@team_invitation) - - assert_match @team.name, email.html_part.body.to_s - end - - test 'invite email body contains inviter email' do - email = TeamInvitationsMailer.invite(@team_invitation) - - assert_match @inviter.email_address, email.html_part.body.to_s - end - - test 'invite email contains accept invitation link' do - email = TeamInvitationsMailer.invite(@team_invitation) - - assert_match 'Accept Invitation', email.html_part.body.to_s - assert_match %r{/team_invitations/[^/]+/accept}, email.html_part.body.to_s - end - - test 'invite email mentions expiration' do - email = TeamInvitationsMailer.invite(@team_invitation) - - assert_match 'expire', email.html_part.body.to_s - end - - test 'invite email from address is set' do - email = TeamInvitationsMailer.invite(@team_invitation) - - assert_equal [ 'noreply@checkend.local' ], email.from - end -end diff --git a/test/models/password_history_test.rb b/test/models/password_history_test.rb deleted file mode 100644 index 1433dd6..0000000 --- a/test/models/password_history_test.rb +++ /dev/null @@ -1,222 +0,0 @@ -require 'test_helper' - -class PasswordHistoryTest < ActiveSupport::TestCase - setup do - @user = users(:one) - # Clear any existing password histories - @user.password_histories.destroy_all - end - - # Association tests - test 'belongs to user' do - history = PasswordHistory.new(user: @user, password_digest: 'test_digest') - assert_equal @user, history.user - end - - test 'requires user' do - history = PasswordHistory.new(password_digest: 'test_digest') - assert_not history.valid? - assert_includes history.errors[:user], 'must exist' - end - - # Password history creation tests - test 'password history is created when password changes' do - original_digest = @user.password_digest - - assert_difference('@user.password_histories.count', 1) do - @user.update!(password: 'newpassword123') - end - - history = @user.password_histories.last - assert_equal original_digest, history.password_digest - end - - test 'password history is not created for new user' do - new_user = User.new( - email_address: 'newuser@example.com', - password: 'initialpassword' - ) - - assert_no_difference('PasswordHistory.count') do - new_user.save! - end - end - - test 'password history is not created if password does not change' do - assert_no_difference('@user.password_histories.count') do - @user.update!(email_address: 'updated@example.com') - end - end - - # Password reuse prevention tests - test 'cannot reuse current password' do - @user.update(password: 'currentpassword') - - @user.password = 'currentpassword' - assert_not @user.valid? - assert_includes @user.errors[:password], 'has been used recently. Please choose a different password.' - end - - test 'cannot reuse password from history' do - # Set initial password - @user.update!(password: 'password1') - - # Change to a new password (password1 is now in history) - @user.update!(password: 'password2') - - # Try to reuse password1 - @user.password = 'password1' - assert_not @user.valid? - assert_includes @user.errors[:password], 'has been used recently. Please choose a different password.' - end - - test 'cannot reuse any of the last 5 passwords' do - passwords = %w[pass1xxx pass2xxx pass3xxx pass4xxx pass5xxx pass6xxx] - - # Set initial password and change through 5 passwords - passwords[0..4].each do |pwd| - @user.update!(password: pwd) - end - - # Now passwords 1-5 should be in history (password 1 was moved to history when changed to 2, etc.) - # Current password is pass5xxx - - # Try to reuse each of the last 5 passwords - passwords[0..4].each do |pwd| - @user.password = pwd - assert_not @user.valid?, "Should not allow reuse of password: #{pwd}" - end - end - - test 'can use password older than last 5' do - passwords = %w[oldpass1 pass2xxx pass3xxx pass4xxx pass5xxx pass6xxx pass7xxx] - - # Cycle through 7 passwords - passwords.each do |pwd| - @user.update!(password: pwd) - end - - # oldpass1 should now be older than the last 5 and allowed - @user.password = 'oldpass1' - assert @user.valid?, 'Should allow reuse of password older than last 5' - end - - test 'can use a completely new password' do - @user.update!(password: 'existingpwd') - @user.update!(password: 'anotherpwd') - - @user.password = 'brandnewpassword' - assert @user.valid? - end - - # Password history cleanup tests - test 'only keeps last 5 password histories' do - 7.times do |i| - @user.update!(password: "password#{i}xxx") - end - - assert_equal 5, @user.password_histories.count - end - - test 'oldest password histories are deleted when limit exceeded' do - # Create 6 password changes - 6.times do |i| - @user.update!(password: "password#{i}xxx") - end - - # Should only have 5 histories - assert_equal 5, @user.password_histories.count - - # The oldest should be password0xxx (fixture password was cleaned up) - oldest_history = @user.password_histories.order(:created_at).first - assert BCrypt::Password.new(oldest_history.password_digest).is_password?('password0xxx') - end - - # password_previously_used? method tests - test 'password_previously_used? returns true for recently used password' do - @user.update!(password: 'usedpassword1') - @user.update!(password: 'usedpassword2') - - assert @user.password_previously_used?('usedpassword1') - end - - test 'password_previously_used? returns false for unused password' do - @user.update!(password: 'somepassword') - - assert_not @user.password_previously_used?('neverusedpassword') - end - - test 'password_previously_used? returns false for password older than limit' do - # Cycle through more than PASSWORD_HISTORY_LIMIT passwords - 7.times do |i| - @user.update!(password: "password#{i}xxx") - end - - # password0xxx should be old enough to reuse - assert_not @user.password_previously_used?('password0xxx') - end - - test 'password_previously_used? checks current password too' do - @user.update!(password: 'currentpassword') - - # The current password_digest is checked during validation, not in password_previously_used? - # password_previously_used? only checks history, but validation prevents current password reuse - # Let's verify the history doesn't include current password - assert_not @user.password_histories.any? { |h| BCrypt::Password.new(h.password_digest).is_password?('currentpassword') } - end - - # Edge cases - test 'password history works with special characters' do - special_password = 'P@$$w0rd!#%^&*()' - @user.update!(password: special_password) - @user.update!(password: 'newpassword123') - - @user.password = special_password - assert_not @user.valid? - end - - test 'password history works with unicode characters' do - unicode_password = 'пароль密码🔐' - @user.update!(password: unicode_password) - @user.update!(password: 'newpassword123') - - @user.password = unicode_password - assert_not @user.valid? - end - - test 'password history is deleted when user is destroyed' do - @user.update!(password: 'password1') - @user.update!(password: 'password2') - - history_ids = @user.password_histories.pluck(:id) - assert history_ids.any? - - @user.destroy! - - assert_empty PasswordHistory.where(id: history_ids) - end - - test 'multiple users have separate password histories' do - other_user = users(:two) - other_user.password_histories.destroy_all - - # Both users use the same password - @user.update!(password: 'sharedpassword') - other_user.update!(password: 'sharedpassword') - - # Change passwords - @user.update!(password: 'newpassword1') - other_user.update!(password: 'newpassword2') - - # Each user should be blocked from reusing their own history - @user.password = 'sharedpassword' - assert_not @user.valid? - - other_user.password = 'sharedpassword' - assert_not other_user.valid? - - # But can use the other user's current password (newpassword2 for @user) - @user.password = 'newpassword2' - assert @user.valid? - end -end diff --git a/test/models/session_test.rb b/test/models/session_test.rb deleted file mode 100644 index 7c47a01..0000000 --- a/test/models/session_test.rb +++ /dev/null @@ -1,180 +0,0 @@ -require 'test_helper' - -class SessionTest < ActiveSupport::TestCase - setup do - @user = users(:one) - @session = sessions(:one) - end - - # Association tests - test 'belongs to user' do - assert_equal @user, @session.user - end - - test 'requires user' do - session = Session.new(ip_address: '127.0.0.1', user_agent: 'Test') - assert_not session.valid? - assert_includes session.errors[:user], 'must exist' - end - - # current? tests - test 'current? returns true when session matches' do - assert @session.current?(@session) - end - - test 'current? returns false when session does not match' do - other_session = sessions(:two) - assert_not @session.current?(other_session) - end - - test 'current? returns false when given nil' do - assert_not @session.current?(nil) - end - - # device_name tests - test 'device_name returns iPhone for iPhone user agent' do - @session.user_agent = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15' - assert_equal 'iPhone', @session.device_name - end - - test 'device_name returns iPad for iPad user agent' do - @session.user_agent = 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15' - assert_equal 'iPad', @session.device_name - end - - test 'device_name returns Android for Android user agent' do - @session.user_agent = 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36' - assert_equal 'Android', @session.device_name - end - - test 'device_name returns Mac for Macintosh user agent' do - @session.user_agent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36' - assert_equal 'Mac', @session.device_name - end - - test 'device_name returns Mac for Mac OS user agent' do - @session.user_agent = 'Mozilla/5.0 (Mac OS X) SomeApp/1.0' - assert_equal 'Mac', @session.device_name - end - - test 'device_name returns Windows for Windows user agent' do - @session.user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' - assert_equal 'Windows', @session.device_name - end - - test 'device_name returns Linux for Linux user agent' do - @session.user_agent = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36' - assert_equal 'Linux', @session.device_name - end - - test 'device_name returns Unknown device for unrecognized user agent' do - @session.user_agent = 'SomeRandomBot/1.0' - assert_equal 'Unknown device', @session.device_name - end - - test 'device_name returns Unknown device for blank user agent' do - @session.user_agent = '' - assert_equal 'Unknown device', @session.device_name - end - - test 'device_name returns Unknown device for nil user agent' do - @session.user_agent = nil - assert_equal 'Unknown device', @session.device_name - end - - test 'device_name is case insensitive' do - @session.user_agent = 'mozilla/5.0 (IPHONE; cpu iphone os 17_0)' - assert_equal 'iPhone', @session.device_name - end - - # browser_name tests - test 'browser_name returns Chrome for Chrome user agent' do - @session.user_agent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' - assert_equal 'Chrome', @session.browser_name - end - - test 'browser_name returns Safari for Safari user agent' do - @session.user_agent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15' - assert_equal 'Safari', @session.browser_name - end - - test 'browser_name returns Firefox for Firefox user agent' do - @session.user_agent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0' - assert_equal 'Firefox', @session.browser_name - end - - test 'browser_name returns Edge for Edge user agent' do - @session.user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0' - assert_equal 'Edge', @session.browser_name - end - - test 'browser_name returns Opera for Opera user agent' do - @session.user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 OPR/106.0.0.0' - assert_equal 'Opera', @session.browser_name - end - - test 'browser_name returns Opera for classic Opera user agent' do - @session.user_agent = 'Opera/9.80 (Windows NT 6.1; WOW64) Presto/2.12.388 Version/12.18' - assert_equal 'Opera', @session.browser_name - end - - test 'browser_name returns Unknown browser for unrecognized user agent' do - @session.user_agent = 'curl/7.88.1' - assert_equal 'Unknown browser', @session.browser_name - end - - test 'browser_name returns Unknown browser for blank user agent' do - @session.user_agent = '' - assert_equal 'Unknown browser', @session.browser_name - end - - test 'browser_name returns Unknown browser for nil user agent' do - @session.user_agent = nil - assert_equal 'Unknown browser', @session.browser_name - end - - test 'browser_name is case insensitive' do - @session.user_agent = 'mozilla/5.0 CHROME/120.0' - assert_equal 'Chrome', @session.browser_name - end - - # device_description tests - test 'device_description combines device and browser' do - @session.user_agent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' - assert_equal 'Mac • Chrome', @session.device_description - end - - test 'device_description with iPhone and Safari' do - @session.user_agent = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1' - assert_equal 'iPhone • Safari', @session.device_description - end - - test 'device_description with Windows and Firefox' do - @session.user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0' - assert_equal 'Windows • Firefox', @session.device_description - end - - test 'device_description with unknown device and browser' do - @session.user_agent = nil - assert_equal 'Unknown device • Unknown browser', @session.device_description - end - - test 'device_description with Android and Chrome' do - @session.user_agent = 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36' - assert_equal 'Android • Chrome', @session.device_description - end - - # Edge cases - test 'Chrome on iOS reports as iPhone with Chrome' do - # Chrome on iOS uses WebKit and includes both Chrome and Safari in UA - @session.user_agent = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.0.0 Mobile/15E148 Safari/604.1' - assert_equal 'iPhone', @session.device_name - # Note: This will match Chrome first due to case statement order - end - - test 'Linux with Firefox' do - @session.user_agent = 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0' - assert_equal 'Linux', @session.device_name - assert_equal 'Firefox', @session.browser_name - end -end diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 6ee9573..b1aa8b3 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -97,72 +97,4 @@ class UserTest < ActiveSupport::TestCase assert_not user.admin_of_team?(other_team) end - - # Password History Tests - test 'saves password to history when password changes' do - user = users(:one) - original_digest = user.password_digest - - user.update!(password: 'newpassword123', password_confirmation: 'newpassword123') - - assert_equal 1, user.password_histories.count - assert_equal original_digest, user.password_histories.first.password_digest - end - - test 'prevents reusing recently used password' do - user = users(:one) - original_password = 'password' # from fixtures - - # Change password first time - user.update!(password: 'newpassword123', password_confirmation: 'newpassword123') - - # Try to reuse original password - user.password = original_password - user.password_confirmation = original_password - - assert_not user.valid? - assert_includes user.errors[:password], 'has been used recently. Please choose a different password.' - end - - test 'allows reusing password after history limit exceeded' do - user = users(:one) - original_password = 'password' # from fixtures - - # Change password PASSWORD_HISTORY_LIMIT + 1 times - (User::PASSWORD_HISTORY_LIMIT + 1).times do |i| - user.update!(password: "newpassword#{i}abc", password_confirmation: "newpassword#{i}abc") - end - - # Now we should be able to reuse the original password - user.password = original_password - user.password_confirmation = original_password - - assert user.valid?, "Expected user to be valid but got errors: #{user.errors.full_messages}" - end - - test 'keeps only last N passwords in history' do - user = users(:one) - - # Change password more times than the limit - (User::PASSWORD_HISTORY_LIMIT + 3).times do |i| - user.update!(password: "testpassword#{i}x", password_confirmation: "testpassword#{i}x") - end - - assert_equal User::PASSWORD_HISTORY_LIMIT, user.password_histories.count - end - - test 'password_previously_used? returns true for recently used password' do - user = users(:one) - original_password = 'password' - - user.update!(password: 'newpassword123', password_confirmation: 'newpassword123') - - assert user.password_previously_used?(original_password) - end - - test 'password_previously_used? returns false for never used password' do - user = users(:one) - - assert_not user.password_previously_used?('neverusedpassword123') - end end