From e14bada114c57dbf6d55df324881be02fa4c2d33 Mon Sep 17 00:00:00 2001 From: Wild Karl-Heinz Date: Mon, 28 Sep 2026 20:09:06 +0200 Subject: [PATCH] Add Rails backend: accounts via magic link, server documents, sharing The app becomes a Rails 8 application; the Vue frontend moves to app/frontend and is served through vite_rails. - Without an account documents stay in the browser (IndexedDB). Sign-in is by magic link or the 6-digit code from the same mail; afterwards local documents can be uploaded into the account. - Server documents (/d/) with versioned saves and a single-editor lock per browser tab; others see them read-only and follow by polling. - Sharing via link (works without an account) and via e-mail invitation, roles viewer/editor; the owner manages links and people. - Store gains a read-only mode that blocks all content changes. - Solid Queue, Cache and Cable share the one PostgreSQL database. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 22 + .ruby-version | 1 + Gemfile | 46 + Gemfile.lock | 315 +++++++ Procfile.dev | 2 + README.md | 41 +- Rakefile | 6 + app/controllers/api/auth_controller.rb | 37 + app/controllers/api/base_controller.rb | 28 + .../api/document_locks_controller.rb | 29 + app/controllers/api/documents_controller.rb | 87 ++ app/controllers/api/joins_controller.rb | 17 + app/controllers/api/me_controller.rb | 16 + app/controllers/api/shares_controller.rb | 74 ++ app/controllers/application_controller.rb | 6 + app/controllers/auth_controller.rb | 22 + app/controllers/concerns/.keep | 0 app/controllers/concerns/authentication.rb | 43 + app/controllers/spa_controller.rb | 9 + {src => app/frontend}/App.vue | 0 .../frontend}/components/AppToolbar.vue | 117 ++- .../frontend}/components/BaseModal.vue | 0 .../frontend}/components/ButtonView.vue | 0 .../frontend}/components/CanvasObject.vue | 0 .../frontend}/components/ChartView.vue | 0 .../frontend}/components/CodeReference.vue | 0 .../frontend}/components/ContextMenu.vue | 0 .../components/CustomFunctionsModal.vue | 0 .../components/DependencyGraphView.vue | 0 app/frontend/components/DocumentsModal.vue | 322 +++++++ .../frontend}/components/FormulaBar.vue | 3 +- .../frontend}/components/FormulaSuggest.vue | 0 app/frontend/components/LoginModal.vue | 160 ++++ .../frontend}/components/MacrosModal.vue | 0 .../frontend}/components/NamedRangesModal.vue | 0 .../frontend}/components/SettingsPanel.vue | 0 app/frontend/components/ShareModal.vue | 310 +++++++ .../frontend}/components/SheetCanvas.vue | 0 .../frontend}/components/SheetTabs.vue | 0 .../frontend}/components/StatusBar.vue | 0 .../frontend}/components/TableGrid.vue | 4 + {src => app/frontend}/components/TextBox.vue | 0 {src => app/frontend}/components/TheLogo.vue | 0 .../frontend}/components/ToolbarIcon.vue | 0 app/frontend/components/UploadModal.vue | 93 ++ .../composables/useFormulaAutocomplete.ts | 0 .../composables/useKeyboardPointMode.ts | 0 {src => app/frontend}/composables/useTheme.ts | 0 .../frontend}/composables/useToasts.ts | 0 {src => app/frontend}/config/brand.ts | 0 .../data/__tests__/codeReference.spec.ts | 0 {src => app/frontend}/data/codeReference.ts | 0 {src => app/frontend}/data/seed.ts | 0 {src => app/frontend}/engine/README.md | 0 .../engine/__tests__/dateFunctions.spec.ts | 0 .../frontend}/engine/__tests__/dates.spec.ts | 0 .../engine/__tests__/evaluator.spec.ts | 0 .../engine/__tests__/functions.spec.ts | 0 .../frontend}/engine/__tests__/graph.spec.ts | 0 .../frontend}/engine/__tests__/lexer.spec.ts | 0 .../frontend}/engine/__tests__/macros.spec.ts | 0 .../frontend}/engine/__tests__/parser.spec.ts | 0 .../frontend}/engine/__tests__/refs.spec.ts | 0 .../engine/__tests__/userFunctions.spec.ts | 0 .../engine/__tests__/workbook.spec.ts | 0 {src => app/frontend}/engine/dates.ts | 0 {src => app/frontend}/engine/evaluator.ts | 0 {src => app/frontend}/engine/functions.ts | 0 {src => app/frontend}/engine/graph.ts | 0 {src => app/frontend}/engine/index.ts | 0 {src => app/frontend}/engine/lexer.ts | 0 {src => app/frontend}/engine/parser.ts | 0 {src => app/frontend}/engine/refs.ts | 0 {src => app/frontend}/engine/types.ts | 0 {src => app/frontend}/engine/userFunctions.ts | 0 {src => app/frontend}/engine/workbook.ts | 0 .../frontend/entrypoints/application.ts | 6 +- {src => app/frontend}/env.d.ts | 0 .../frontend}/io/__tests__/library.spec.ts | 0 .../frontend}/io/__tests__/num.spec.ts | 0 .../frontend}/io/__tests__/roundtrip.spec.ts | 0 app/frontend/io/api.ts | 116 +++ {src => app/frontend}/io/csv.ts | 0 {src => app/frontend}/io/download.ts | 0 {src => app/frontend}/io/json.ts | 0 {src => app/frontend}/io/library.ts | 0 {src => app/frontend}/io/num.ts | 0 {src => app/frontend}/io/operations.ts | 0 {src => app/frontend}/io/xlsx.ts | 0 {src => app/frontend}/numolus-logo.svg | 0 .../store/__tests__/document.spec.ts | 0 .../frontend}/store/__tests__/format.spec.ts | 0 {src => app/frontend}/store/document.ts | 53 +- {src => app/frontend}/store/format.ts | 0 app/frontend/store/library.ts | 852 ++++++++++++++++++ app/frontend/store/session.ts | 36 + {src => app/frontend}/store/types.ts | 0 {src => app/frontend}/styles/main.css | 0 app/helpers/application_helper.rb | 2 + app/jobs/application_job.rb | 7 + app/mailers/application_mailer.rb | 4 + app/mailers/auth_mailer.rb | 15 + app/models/application_record.rb | 3 + app/models/concerns/.keep | 0 app/models/current.rb | 4 + app/models/document.rb | 84 ++ app/models/document_membership.rb | 25 + app/models/magic_link.rb | 88 ++ app/models/session.rb | 4 + app/models/share_link.rb | 27 + app/models/user.rb | 28 + app/views/auth/invalid.html.erb | 3 + app/views/auth/show.html.erb | 11 + app/views/auth_mailer/invitation.html.erb | 4 + app/views/auth_mailer/invitation.text.erb | 7 + app/views/auth_mailer/magic_link.html.erb | 5 + app/views/auth_mailer/magic_link.text.erb | 9 + app/views/layouts/auth.html.erb | 30 + app/views/layouts/mailer.html.erb | 13 + app/views/layouts/mailer.text.erb | 1 + app/views/pwa/manifest.json.erb | 22 + app/views/pwa/service-worker.js | 26 + index.html => app/views/spa/show.html.erb | 7 +- bin/brakeman | 7 + bin/bundler-audit | 6 + bin/ci | 6 + bin/dev | 9 + bin/jobs | 6 + bin/rails | 4 + bin/rake | 4 + bin/setup | 36 + bin/vite | 27 + config.ru | 6 + config/application.rb | 42 + config/boot.rb | 4 + config/bundler-audit.yml | 5 + config/cable.yml | 16 + config/cache.yml | 15 + config/ci.rb | 21 + config/credentials.yml.enc | 1 + config/database.yml | 67 ++ config/environment.rb | 5 + config/environments/development.rb | 72 ++ config/environments/production.rb | 88 ++ config/environments/test.rb | 50 + .../initializers/content_security_policy.rb | 38 + .../initializers/filter_parameter_logging.rb | 8 + config/initializers/inflections.rb | 16 + config/locales/en.yml | 31 + config/puma.rb | 42 + config/queue.yml | 18 + config/recurring.yml | 15 + config/routes.rb | 33 + config/vite.json | 17 + .../20260928120000_create_core_tables.rb | 71 ++ ...0260928120100_create_solid_queue_tables.rb | 164 ++++ ...0260928120200_create_solid_cache_tables.rb | 16 + ...0260928120300_create_solid_cable_tables.rb | 14 + db/schema.rb | 291 ++++++ db/seeds.rb | 9 + docs/konzept-teilen-und-zusammenarbeit.md | 17 + eslint.config.js | 2 +- lib/tasks/.keep | 0 log/.keep | 0 package-lock.json | 585 +++++++++--- package.json | 10 +- public/400.html | 135 +++ public/404.html | 135 +++ public/406-unsupported-browser.html | 135 +++ public/422.html | 135 +++ public/500.html | 135 +++ public/icon.png | Bin 0 -> 4166 bytes public/icon.svg | 3 + public/robots.txt | 1 + script/.keep | 0 src/components/DocumentsModal.vue | 211 ----- src/store/library.ts | 341 ------- test/integration/.keep | 0 test/integration/auth_flow_test.rb | 52 ++ test/integration/documents_api_test.rb | 111 +++ test/integration/shares_api_test.rb | 55 ++ test/models/.keep | 0 test/models/document_test.rb | 63 ++ test/models/magic_link_test.rb | 49 + test/test_helper.rb | 32 + tmp/.keep | 0 tmp/pids/.keep | 0 tsconfig.json | 2 +- vendor/.keep | 0 vite.config.ts | 8 +- 190 files changed, 6077 insertions(+), 722 deletions(-) create mode 100644 .ruby-version create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 Procfile.dev create mode 100644 Rakefile create mode 100644 app/controllers/api/auth_controller.rb create mode 100644 app/controllers/api/base_controller.rb create mode 100644 app/controllers/api/document_locks_controller.rb create mode 100644 app/controllers/api/documents_controller.rb create mode 100644 app/controllers/api/joins_controller.rb create mode 100644 app/controllers/api/me_controller.rb create mode 100644 app/controllers/api/shares_controller.rb create mode 100644 app/controllers/application_controller.rb create mode 100644 app/controllers/auth_controller.rb create mode 100644 app/controllers/concerns/.keep create mode 100644 app/controllers/concerns/authentication.rb create mode 100644 app/controllers/spa_controller.rb rename {src => app/frontend}/App.vue (100%) rename {src => app/frontend}/components/AppToolbar.vue (87%) rename {src => app/frontend}/components/BaseModal.vue (100%) rename {src => app/frontend}/components/ButtonView.vue (100%) rename {src => app/frontend}/components/CanvasObject.vue (100%) rename {src => app/frontend}/components/ChartView.vue (100%) rename {src => app/frontend}/components/CodeReference.vue (100%) rename {src => app/frontend}/components/ContextMenu.vue (100%) rename {src => app/frontend}/components/CustomFunctionsModal.vue (100%) rename {src => app/frontend}/components/DependencyGraphView.vue (100%) create mode 100644 app/frontend/components/DocumentsModal.vue rename {src => app/frontend}/components/FormulaBar.vue (99%) rename {src => app/frontend}/components/FormulaSuggest.vue (100%) create mode 100644 app/frontend/components/LoginModal.vue rename {src => app/frontend}/components/MacrosModal.vue (100%) rename {src => app/frontend}/components/NamedRangesModal.vue (100%) rename {src => app/frontend}/components/SettingsPanel.vue (100%) create mode 100644 app/frontend/components/ShareModal.vue rename {src => app/frontend}/components/SheetCanvas.vue (100%) rename {src => app/frontend}/components/SheetTabs.vue (100%) rename {src => app/frontend}/components/StatusBar.vue (100%) rename {src => app/frontend}/components/TableGrid.vue (99%) rename {src => app/frontend}/components/TextBox.vue (100%) rename {src => app/frontend}/components/TheLogo.vue (100%) rename {src => app/frontend}/components/ToolbarIcon.vue (100%) create mode 100644 app/frontend/components/UploadModal.vue rename {src => app/frontend}/composables/useFormulaAutocomplete.ts (100%) rename {src => app/frontend}/composables/useKeyboardPointMode.ts (100%) rename {src => app/frontend}/composables/useTheme.ts (100%) rename {src => app/frontend}/composables/useToasts.ts (100%) rename {src => app/frontend}/config/brand.ts (100%) rename {src => app/frontend}/data/__tests__/codeReference.spec.ts (100%) rename {src => app/frontend}/data/codeReference.ts (100%) rename {src => app/frontend}/data/seed.ts (100%) rename {src => app/frontend}/engine/README.md (100%) rename {src => app/frontend}/engine/__tests__/dateFunctions.spec.ts (100%) rename {src => app/frontend}/engine/__tests__/dates.spec.ts (100%) rename {src => app/frontend}/engine/__tests__/evaluator.spec.ts (100%) rename {src => app/frontend}/engine/__tests__/functions.spec.ts (100%) rename {src => app/frontend}/engine/__tests__/graph.spec.ts (100%) rename {src => app/frontend}/engine/__tests__/lexer.spec.ts (100%) rename {src => app/frontend}/engine/__tests__/macros.spec.ts (100%) rename {src => app/frontend}/engine/__tests__/parser.spec.ts (100%) rename {src => app/frontend}/engine/__tests__/refs.spec.ts (100%) rename {src => app/frontend}/engine/__tests__/userFunctions.spec.ts (100%) rename {src => app/frontend}/engine/__tests__/workbook.spec.ts (100%) rename {src => app/frontend}/engine/dates.ts (100%) rename {src => app/frontend}/engine/evaluator.ts (100%) rename {src => app/frontend}/engine/functions.ts (100%) rename {src => app/frontend}/engine/graph.ts (100%) rename {src => app/frontend}/engine/index.ts (100%) rename {src => app/frontend}/engine/lexer.ts (100%) rename {src => app/frontend}/engine/parser.ts (100%) rename {src => app/frontend}/engine/refs.ts (100%) rename {src => app/frontend}/engine/types.ts (100%) rename {src => app/frontend}/engine/userFunctions.ts (100%) rename {src => app/frontend}/engine/workbook.ts (100%) rename src/main.ts => app/frontend/entrypoints/application.ts (67%) rename {src => app/frontend}/env.d.ts (100%) rename {src => app/frontend}/io/__tests__/library.spec.ts (100%) rename {src => app/frontend}/io/__tests__/num.spec.ts (100%) rename {src => app/frontend}/io/__tests__/roundtrip.spec.ts (100%) create mode 100644 app/frontend/io/api.ts rename {src => app/frontend}/io/csv.ts (100%) rename {src => app/frontend}/io/download.ts (100%) rename {src => app/frontend}/io/json.ts (100%) rename {src => app/frontend}/io/library.ts (100%) rename {src => app/frontend}/io/num.ts (100%) rename {src => app/frontend}/io/operations.ts (100%) rename {src => app/frontend}/io/xlsx.ts (100%) rename {src => app/frontend}/numolus-logo.svg (100%) rename {src => app/frontend}/store/__tests__/document.spec.ts (100%) rename {src => app/frontend}/store/__tests__/format.spec.ts (100%) rename {src => app/frontend}/store/document.ts (97%) rename {src => app/frontend}/store/format.ts (100%) create mode 100644 app/frontend/store/library.ts create mode 100644 app/frontend/store/session.ts rename {src => app/frontend}/store/types.ts (100%) rename {src => app/frontend}/styles/main.css (100%) create mode 100644 app/helpers/application_helper.rb create mode 100644 app/jobs/application_job.rb create mode 100644 app/mailers/application_mailer.rb create mode 100644 app/mailers/auth_mailer.rb create mode 100644 app/models/application_record.rb create mode 100644 app/models/concerns/.keep create mode 100644 app/models/current.rb create mode 100644 app/models/document.rb create mode 100644 app/models/document_membership.rb create mode 100644 app/models/magic_link.rb create mode 100644 app/models/session.rb create mode 100644 app/models/share_link.rb create mode 100644 app/models/user.rb create mode 100644 app/views/auth/invalid.html.erb create mode 100644 app/views/auth/show.html.erb create mode 100644 app/views/auth_mailer/invitation.html.erb create mode 100644 app/views/auth_mailer/invitation.text.erb create mode 100644 app/views/auth_mailer/magic_link.html.erb create mode 100644 app/views/auth_mailer/magic_link.text.erb create mode 100644 app/views/layouts/auth.html.erb create mode 100644 app/views/layouts/mailer.html.erb create mode 100644 app/views/layouts/mailer.text.erb create mode 100644 app/views/pwa/manifest.json.erb create mode 100644 app/views/pwa/service-worker.js rename index.html => app/views/spa/show.html.erb (90%) create mode 100755 bin/brakeman create mode 100755 bin/bundler-audit create mode 100755 bin/ci create mode 100755 bin/dev create mode 100755 bin/jobs create mode 100755 bin/rails create mode 100755 bin/rake create mode 100755 bin/setup create mode 100755 bin/vite create mode 100644 config.ru create mode 100644 config/application.rb create mode 100644 config/boot.rb create mode 100644 config/bundler-audit.yml create mode 100644 config/cable.yml create mode 100644 config/cache.yml create mode 100644 config/ci.rb create mode 100644 config/credentials.yml.enc create mode 100644 config/database.yml create mode 100644 config/environment.rb create mode 100644 config/environments/development.rb create mode 100644 config/environments/production.rb create mode 100644 config/environments/test.rb create mode 100644 config/initializers/content_security_policy.rb create mode 100644 config/initializers/filter_parameter_logging.rb create mode 100644 config/initializers/inflections.rb create mode 100644 config/locales/en.yml create mode 100644 config/puma.rb create mode 100644 config/queue.yml create mode 100644 config/recurring.yml create mode 100644 config/routes.rb create mode 100644 config/vite.json create mode 100644 db/migrate/20260928120000_create_core_tables.rb create mode 100644 db/migrate/20260928120100_create_solid_queue_tables.rb create mode 100644 db/migrate/20260928120200_create_solid_cache_tables.rb create mode 100644 db/migrate/20260928120300_create_solid_cable_tables.rb create mode 100644 db/schema.rb create mode 100644 db/seeds.rb create mode 100644 lib/tasks/.keep create mode 100644 log/.keep create mode 100644 public/400.html create mode 100644 public/404.html create mode 100644 public/406-unsupported-browser.html create mode 100644 public/422.html create mode 100644 public/500.html create mode 100644 public/icon.png create mode 100644 public/icon.svg create mode 100644 public/robots.txt create mode 100644 script/.keep delete mode 100644 src/components/DocumentsModal.vue delete mode 100644 src/store/library.ts create mode 100644 test/integration/.keep create mode 100644 test/integration/auth_flow_test.rb create mode 100644 test/integration/documents_api_test.rb create mode 100644 test/integration/shares_api_test.rb create mode 100644 test/models/.keep create mode 100644 test/models/document_test.rb create mode 100644 test/models/magic_link_test.rb create mode 100644 test/test_helper.rb create mode 100644 tmp/.keep create mode 100644 tmp/pids/.keep create mode 100644 vendor/.keep diff --git a/.gitignore b/.gitignore index b43c151..50e7110 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,25 @@ coverage .DS_Store .vscode/* !.vscode/extensions.json + +# Vite Ruby +/public/vite* +node_modules +# Vite uses dotenv and suggests to ignore local-only env files. See +# https://vite.dev/guide/env-and-mode.html#env-files +*.local + +# Rails +/.bundle +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep +!/tmp/pids/ +/tmp/pids/* +!/tmp/pids/.keep +/storage/* +!/storage/.keep +/config/master.key +/.env* +/vendor/gems diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..61a52c9 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +ruby-3.4.9 diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..c0aaf7a --- /dev/null +++ b/Gemfile @@ -0,0 +1,46 @@ +source "https://rubygems.org" + +# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" +gem "rails", "~> 8.1.3", ">= 8.1.3.1" +# Use postgresql as the database for Active Record +gem "pg", "~> 1.1" +# Use the Puma web server [https://github.com/puma/puma] +gem "puma", ">= 5.0" + +# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] +# gem "bcrypt", "~> 3.1.7" + +# Windows does not include zoneinfo files, so bundle the tzinfo-data gem +gem "tzinfo-data", platforms: %i[ windows jruby ] + +# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable +gem "solid_cache" +gem "solid_queue" +gem "solid_cable" + +# Reduces boot times through caching; required in config/boot.rb +gem "bootsnap", require: false + +group :development, :test do + # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem + gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" + + # Audits gems for known security defects (use config/bundler-audit.yml to ignore issues) + gem "bundler-audit", require: false + + # Static analysis for security vulnerabilities [https://brakemanscanner.org/] + gem "brakeman", require: false +end + +group :development do + # Use console on exceptions pages [https://github.com/rails/web-console] + gem "web-console" +end + +# Vite as the frontend build, integrated into Rails [https://vite-ruby.netlify.app] +gem "vite_rails" + +group :development do + # Mails open in the browser at /letter_opener instead of being sent + gem "letter_opener_web" +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..803ef80 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,315 @@ +GEM + remote: https://rubygems.org/ + specs: + action_text-trix (2.1.19) + railties + actioncable (8.1.4) + actionpack (= 8.1.4) + activesupport (= 8.1.4) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (8.1.4) + actionpack (= 8.1.4) + activejob (= 8.1.4) + activerecord (= 8.1.4) + activestorage (= 8.1.4) + activesupport (= 8.1.4) + mail (>= 2.8.0) + actionmailer (8.1.4) + actionpack (= 8.1.4) + actionview (= 8.1.4) + activejob (= 8.1.4) + activesupport (= 8.1.4) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.1.4) + actionview (= 8.1.4) + activesupport (= 8.1.4) + nokogiri (>= 1.8.5) + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (8.1.4) + action_text-trix (~> 2.1.15) + actionpack (= 8.1.4) + activerecord (= 8.1.4) + activestorage (= 8.1.4) + activesupport (= 8.1.4) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (8.1.4) + activesupport (= 8.1.4) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (8.1.4) + activesupport (= 8.1.4) + globalid (>= 0.3.6) + activemodel (8.1.4) + activesupport (= 8.1.4) + activerecord (8.1.4) + activemodel (= 8.1.4) + activesupport (= 8.1.4) + timeout (>= 0.4.0) + activestorage (8.1.4) + actionpack (= 8.1.4) + activejob (= 8.1.4) + activerecord (= 8.1.4) + activesupport (= 8.1.4) + marcel (~> 1.0) + activesupport (8.1.4) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + json + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + base64 (0.3.0) + bigdecimal (4.1.3) + bindex (0.8.1) + bootsnap (1.26.0) + msgpack (~> 1.5) + brakeman (8.0.6) + racc + builder (3.3.0) + bundler-audit (0.9.3) + bundler (>= 1.2.0) + thor (~> 1.0) + childprocess (5.1.0) + logger (~> 1.5) + concurrent-ruby (1.3.8) + connection_pool (3.0.2) + crass (1.0.7) + date (3.5.1) + debug (1.11.1) + irb (~> 1.10) + reline (>= 0.3.8) + drb (2.2.3) + dry-cli (1.4.1) + erb (6.0.7) + erubi (1.13.1) + et-orbi (1.4.2) + tzinfo + fugit (1.14.0) + et-orbi (~> 1.4) + raabro (~> 1.4) + globalid (1.4.0) + activesupport (>= 6.1) + i18n (1.15.2) + concurrent-ruby (~> 1.0) + io-console (0.9.4) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (3.0.2) + launchy (3.1.1) + addressable (~> 2.8) + childprocess (~> 5.0) + logger (~> 1.6) + letter_opener (1.10.0) + launchy (>= 2.2, < 4) + letter_opener_web (3.0.0) + actionmailer (>= 6.1) + letter_opener (~> 1.9) + railties (>= 6.1) + rexml + logger (1.7.0) + loofah (2.25.2) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.9.1) + logger + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.2.1) + mini_mime (1.1.5) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) + msgpack (1.8.5) + mutex_m (0.3.0) + net-imap (0.6.7) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.4.0) + timeout + net-smtp (0.5.1) + net-protocol + nio4r (2.7.5) + nokogiri (1.19.4-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.19.4-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-arm-linux-musl) + racc (~> 1.4) + nokogiri (1.19.4-arm64-darwin) + racc (~> 1.4) + nokogiri (1.19.4-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.19.4-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-x86_64-linux-musl) + racc (~> 1.4) + 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-darwin) + pg (1.6.3-x86_64-linux) + pg (1.6.3-x86_64-linux-musl) + pp (0.6.4) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + public_suffix (7.0.5) + puma (8.0.2) + nio4r (~> 2.0) + raabro (1.5.0) + racc (1.8.1) + rack (3.2.7) + rack-proxy (2.0.1) + rack (>= 2.0, < 4) + rack-session (2.1.2) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.3.1) + rack (>= 3) + rails (8.1.4) + actioncable (= 8.1.4) + actionmailbox (= 8.1.4) + actionmailer (= 8.1.4) + actionpack (= 8.1.4) + actiontext (= 8.1.4) + actionview (= 8.1.4) + activejob (= 8.1.4) + activemodel (= 8.1.4) + activerecord (= 8.1.4) + activestorage (= 8.1.4) + activesupport (= 8.1.4) + bundler (>= 1.15.0) + railties (= 8.1.4) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.7.1) + loofah (~> 2.25, >= 2.25.2) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + railties (8.1.4) + actionpack (= 8.1.4) + activesupport (= 8.1.4) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) + zeitwerk (~> 2.6) + rake (13.4.2) + rbs (4.2.0) + logger + prism (>= 1.6.0) + tsort + rdoc (8.0.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort + reline (0.7.0) + io-console (~> 0.5) + rexml (3.4.4) + securerandom (0.4.1) + solid_cable (4.1.0) + actioncable (>= 7.2) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_cache (1.0.10) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_queue (1.7.0) + activejob (>= 7.1) + activerecord (>= 7.1) + concurrent-ruby (>= 1.3.1) + fugit (~> 1.11) + railties (>= 7.1) + thor (>= 1.3.1) + thor (1.5.0) + timeout (0.6.1) + tsort (0.2.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + uri (1.1.1) + useragent (0.16.11) + vite_rails (3.11.1) + railties (>= 5.1, < 9) + vite_ruby (~> 3.0, >= 3.2.2) + vite_ruby (3.11.0) + dry-cli (>= 0.7, < 2) + logger (~> 1.6) + mutex_m + rack-proxy (>= 0.6.1) + zeitwerk (~> 2.2) + web-console (4.3.0) + actionview (>= 8.0.0) + bindex (>= 0.4.0) + railties (>= 8.0.0) + websocket-driver (0.8.2) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + zeitwerk (2.8.3) + +PLATFORMS + aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin + x86_64-darwin + x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + bootsnap + brakeman + bundler-audit + debug + letter_opener_web + pg (~> 1.1) + puma (>= 5.0) + rails (~> 8.1.3, >= 8.1.3.1) + solid_cable + solid_cache + solid_queue + tzinfo-data + vite_rails + web-console + +BUNDLED WITH + 2.6.9 diff --git a/Procfile.dev b/Procfile.dev new file mode 100644 index 0000000..25b701e --- /dev/null +++ b/Procfile.dev @@ -0,0 +1,2 @@ +web: bin/rails server -p 3000 +vite: bin/vite dev diff --git a/README.md b/README.md index db092f6..ad2214a 100644 --- a/README.md +++ b/README.md @@ -4,21 +4,34 @@ Eine browserbasierte Tabellenkalkulation nach dem Vorbild von **Apple Numbers**. 1. **Handwerk** — eine von Grund auf selbst geschriebene, schlanke Formel-Engine (Tokenizer, Parser, AST, Abhängigkeitsgraph, Neuberechnung, Zirkelbezug- - Erkennung). Dokumentiert in [`src/engine/README.md`](src/engine/README.md). + Erkennung). Dokumentiert in [`app/frontend/engine/README.md`](app/frontend/engine/README.md). 2. **Produkt** — eine polierte, Numbers-nahe Oberfläche: jedes Blatt ist eine **freie Fläche (Canvas)**, auf die man beliebig viele **Tabellen- und Diagramm-Objekte** setzt, verschiebt, skaliert und befüllt. ```bash -npm install -npm run dev # Vite-Devserver -npm run test # Vitest-Suite (Engine + Store + IO), 570 Tests -npm run build # Typecheck + Produktionsbuild +bin/setup # Gems, npm-Pakete, Datenbank (PostgreSQL) anlegen +bin/dev # Rails (Port 3000) + Vite-Devserver mit HMR (Port 3036) +npm run test # Vitest-Suite (Engine + Store + IO) +bin/rails test # Server-Tests (Models, API, Anmeldung, Teilen) +npm run build # Typecheck + Produktionsbuild nach public/vite npm run lint # ESLint (flat config) ``` -Alles läuft **rein clientseitig** — kein Server, keine Netzwerk-Requests. Datei- -I/O über File API und Blob-URLs. +Die App ist eine **Rails-8-Anwendung** mit dem Vue-Frontend unter +[`app/frontend/`](app/frontend/) (eingebunden über `vite_rails`). Ohne Konto +bleibt alles **im Browser** (IndexedDB). Mit Konto (Anmeldung per Magic Link, +kein Passwort) liegen Dokumente auf dem Server und lassen sich teilen – per +Link oder per Einladung, mit den Rollen „ansehen“ und „bearbeiten“. Solange +es keine Live-Zusammenarbeit gibt, bearbeitet immer nur ein Browser-Tab ein +Server-Dokument; alle anderen sehen es schreibgeschützt und folgen den +Änderungen. Details: [`docs/konzept-teilen-und-zusammenarbeit.md`](docs/konzept-teilen-und-zusammenarbeit.md). + +Mails landen in der Entwicklung nicht im Postfach, sondern unter +. Produktion braucht `SMTP_ADDRESS`, +`SMTP_USER_NAME`, `SMTP_PASSWORD`, `NUMOLUS_MAIL_FROM`, `NUMOLUS_HOST` und +`NUMOLUS_DATABASE_PASSWORD`; Solid Queue, Cache und Cable liegen in derselben +Datenbank wie die Daten. --- @@ -268,7 +281,7 @@ eingebauter Funktionsname: `=MARGE(Umsatz!B2; Umsatz!C2)`. sich automatisch umschreiben ließe, nur ihren Aufruf-Namen. - Ein Aufruf wird **beim Parsen der aufrufenden Zelle in ihre AST eingesetzt** (Inlining, keine Laufzeit-Closures) — siehe - [`src/engine/userFunctions.ts`](src/engine/userFunctions.ts) und die + [`app/frontend/engine/userFunctions.ts`](app/frontend/engine/userFunctions.ts) und die Architektur-Tabelle unten. Dadurch braucht Evaluator/Graph keine Änderung, und auch Zellen, die *nur* im Funktionskörper referenziert werden (nicht im Aufruf), lösen bei Änderung korrekt eine Neuberechnung aus. @@ -336,7 +349,7 @@ berechenbar ist. Eine `FOR`-Schleife über insgesamt mehr als 10.000 Zellen Funktion, deren Ausführung endet, ohne dass `RETURN` je gefeuert hat, ebenso — das Formular warnt davor zusätzlich schon **beim Speichern**, wenn nicht erkennbar jeder Codepfad ein `RETURN` erreicht. Details: -[`src/engine/README.md`](src/engine/README.md)s „Schleifen"-Abschnitt. +[`app/frontend/engine/README.md`](app/frontend/engine/README.md)s „Schleifen"-Abschnitt. #### SUMIF/COUNTIF/AVERAGEIF nachbauen @@ -644,7 +657,7 @@ unabhängig von der Locale), **Zell-Textfarbe**, ### Einstellungen (⚙, links neben dem Titel) Ein Zahnrad neben der Überschrift öffnet eine linke Inspector-Schublade -([`SettingsPanel.vue`](src/components/SettingsPanel.vue)), die die Fläche zur +([`SettingsPanel.vue`](app/frontend/components/SettingsPanel.vue)), die die Fläche zur Seite schiebt (auf schmalen Viewports überlagert sie). Sie bündelt alle wichtigen Punkte: @@ -1034,7 +1047,7 @@ Fehlercode nötig). ## Architektur der Engine-Module -Framework-agnostisches ES-Modul unter [`src/engine/`](src/engine/) — **keine** +Framework-agnostisches ES-Modul unter [`app/frontend/engine/`](app/frontend/engine/) — **keine** Formel-/Spreadsheet-Library. Kennt kein Vue, kein DOM. Die adressierbare Einheit ist eine **Tabelle**; der Abhängigkeitsgraph läuft dokumentweit über alle Tabellen aller Blätter. @@ -1054,7 +1067,7 @@ Tabellen aller Blätter. `graph.topoOrder()` → Zyklus-Zellen `#CIRC`, Rest topologisch auswerten, nicht betroffene Zellen behalten ihren Wert. -**Anbindung an Vue.** [`src/store/document.ts`](src/store/document.ts) ist das +**Anbindung an Vue.** [`app/frontend/store/document.ts`](app/frontend/store/document.ts) ist das „eigene `reactive()`-Modul": es hält eine `Workbook`-Instanz und einen `recalcTick`-Ref. Die UI liest `store.cellValue(...)`, das `recalcTick` berührt — nach jeder Neuberechnung wird der Tick erhöht, jede Formelzelle re-rendert also @@ -1123,7 +1136,7 @@ Blatt **„Analyse"** mit drei Tabellen-Objekten (**Umsatz**, **Kosten**, **Übersicht**) und einem **Diagramm** (Umsatz je Region), plus ein leeres Blatt **„Blatt 2"**. „Übersicht" demonstriert tabellenübergreifende Bezüge und die vier Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`, -`E2 = =D2/B2*100`. Siehe [`src/data/seed.ts`](src/data/seed.ts). +`E2 = =D2/B2*100`. Siehe [`app/frontend/data/seed.ts`](app/frontend/data/seed.ts). > **Hinweis zur Aufgabenstellung:** Die Akzeptanzliste nennt „Übersicht!B2 = > 6070". Die explizit angegebene Nord-Zeile ergibt jedoch @@ -1498,7 +1511,7 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`, ## Projektstruktur ``` -src/ +app/frontend/ engine/ # Formel-Engine (Eigenbau) + Unit-Tests store/ # reaktiver Dokument-Store (v2 Canvas-Modell), Formatierung io/ # csv / json (Eigenbau, inkl. v1→v2), num (ZIP via fflate, lazy), xlsx (SheetJS, lazy) diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..9a5ea73 --- /dev/null +++ b/Rakefile @@ -0,0 +1,6 @@ +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require_relative "config/application" + +Rails.application.load_tasks diff --git a/app/controllers/api/auth_controller.rb b/app/controllers/api/auth_controller.rb new file mode 100644 index 0000000..a4c9856 --- /dev/null +++ b/app/controllers/api/auth_controller.rb @@ -0,0 +1,37 @@ +module Api + class AuthController < BaseController + rate_limit to: 20, within: 1.hour, only: :magic_link, with: -> { too_many } + rate_limit to: 30, within: 15.minutes, only: :code, with: -> { too_many } + + # POST /api/auth/magic_link { email, return_to } + # Always the same answer, so it can't be used to find out who has an account. + def magic_link + email = params.require(:email).to_s + unless MagicLink.throttled?(email) + link = MagicLink.issue!(email:, return_to: params[:return_to]) + AuthMailer.magic_link(link, link.token, link.code).deliver_later + end + render json: { ok: true } + rescue ActiveRecord::RecordInvalid + render json: { error: "invalid_email" }, status: :unprocessable_entity + end + + # POST /api/auth/code { email, code } -- the 6-digit code from the mail + def code + link = MagicLink.find_by_code(params.require(:email), params.require(:code)) + return render(json: { error: "invalid_code" }, status: :unprocessable_entity) unless link + start_session_for(link.redeem!) + render json: { user: Current.user.as_json, redirect: link.destination } + end + + # DELETE /api/auth/session + def logout + terminate_session + render json: { ok: true } + end + + private + + def too_many = render(json: { error: "rate_limited" }, status: :too_many_requests) + end +end diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb new file mode 100644 index 0000000..cf8b088 --- /dev/null +++ b/app/controllers/api/base_controller.rb @@ -0,0 +1,28 @@ +module Api + class BaseController < ApplicationController + # the app sends the CSRF token from the page's meta tag as X-CSRF-Token + protect_from_forgery with: :exception + + rescue_from ActiveRecord::RecordNotFound do + render json: { error: "not_found" }, status: :not_found + end + rescue_from ActiveRecord::RecordInvalid do |e| + render json: { error: "invalid", messages: e.record.errors.full_messages }, status: :unprocessable_entity + end + rescue_from ActionController::InvalidAuthenticityToken do + render json: { error: "csrf" }, status: :unprocessable_entity + end + + private + + # a share link token, for visitors (with or without account) who got the link + def share_token = request.headers["X-Share-Token"].presence + + # identifies the lock holder: one browser tab + def client_id = request.headers["X-Client-Id"].to_s[0, 64].presence || "anon" + + def lock_holder = "#{Current.user&.id || 'anon'}:#{client_id}" + + def holder_name = Current.user&.name || "Jemand mit dem Link" + end +end diff --git a/app/controllers/api/document_locks_controller.rb b/app/controllers/api/document_locks_controller.rb new file mode 100644 index 0000000..cad98e1 --- /dev/null +++ b/app/controllers/api/document_locks_controller.rb @@ -0,0 +1,29 @@ +module Api + # The edit lock of a server document: one browser tab edits at a time, + # everyone else sees the document read-only until the lock is released or + # expires (Document::LOCK_TTL without a heartbeat). + class DocumentLocksController < BaseController + before_action :load_document + + # POST /api/documents/:id/lock -- take or refresh (heartbeat) + def create + return render(json: { lock: { mine: false, holder_name: nil } }) unless Document.role_at_least?(@role, :editor) + mine = @doc.acquire_lock!(lock_holder, holder_name) + render json: { lock: { mine:, holder_name: (@doc.lock_holder_name unless mine) } } + end + + # DELETE /api/documents/:id/lock + def destroy + @doc.release_lock!(lock_holder) + head :no_content + end + + private + + def load_document + @doc = Document.find(params[:document_id]) + @role = @doc.role_for(user: Current.user, token: share_token) + raise ActiveRecord::RecordNotFound unless @role + end + end +end diff --git a/app/controllers/api/documents_controller.rb b/app/controllers/api/documents_controller.rb new file mode 100644 index 0000000..ebf5ade --- /dev/null +++ b/app/controllers/api/documents_controller.rb @@ -0,0 +1,87 @@ +module Api + # Server documents. Listing and creating need an account; reading and + # writing a single document also work with just a share link token. + class DocumentsController < BaseController + before_action :require_authentication, only: %i[index create] + before_action :load_document, except: %i[index create] + + # GET /api/documents -- owned + shared with me + def index + user = Current.user + memberships = user.document_memberships.index_by(&:document_id) + docs = user.accessible_documents.includes(:owner, :updated_by).order(content_updated_at: :desc) + render json: { + documents: docs.map { |d| d.meta_json(role: d.owner_id == user.id ? :owner : memberships[d.id]&.role) } + } + end + + # POST /api/documents { title, content } -- also used to upload a local document + def create + doc = Current.user.owned_documents.create!( + title: params.require(:title), content: content_param, updated_by: Current.user + ) + render json: { document: doc.meta_json(role: :owner) }, status: :created + end + + # GET /api/documents/:id[?known_version=n] + # With known_version: just the metadata when nothing changed since. + def show + touch_membership + meta = @doc.meta_json(role: @role).merge(lock: @doc.lock_info(lock_holder)) + if params[:known_version].present? && params[:known_version].to_i == @doc.version + render json: { document: meta, unchanged: true } + else + render json: { document: meta.merge(content: @doc.content) } + end + end + + # PATCH /api/documents/:id { title } and/or { content, base_version } + def update + return forbidden unless Document.role_at_least?(@role, :editor) + if params.key?(:content) + return render(json: { error: "locked", holder_name: @doc.lock_holder_name }, status: :locked) if @doc.locked_by_other?(lock_holder) + @doc.with_lock do + if params.require(:base_version).to_i != @doc.version + return render(json: { error: "conflict", version: @doc.version }, status: :conflict) + end + @doc.update!(content: content_param, version: @doc.version + 1, + content_updated_at: Time.current, updated_by: Current.user) + end + end + @doc.update!(title: params[:title]) if params[:title].present? + render json: { document: @doc.meta_json(role: @role) } + end + + # DELETE /api/documents/:id -- the owner deletes it; anyone else leaves it + def destroy + if @role == :owner + @doc.destroy! + elsif Current.user + @doc.document_memberships.where(user: Current.user).delete_all + else + return forbidden + end + head :no_content + end + + private + + def load_document + @doc = Document.find(params[:id]) + @role = @doc.role_for(user: Current.user, token: share_token) + raise ActiveRecord::RecordNotFound unless @role # 404, not 403: don't confirm it exists + end + + def content_param + c = params.require(:content) + c.is_a?(String) ? c : c.to_unsafe_h.to_json + end + + def touch_membership + return unless Current.user + @doc.document_memberships.where(user: Current.user).update_all(last_opened_at: Time.current) + end + + def forbidden = render(json: { error: "forbidden" }, status: :forbidden) + end +end diff --git a/app/controllers/api/joins_controller.rb b/app/controllers/api/joins_controller.rb new file mode 100644 index 0000000..c730cd9 --- /dev/null +++ b/app/controllers/api/joins_controller.rb @@ -0,0 +1,17 @@ +module Api + # POST /api/documents/:document_id/join { token } + # Redeems a share link. Signed in: the document is added to the account + # (a membership). Not signed in: only answers what the token grants -- the + # browser keeps the token and sends it along (X-Share-Token). + class JoinsController < BaseController + def create + doc = Document.find(params[:document_id]) + link = doc.share_links.active.find_by(token: params.require(:token)) + raise ActiveRecord::RecordNotFound unless link + if Current.user + DocumentMembership.grant!(document: doc, user: Current.user, role: link.role, source: :link, share_link: link) + end + render json: { document: doc.meta_json(role: doc.role_for(user: Current.user, token: link.token)) } + end + end +end diff --git a/app/controllers/api/me_controller.rb b/app/controllers/api/me_controller.rb new file mode 100644 index 0000000..ba75ef7 --- /dev/null +++ b/app/controllers/api/me_controller.rb @@ -0,0 +1,16 @@ +module Api + class MeController < BaseController + before_action :require_authentication, only: :update + + # GET /api/me + def show + render json: { user: Current.user&.as_json } + end + + # PATCH /api/me { display_name } + def update + Current.user.update!(params.permit(:display_name)) + render json: { user: Current.user.as_json } + end + end +end diff --git a/app/controllers/api/shares_controller.rb b/app/controllers/api/shares_controller.rb new file mode 100644 index 0000000..8eda64a --- /dev/null +++ b/app/controllers/api/shares_controller.rb @@ -0,0 +1,74 @@ +module Api + # Everything about who has access to a document. Owner only. + class SharesController < BaseController + before_action :require_authentication + before_action :load_owned_document + + # GET /api/documents/:document_id/share + def show + render json: share_json + end + + # POST /api/documents/:document_id/share/links { role } + # One active link per role: creating one replaces (revokes) the previous. + def create_link + role = params.require(:role) + return render(json: { error: "invalid_role" }, status: :unprocessable_entity) unless ShareLink.roles.key?(role) + @doc.transaction do + @doc.share_links.active.where(role:).find_each(&:revoke!) + @doc.share_links.create!(role:, created_by: Current.user) + end + render json: share_json + end + + # DELETE /api/documents/:document_id/share/links/:id + def revoke_link + @doc.share_links.active.find(params[:id]).revoke! + render json: share_json + end + + # POST /api/documents/:document_id/share/invitations { email, role } + def invite + role = params.require(:role) + return render(json: { error: "invalid_role" }, status: :unprocessable_entity) unless MagicLink.roles.key?(role) + link = MagicLink.issue!(email: params.require(:email), purpose: :invitation, document: @doc, role:, invited_by: Current.user) + if link.email == Current.user.email + link.destroy! + return render(json: { error: "self_invite" }, status: :unprocessable_entity) + end + AuthMailer.invitation(link, link.token).deliver_later + render json: share_json + end + + # PATCH /api/documents/:document_id/share/members/:user_id { role } + def update_member + m = @doc.document_memberships.find_by!(user_id: params[:user_id]) + m.update!(role: params.require(:role)) + render json: share_json + end + + # DELETE /api/documents/:document_id/share/members/:user_id + def remove_member + @doc.document_memberships.where(user_id: params[:user_id]).delete_all + render json: share_json + end + + private + + def load_owned_document + @doc = Current.user.owned_documents.find(params[:document_id]) + end + + def share_json + { + links: @doc.share_links.active.order(:created_at).map(&:as_json), + members: @doc.document_memberships.includes(:user).order(:created_at).map do |m| + { user_id: m.user_id, name: m.user.name, email: m.user.email, role: m.role, source: m.source } + end, + invitations: MagicLink.usable.invitation.where(document: @doc).order(:created_at).map do |i| + { email: i.email, role: i.role, expires_at: i.expires_at.iso8601 } + end + } + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb new file mode 100644 index 0000000..e4ba7a2 --- /dev/null +++ b/app/controllers/application_controller.rb @@ -0,0 +1,6 @@ +class ApplicationController < ActionController::Base + include Authentication + + # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. + allow_browser versions: :modern +end diff --git a/app/controllers/auth_controller.rb b/app/controllers/auth_controller.rb new file mode 100644 index 0000000..b426fdb --- /dev/null +++ b/app/controllers/auth_controller.rb @@ -0,0 +1,22 @@ +# The page behind the link in a sign-in / invitation mail. Opening the link +# (GET) only shows a button; the sign-in happens on POST. Mail scanners +# fetch links on their own and would otherwise use up the one-time token. +class AuthController < ApplicationController + layout "auth" + rate_limit to: 30, within: 15.minutes, only: :create + + def show + @token = params[:token].to_s + @link = MagicLink.find_by_token(@token) + render :invalid, status: :not_found unless @link + end + + def create + link = MagicLink.find_by_token(params[:token]) + return render(:invalid, status: :not_found) unless link + start_session_for(link.redeem!) + redirect_to link.destination + rescue ActiveRecord::RecordNotFound + render :invalid, status: :not_found + end +end diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb new file mode 100644 index 0000000..b5b104f --- /dev/null +++ b/app/controllers/concerns/authentication.rb @@ -0,0 +1,43 @@ +# Cookie session (signed session id), resumed on every request. Everything +# is usable without signing in; controllers call `require_authentication` +# where an account is needed. +module Authentication + extend ActiveSupport::Concern + + included do + before_action :resume_session + helper_method :signed_in? + end + + private + + def signed_in? = Current.user.present? + + def resume_session + Current.session = Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id] + touch_session if Current.session + end + + def touch_session + s = Current.session + return if s.last_active_at && s.last_active_at > 1.hour.ago + s.update_columns(last_active_at: Time.current) + s.user.update_columns(last_seen_at: Time.current) + end + + def require_authentication + render json: { error: "unauthenticated" }, status: :unauthorized unless signed_in? + end + + def start_session_for(user) + reset_session + Current.session = user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip, last_active_at: Time.current) + cookies.signed.permanent[:session_id] = { value: Current.session.id, httponly: true, same_site: :lax, secure: Rails.env.production? } + end + + def terminate_session + Current.session&.destroy + Current.session = nil + cookies.delete(:session_id) + end +end diff --git a/app/controllers/spa_controller.rb b/app/controllers/spa_controller.rb new file mode 100644 index 0000000..6d5a6f5 --- /dev/null +++ b/app/controllers/spa_controller.rb @@ -0,0 +1,9 @@ +# Serves the Vue app for every client-side route (/, /d/). The signed-in +# user is embedded so the app knows it before its first render. +class SpaController < ApplicationController + layout false + + def show + @boot = { user: Current.user&.as_json } + end +end diff --git a/src/App.vue b/app/frontend/App.vue similarity index 100% rename from src/App.vue rename to app/frontend/App.vue diff --git a/src/components/AppToolbar.vue b/app/frontend/components/AppToolbar.vue similarity index 87% rename from src/components/AppToolbar.vue rename to app/frontend/components/AppToolbar.vue index 99d5f17..5945b9f 100644 --- a/src/components/AppToolbar.vue +++ b/app/frontend/components/AppToolbar.vue @@ -8,7 +8,11 @@ import CustomFunctionsModal from './CustomFunctionsModal.vue'; import NamedRangesModal from './NamedRangesModal.vue'; import MacrosModal from './MacrosModal.vue'; import DocumentsModal from './DocumentsModal.vue'; +import LoginModal from './LoginModal.vue'; +import ShareModal from './ShareModal.vue'; +import UploadModal from './UploadModal.vue'; import { useLibrary } from '../store/library'; +import { useSession } from '../store/session'; import { parseDocument } from '../io/json'; import TheLogo from './TheLogo.vue'; import ToolbarIcon, { type ToolbarIconName } from './ToolbarIcon.vue'; @@ -32,6 +36,7 @@ import type { Delimiter } from '../io/csv'; const store = useDocument(); const library = useLibrary(); +const session = useSession(); const { mode: themeMode, cycle: cycleTheme } = useTheme(); const toasts = useToasts(); @@ -110,15 +115,48 @@ const functionsOpen = ref(false); const namedRangesOpen = ref(false); const macrosOpen = ref(false); const documentsOpen = ref(false); +const shareOpen = ref(false); +const uploadOpen = ref(false); +const loginOpen = ref(false); +const loginReason = ref(); +const accountMenuOpen = ref(false); + +function openLogin(reason?: string) { + loginReason.value = reason; + documentsOpen.value = false; + shareOpen.value = false; + loginOpen.value = true; +} + +// after signing in: offer once (per account, per browser) to move this +// browser's documents into the account +onMounted(() => { + const user = session.state.user; + if (!user || !library.state.docs.length) return; + const key = `numolus:upload-asked:${user.id}`; + try { + if (localStorage.getItem(key)) return; + localStorage.setItem(key, '1'); + } catch { + return; + } + uploadOpen.value = true; +}); + +const statusNote = computed(() => { + if (library.state.syncState === 'offline') return 'Offline – wird später gespeichert'; + if (library.state.syncState === 'saving') return 'Speichert …'; + return ''; +}); // ---- document title (inline rename) ------------------------------------ const titleDraft = ref(library.currentTitle.value); watch(library.currentTitle, (t) => (titleDraft.value = t)); function commitTitle() { - const id = library.state.currentId; + const ref = library.state.current; const t = titleDraft.value.trim(); - if (!id || !t) titleDraft.value = library.currentTitle.value; - else if (t !== library.currentTitle.value) void library.rename(id, t); + if (!ref || !t) titleDraft.value = library.currentTitle.value; + else if (t !== library.currentTitle.value) void library.rename(ref, t); } const busy = ref(false); const fileInput = ref(null); @@ -323,6 +361,7 @@ async function onSample() { title="Dokumentname (klicken zum Umbenennen)" aria-label="Dokumentname" :size="Math.max(8, Math.min(28, titleDraft.length + 1))" + :readonly="library.currentRole.value === 'viewer'" @keydown.enter.prevent="($event.target as HTMLInputElement).blur()" @keydown.esc.prevent="titleDraft = library.currentTitle.value; ($event.target as HTMLInputElement).blur()" @blur="commitTitle()" @@ -400,8 +439,15 @@ async function onSample() { + + {{ library.state.lockedBy ? `${library.state.lockedBy} bearbeitet` : 'Nur Lesen' }} + + {{ statusNote }} +
+ +