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/<id>) 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 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-28 20:09:06 +02:00
co-authored by Claude Opus 5.5
parent 5a0f1fcd78
commit e14bada114
190 changed files with 6077 additions and 722 deletions
+63
View File
@@ -0,0 +1,63 @@
require "test_helper"
class DocumentTest < ActiveSupport::TestCase
setup do
@owner = create_user("owner@example.com")
@doc = create_document(@owner)
end
test "rejects content that is not a numolus document" do
doc = @owner.owned_documents.build(title: "x", content: "[1,2]")
assert_not doc.valid?
doc.content = "{nope"
assert_not doc.valid?
end
test "role_for: owner, member, link token, and the strongest grant wins" do
bob = create_user("bob@example.com")
assert_equal :owner, @doc.role_for(user: @owner)
assert_nil @doc.role_for(user: bob)
viewer_link = @doc.share_links.create!(role: :viewer, created_by: @owner)
editor_link = @doc.share_links.create!(role: :editor, created_by: @owner)
assert_equal :viewer, @doc.role_for(token: viewer_link.token)
DocumentMembership.grant!(document: @doc, user: bob, role: :viewer, source: :invite)
assert_equal :viewer, @doc.role_for(user: bob)
assert_equal :editor, @doc.role_for(user: bob, token: editor_link.token)
editor_link.revoke!
assert_nil @doc.role_for(token: editor_link.token)
end
test "revoking a link removes access gained through it, but not invitations" do
carl = create_user("carl@example.com")
dora = create_user("dora@example.com")
link = @doc.share_links.create!(role: :editor, created_by: @owner)
DocumentMembership.grant!(document: @doc, user: carl, role: :editor, source: :link, share_link: link)
DocumentMembership.grant!(document: @doc, user: dora, role: :editor, source: :link, share_link: link)
DocumentMembership.grant!(document: @doc, user: dora, role: :viewer, source: :invite)
link.revoke!
assert_nil @doc.role_for(user: carl)
assert_equal :viewer, @doc.role_for(user: dora)
end
test "a link never weakens a membership" do
bob = create_user("bob@example.com")
DocumentMembership.grant!(document: @doc, user: bob, role: :editor, source: :invite)
DocumentMembership.grant!(document: @doc, user: bob, role: :viewer, source: :link)
assert_equal :editor, @doc.role_for(user: bob)
end
test "edit lock: one holder at a time, expires without heartbeat" do
assert @doc.acquire_lock!("1:tab-a", "Anna")
assert @doc.acquire_lock!("1:tab-a", "Anna"), "refreshing own lock"
assert_not @doc.acquire_lock!("2:tab-b", "Bob")
assert @doc.reload.locked_by_other?("2:tab-b")
travel Document::LOCK_TTL + 1.second do
assert @doc.acquire_lock!("2:tab-b", "Bob")
end
end
end