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
View File
+52
View File
@@ -0,0 +1,52 @@
require "test_helper"
class AuthFlowTest < ActionDispatch::IntegrationTest
include ActionMailer::TestHelper
test "requesting a link sends a mail, same answer for any address" do
assert_enqueued_emails 1 do
post "/api/auth/magic_link", params: { email: "anna@example.com", return_to: "/d/x" }, as: :json
end
assert_response :success
assert_equal({ "ok" => true }, json)
end
test "mail link: GET only shows a confirm page, POST signs in and redirects" do
link = MagicLink.issue!(email: "anna@example.com", return_to: "/d/123")
get "/auth/verify", params: { token: link.token }
assert_response :success
assert_nil link.reload.used_at, "a GET (mail scanner) must not use the link"
post "/auth/verify", params: { token: link.token }
assert_redirected_to "/d/123"
get "/api/me"
assert_equal "anna@example.com", json.dig("user", "email")
post "/auth/verify", params: { token: link.token }
assert_response :not_found
end
test "code sign-in and logout" do
user = create_user
sign_in user
get "/api/me"
assert_equal user.id, json.dig("user", "id")
delete "/api/auth/session"
get "/api/me"
assert_nil json["user"]
end
test "wrong code" do
MagicLink.issue!(email: "anna@example.com")
post "/api/auth/code", params: { email: "anna@example.com", code: "abc" }, as: :json
assert_response :unprocessable_entity
end
test "the app page embeds the signed-in user" do
sign_in create_user
get "/"
assert_response :success
assert_includes response.body, "anna@example.com"
end
end
+111
View File
@@ -0,0 +1,111 @@
require "test_helper"
class DocumentsApiTest < ActionDispatch::IntegrationTest
setup do
@anna = create_user("anna@example.com")
@bob = create_user("bob@example.com")
@doc = create_document(@anna)
end
def put_content(doc, base_version, headers: {})
patch "/api/documents/#{doc.id}", params: { content: DOC_JSON, base_version: }, headers:, as: :json
end
test "listing and creating need an account" do
get "/api/documents"
assert_response :unauthorized
post "/api/documents", params: { title: "x", content: DOC_JSON }, as: :json
assert_response :unauthorized
end
test "create (upload), list, read" do
sign_in @anna
post "/api/documents", params: { title: "Hochgeladen", content: DOC_JSON }, as: :json
assert_response :created
id = json.dig("document", "id")
get "/api/documents"
assert_equal [ id, @doc.id ].sort, json["documents"].map { _1["id"] }.sort
get "/api/documents/#{id}"
assert_equal DOC_JSON, json.dig("document", "content")
assert_equal "owner", json.dig("document", "role")
end
test "someone else's document is a 404" do
sign_in @bob
get "/api/documents/#{@doc.id}"
assert_response :not_found
end
test "save with version check; stale base_version is a conflict" do
sign_in @anna
put_content(@doc, 1)
assert_response :success
assert_equal 2, json.dig("document", "version")
put_content(@doc, 1)
assert_response :conflict
assert_equal 2, json["version"]
end
test "known_version returns metadata only when unchanged" do
sign_in @anna
get "/api/documents/#{@doc.id}", params: { known_version: 1 }
assert json["unchanged"]
assert_nil json.dig("document", "content")
end
test "edit lock blocks saves from another tab" do
sign_in @anna
post "/api/documents/#{@doc.id}/lock", headers: { "X-Client-Id" => "tab-a" }
assert json.dig("lock", "mine")
post "/api/documents/#{@doc.id}/lock", headers: { "X-Client-Id" => "tab-b" }
assert_not json.dig("lock", "mine")
put_content(@doc, 1, headers: { "X-Client-Id" => "tab-b" })
assert_response :locked
delete "/api/documents/#{@doc.id}/lock", headers: { "X-Client-Id" => "tab-a" }
put_content(@doc, 1, headers: { "X-Client-Id" => "tab-b" })
assert_response :success
end
test "share link without account: read with token; viewer cannot write" do
link = @doc.share_links.create!(role: :viewer, created_by: @anna)
headers = { "X-Share-Token" => link.token }
post "/api/documents/#{@doc.id}/join", params: { token: link.token }, as: :json
assert_equal "viewer", json.dig("document", "role")
get "/api/documents/#{@doc.id}", headers: headers
assert_response :success
put_content(@doc, 1, headers:)
assert_response :forbidden
get "/api/documents/#{@doc.id}", headers: { "X-Share-Token" => "wrong" }
assert_response :not_found
end
test "share link with account: joining adds it to the list" do
link = @doc.share_links.create!(role: :editor, created_by: @anna)
sign_in @bob
post "/api/documents/#{@doc.id}/join", params: { token: link.token }, as: :json
get "/api/documents"
assert_equal [ [ @doc.id, "editor" ] ], json["documents"].map { [ _1["id"], _1["role"] ] }
put_content(@doc, 1)
assert_response :success
end
test "owner deletes; a member only leaves" do
DocumentMembership.grant!(document: @doc, user: @bob, role: :editor, source: :invite)
sign_in @bob
delete "/api/documents/#{@doc.id}"
assert_response :no_content
assert Document.exists?(@doc.id)
assert_nil @doc.role_for(user: @bob)
sign_in @anna
delete "/api/documents/#{@doc.id}"
assert_not Document.exists?(@doc.id)
end
end
+55
View File
@@ -0,0 +1,55 @@
require "test_helper"
class SharesApiTest < ActionDispatch::IntegrationTest
include ActionMailer::TestHelper
setup do
@anna = create_user("anna@example.com")
@bob = create_user("bob@example.com")
@doc = create_document(@anna)
end
test "only the owner manages sharing" do
DocumentMembership.grant!(document: @doc, user: @bob, role: :editor, source: :invite)
sign_in @bob
get "/api/documents/#{@doc.id}/share"
assert_response :not_found
end
test "one active link per role; a new one revokes the old" do
sign_in @anna
post "/api/documents/#{@doc.id}/share/links", params: { role: "editor" }, as: :json
first = json["links"].first["token"]
post "/api/documents/#{@doc.id}/share/links", params: { role: "editor" }, as: :json
assert_equal 1, json["links"].size
assert_not_equal first, json["links"].first["token"]
assert_nil @doc.role_for(token: first)
delete "/api/documents/#{@doc.id}/share/links/#{json['links'].first['id']}"
assert_empty json["links"]
end
test "invite by mail, change role, remove" do
sign_in @anna
assert_enqueued_emails 1 do
post "/api/documents/#{@doc.id}/share/invitations", params: { email: "bob@example.com", role: "viewer" }, as: :json
end
assert_equal [ "bob@example.com" ], json["invitations"].map { _1["email"] }
MagicLink.invitation.last.redeem!
get "/api/documents/#{@doc.id}/share"
assert_equal [ [ "bob@example.com", "viewer", "invite" ] ], json["members"].map { _1.values_at("email", "role", "source") }
assert_empty json["invitations"]
patch "/api/documents/#{@doc.id}/share/members/#{@bob.id}", params: { role: "editor" }, as: :json
assert_equal :editor, @doc.role_for(user: @bob)
delete "/api/documents/#{@doc.id}/share/members/#{@bob.id}"
assert_nil @doc.role_for(user: @bob)
end
test "cannot invite yourself" do
sign_in @anna
post "/api/documents/#{@doc.id}/share/invitations", params: { email: "anna@example.com", role: "viewer" }, as: :json
assert_response :unprocessable_entity
end
end
View File
+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
+49
View File
@@ -0,0 +1,49 @@
require "test_helper"
class MagicLinkTest < ActiveSupport::TestCase
test "stores only digests and redeems once, creating the user" do
link = MagicLink.issue!(email: " New@Example.com ")
assert_equal "new@example.com", link.email
assert_not_equal link.token, link.token_digest
assert_equal link, MagicLink.find_by_token(link.token)
user = link.redeem!
assert_equal "new@example.com", user.email
assert_nil MagicLink.find_by_token(link.token), "used"
end
test "expires" do
link = MagicLink.issue!(email: "a@example.com")
travel MagicLink::LOGIN_TTL + 1.second do
assert_nil MagicLink.find_by_token(link.token)
end
end
test "code: wrong attempts burn the link" do
link = MagicLink.issue!(email: "a@example.com")
wrong = link.code == "000000" ? "111111" : "000000"
MagicLink::MAX_CODE_ATTEMPTS.times { assert_nil MagicLink.find_by_code("a@example.com", wrong) }
assert_nil MagicLink.find_by_code("a@example.com", link.code)
end
test "code accepts formatting and the right email only" do
link = MagicLink.issue!(email: "a@example.com")
assert_equal link, MagicLink.find_by_code("A@example.com", link.code.chars.each_slice(3).map(&:join).join(" "))
assert_nil MagicLink.find_by_code("b@example.com", link.code)
end
test "return_to only allows local paths" do
assert_equal "/d/abc", MagicLink.safe_path("/d/abc")
assert_nil MagicLink.safe_path("https://evil.example")
assert_nil MagicLink.safe_path("//evil.example")
end
test "invitation grants the document on redeem" do
owner = create_user("owner@example.com")
doc = create_document(owner)
link = MagicLink.issue!(email: "guest@example.com", purpose: :invitation, document: doc, role: :editor, invited_by: owner)
user = link.redeem!
assert_equal :editor, doc.role_for(user:)
assert_equal "/d/#{doc.id}", link.destination
end
end
+32
View File
@@ -0,0 +1,32 @@
ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
require "rails/test_help"
module ActiveSupport
class TestCase
# Run tests in parallel with specified workers
parallelize(workers: :number_of_processors)
DOC_JSON = { version: 2, sheets: [ { id: "s1", name: "Blatt 1", objects: [] } ] }.to_json
def create_user(email = "anna@example.com") = User.create!(email:)
def create_document(owner, title: "Budget") =
owner.owned_documents.create!(title:, content: DOC_JSON)
end
end
module SignInHelper
# signs in through the real code flow
def sign_in(user)
link = MagicLink.issue!(email: user.email)
post "/api/auth/code", params: { email: user.email, code: link.code }, as: :json
assert_response :success
end
def json = response.parsed_body
end
class ActionDispatch::IntegrationTest
include SignInHelper
end