From 6a55d6f47d12eb6ad49debaf57393f1e5541d416 Mon Sep 17 00:00:00 2001 From: Tim Hsueh Date: Tue, 1 Sep 2026 17:24:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BE=8C=E5=8F=B0=E5=94=AF=E8=AE=80?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E8=A7=92=E8=89=B2=EF=BC=88viewer=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - admins.role enum(operator 預設/viewer);BaseController 統一 攔截 viewer 的所有非 GET 請求(整體策略,未來新增寫入端點 自動涵蓋),sessions 豁免使其可登入登出 - viewer 介面顯示「展示模式(唯讀)」banner,寫入表單改靜態 說明;operator 完全不受影響(迴歸測試鎖住) - seeds 新增公開展示帳號 demo-admin@venture-ferris.example (密碼刻意公開於 README,寫入攔截在伺服器端非前端隱藏) - 測試 221 integration(+12:五頁瀏覽、四寫入端點零變化、 登出、operator 迴歸) --- README.md | 22 +- app/controllers/admin/base_controller.rb | 23 ++ app/controllers/admin/sessions_controller.rb | 5 + app/models/admin.rb | 5 + app/views/admin/_header.html.erb | 6 + app/views/admin/questions/edit.html.erb | 6 + app/views/admin/reward_codes/index.html.erb | 30 +-- app/views/admin/serial_codes/index.html.erb | 30 +-- app/views/admin/teams/show.html.erb | 4 +- .../20260901000000_add_role_to_admins.rb | 8 + db/schema.rb | 3 +- db/seeds.rb | 21 ++ test/integration/admin_viewer_role_test.rb | 201 ++++++++++++++++++ 13 files changed, 335 insertions(+), 29 deletions(-) create mode 100644 db/migrate/20260901000000_add_role_to_admins.rb create mode 100644 test/integration/admin_viewer_role_test.rb diff --git a/README.md b/README.md index 028736e..8c0b25e 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ | `BossSkillUse` | (新增) | 玩家於某場王戰的主動技使用紀錄,`[boss_battle_id, player_id]` 唯一=每場每人一次 | | `ScoreEntry` | QUEST_SCORE | 每隊每題最終分數,`question_id` 外鍵,伺服器端算好後寫入 | | `RewardCode` | WHEEL_PLAYER_REWARD | 兌獎序號池;以 email 為 key,一人固定配發 2 組 | -| `Admin` | (新增,取代舊站前端驗證機制) | 後台帳號,`has_secure_password` | +| `Admin` | (新增,取代舊站前端驗證機制) | 後台帳號,`has_secure_password`;`role` enum 分 `operator`/`viewer`(唯讀展示帳號) | 文字版 ERD(`1—N` 表示一對多): @@ -247,6 +247,26 @@ ADMIN_PASSWORD=your-password bin/rails db:seed > 既有帳號的密碼(seeds 只在建立時讀取),改密碼需同時更新資料庫中的 > `Admin` 記錄(例如 `Admin.find_by!(email: ...).update!(password: ...)`)。 +### 展示帳號(唯讀) + +`db/seeds.rb` 另外會建立一個公開的展示帳號,讓作品集訪客可以實際登入後台 +瀏覽,帳密**刻意公開**(`Admin.role` enum 的 `viewer`,見 +`app/models/admin.rb`): + +``` +email: demo-admin@venture-ferris.example +password: walkthrough2026 +``` + +viewer 可以看到後台所有頁面(Dashboard、隊伍管理、題目管理、兌獎序號、隊伍 +序號),但任何寫入操作都會被擋下,並顯示「展示模式(唯讀)」提示。設計上 +**寫入攔截在伺服器端 controller 層,非僅前端隱藏**——`Admin::BaseController` +的 `block_viewer_writes` 會擋下 viewer 帳號送出的所有非 GET 請求(見 +`app/controllers/admin/base_controller.rb`),即使直接對寫入端點發送 +POST/PATCH/DELETE 也一樣被拒絕,前端只是額外把對應的表單/按鈕換成「唯讀模式 +不可操作」的說明文字,純粹是 UX,不是安全邊界。一般 operator 帳號(例如上面 +的 `admin@venture-ferris.example`)完全不受影響。 + ## 測試 ```bash diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb index a7d2d9a..f98052b 100644 --- a/app/controllers/admin/base_controller.rb +++ b/app/controllers/admin/base_controller.rb @@ -13,6 +13,7 @@ # is itself a kind of Module. class Admin::BaseController < ApplicationController before_action :require_admin + before_action :block_viewer_writes private @@ -26,4 +27,26 @@ def require_admin redirect_to admin_login_path, alert: "請先登入後台" end + + # The viewer role exists so a portfolio visitor can log in with a public, + # intentionally-published account and actually click around the real back + # office — but never change anything. Enforcement lives here, at the base + # controller, rather than as a per-action check in each controller: it + # blocks by HTTP verb (any non-GET/HEAD request) instead of by listing + # every write action, so a future controller/action that writes data is + # covered automatically the moment it inherits from Admin::BaseController, + # with no extra step to remember. Admin::SessionsController skips this + # (see its own `skip_before_action`) because a viewer must still be able + # to log in (POST) and log out (DELETE). + # + # This is the actual security boundary for the read-only demo account: the + # UI hides write forms/buttons for viewers (app/views/admin/**) purely as + # a UX nicety, but that alone would not stop a direct POST/PATCH/DELETE + # crafted outside the browser — this before_action does. + def block_viewer_writes + return unless current_admin&.viewer? + return if request.get? || request.head? + + redirect_back fallback_location: admin_root_path, alert: "展示帳號為唯讀模式" + end end diff --git a/app/controllers/admin/sessions_controller.rb b/app/controllers/admin/sessions_controller.rb index a6ee492..99fbcd7 100644 --- a/app/controllers/admin/sessions_controller.rb +++ b/app/controllers/admin/sessions_controller.rb @@ -1,5 +1,10 @@ class Admin::SessionsController < Admin::BaseController skip_before_action :require_admin, only: [ :new, :create ] + # Viewer accounts must be able to log in and out like any other admin — + # the read-only guard only makes sense once a session already exists, and + # login/logout are themselves POST/DELETE requests that would otherwise be + # blocked by Admin::BaseController#block_viewer_writes. + skip_before_action :block_viewer_writes def new end diff --git a/app/models/admin.rb b/app/models/admin.rb index 071e059..641bcd6 100644 --- a/app/models/admin.rb +++ b/app/models/admin.rb @@ -4,6 +4,11 @@ class Admin < ApplicationRecord has_secure_password + # operator (default) has full read/write access; viewer is the public + # portfolio-showcase account — it can log in and browse every back-office + # page, but every write is refused server-side (Admin::BaseController). + enum :role, { operator: 0, viewer: 1 }, default: :operator, validate: true + validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } diff --git a/app/views/admin/_header.html.erb b/app/views/admin/_header.html.erb index 7be262e..f06cfe6 100644 --- a/app/views/admin/_header.html.erb +++ b/app/views/admin/_header.html.erb @@ -1,3 +1,9 @@ +<% if current_admin&.viewer? %> +
+ 展示模式(唯讀)— 這是公開的展示帳號,可以瀏覽後台所有頁面,但所有寫入操作都會被伺服器擋下。 +
+<% end %> +

<%= title %>

<%= button_to "登出", admin_session_path, method: :delete, diff --git a/app/views/admin/questions/edit.html.erb b/app/views/admin/questions/edit.html.erb index bf6581d..0c8442e 100644 --- a/app/views/admin/questions/edit.html.erb +++ b/app/views/admin/questions/edit.html.erb @@ -10,6 +10,11 @@ <%= link_to "← 回題目列表", admin_questions_path, class: "mb-6! inline-block! text-sm! font-semibold! text-indigo-600! hover:text-indigo-500!" %> + <% if current_admin&.viewer? %> +
+

唯讀模式不可操作。

+
+ <% else %> <%= form_with model: @question, url: admin_question_path(@question), method: :patch, local: true, class: "space-y-8!" do |f| %>

題目內容

@@ -101,5 +106,6 @@ class: "cursor-pointer! rounded-lg! bg-indigo-600! px-5! py-2.5! text-sm! font-semibold! text-white! transition! hover:bg-indigo-500!" %>
<% end %> + <% end %>
diff --git a/app/views/admin/reward_codes/index.html.erb b/app/views/admin/reward_codes/index.html.erb index acea683..40acd31 100644 --- a/app/views/admin/reward_codes/index.html.erb +++ b/app/views/admin/reward_codes/index.html.erb @@ -24,21 +24,25 @@

批次產生

- <%= form_with url: admin_reward_codes_path, method: :post, local: true, class: "mt-4! flex! flex-wrap! items-end! gap-4!" do |f| %> -
- <%= f.label :count, "產生筆數", class: "mb-1! block! text-sm! font-medium! text-slate-700!" %> - <%= f.number_field :count, value: Admin::RewardCodesController::DEFAULT_COUNT, - min: 1, max: Admin::RewardCodesController::MAX_COUNT, - class: "w-32! rounded-lg! border-0! px-3! py-2! text-slate-800! ring-1! ring-inset! ring-slate-300! focus:ring-2! focus:ring-inset! focus:ring-indigo-500!" %> -
+ <% if current_admin&.viewer? %> +

唯讀模式不可操作。

+ <% else %> + <%= form_with url: admin_reward_codes_path, method: :post, local: true, class: "mt-4! flex! flex-wrap! items-end! gap-4!" do |f| %> +
+ <%= f.label :count, "產生筆數", class: "mb-1! block! text-sm! font-medium! text-slate-700!" %> + <%= f.number_field :count, value: Admin::RewardCodesController::DEFAULT_COUNT, + min: 1, max: Admin::RewardCodesController::MAX_COUNT, + class: "w-32! rounded-lg! border-0! px-3! py-2! text-slate-800! ring-1! ring-inset! ring-slate-300! focus:ring-2! focus:ring-inset! focus:ring-indigo-500!" %> +
-
- <%= f.check_box :test_mode, class: "h-4! w-4! rounded! border-slate-300! text-indigo-600! focus:ring-indigo-500!" %> - <%= f.label :test_mode, "測試模式 (test_mode)", class: "text-sm! font-medium! text-slate-700!" %> -
+
+ <%= f.check_box :test_mode, class: "h-4! w-4! rounded! border-slate-300! text-indigo-600! focus:ring-indigo-500!" %> + <%= f.label :test_mode, "測試模式 (test_mode)", class: "text-sm! font-medium! text-slate-700!" %> +
- <%= f.submit "產生序號", - class: "cursor-pointer! rounded-lg! bg-indigo-600! px-4! py-2! text-sm! font-semibold! text-white! transition! hover:bg-indigo-500!" %> + <%= f.submit "產生序號", + class: "cursor-pointer! rounded-lg! bg-indigo-600! px-4! py-2! text-sm! font-semibold! text-white! transition! hover:bg-indigo-500!" %> + <% end %> <% end %>
diff --git a/app/views/admin/serial_codes/index.html.erb b/app/views/admin/serial_codes/index.html.erb index 1744b31..83f9268 100644 --- a/app/views/admin/serial_codes/index.html.erb +++ b/app/views/admin/serial_codes/index.html.erb @@ -11,21 +11,25 @@

批量產生

- <%= form_with url: admin_serial_codes_path, method: :post, local: true, class: "mt-4! flex! flex-wrap! items-end! gap-4!" do |f| %> -
- <%= f.label :count, "產生筆數", class: "mb-1! block! text-sm! font-medium! text-slate-700!" %> - <%= f.number_field :count, value: Admin::SerialCodesController::DEFAULT_COUNT, - min: 1, max: Admin::SerialCodesController::MAX_COUNT, - class: "w-32! rounded-lg! border-0! px-3! py-2! text-slate-800! ring-1! ring-inset! ring-slate-300! focus:ring-2! focus:ring-inset! focus:ring-indigo-500!" %> -
+ <% if current_admin&.viewer? %> +

唯讀模式不可操作。

+ <% else %> + <%= form_with url: admin_serial_codes_path, method: :post, local: true, class: "mt-4! flex! flex-wrap! items-end! gap-4!" do |f| %> +
+ <%= f.label :count, "產生筆數", class: "mb-1! block! text-sm! font-medium! text-slate-700!" %> + <%= f.number_field :count, value: Admin::SerialCodesController::DEFAULT_COUNT, + min: 1, max: Admin::SerialCodesController::MAX_COUNT, + class: "w-32! rounded-lg! border-0! px-3! py-2! text-slate-800! ring-1! ring-inset! ring-slate-300! focus:ring-2! focus:ring-inset! focus:ring-indigo-500!" %> +
-
- <%= f.check_box :test_mode, class: "h-4! w-4! rounded! border-slate-300! text-indigo-600! focus:ring-indigo-500!" %> - <%= f.label :test_mode, "測試模式 (test_mode)", class: "text-sm! font-medium! text-slate-700!" %> -
+
+ <%= f.check_box :test_mode, class: "h-4! w-4! rounded! border-slate-300! text-indigo-600! focus:ring-indigo-500!" %> + <%= f.label :test_mode, "測試模式 (test_mode)", class: "text-sm! font-medium! text-slate-700!" %> +
- <%= f.submit "產生序號", - class: "cursor-pointer! rounded-lg! bg-indigo-600! px-4! py-2! text-sm! font-semibold! text-white! transition! hover:bg-indigo-500!" %> + <%= f.submit "產生序號", + class: "cursor-pointer! rounded-lg! bg-indigo-600! px-4! py-2! text-sm! font-semibold! text-white! transition! hover:bg-indigo-500!" %> + <% end %> <% end %>
diff --git a/app/views/admin/teams/show.html.erb b/app/views/admin/teams/show.html.erb index e8e0240..3160a5b 100644 --- a/app/views/admin/teams/show.html.erb +++ b/app/views/admin/teams/show.html.erb @@ -31,7 +31,9 @@ - <% if @team.test_mode? %> + <% if current_admin&.viewer? %> +

唯讀模式不可操作。

+ <% elsif @team.test_mode? %>
<%= button_to "刪除此測試隊伍", admin_team_path(@team), method: :delete, data: { turbo_confirm: "確定要刪除測試隊伍 #{@team.serial_no} 嗎?此操作將一併清除隊員、解題紀錄、戰鬥紀錄與分數,且無法復原。" }, diff --git a/db/migrate/20260901000000_add_role_to_admins.rb b/db/migrate/20260901000000_add_role_to_admins.rb new file mode 100644 index 0000000..122f25b --- /dev/null +++ b/db/migrate/20260901000000_add_role_to_admins.rb @@ -0,0 +1,8 @@ +class AddRoleToAdmins < ActiveRecord::Migration[7.2] + def change + # default: 0 (operator) so every existing admin account keeps full + # read/write access after this migration runs — nobody is silently + # downgraded to viewer. + add_column :admins, :role, :integer, default: 0, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 9e460e5..b9997fe 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[7.2].define(version: 2026_08_30_120000) do +ActiveRecord::Schema[7.2].define(version: 2026_09_01_000000) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -19,6 +19,7 @@ t.string "password_digest", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "role", default: 0, null: false t.index ["email"], name: "index_admins_on_email", unique: true end diff --git a/db/seeds.rb b/db/seeds.rb index 42256cf..243e37a 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -242,6 +242,27 @@ Admin.create!( email: ADMIN_EMAIL, password: ENV.fetch("ADMIN_PASSWORD", "changeme"), + role: :operator, + ) +end + +# --------------------------------------------------------------------------- +# 6. Viewer demo account (portfolio showcase — password intentionally public) +# --------------------------------------------------------------------------- +# This is not a real credential to protect: it exists so a portfolio visitor +# can log into the actual back office and click around, and the point only +# works if the password is published right here rather than pulled from an +# ENV var. What keeps it safe to publish is that every write it could +# attempt is refused server-side (Admin::BaseController#block_viewer_writes), +# not that the password is hard to find. +DEMO_VIEWER_EMAIL = "demo-admin@venture-ferris.example" +DEMO_VIEWER_PASSWORD = "walkthrough2026" + +unless Admin.exists?(email: DEMO_VIEWER_EMAIL) + Admin.create!( + email: DEMO_VIEWER_EMAIL, + password: DEMO_VIEWER_PASSWORD, + role: :viewer, ) end diff --git a/test/integration/admin_viewer_role_test.rb b/test/integration/admin_viewer_role_test.rb new file mode 100644 index 0000000..eff61a4 --- /dev/null +++ b/test/integration/admin_viewer_role_test.rb @@ -0,0 +1,201 @@ +require "test_helper" + +# Covers the read-only "viewer" admin role (portfolio showcase account): +# server-side write blocking in Admin::BaseController#block_viewer_writes, +# the read-only banner, per-view hidden write controls, and that the +# regular "operator" role is completely unaffected. +class AdminViewerRoleTest < ActionDispatch::IntegrationTest + setup do + @operator = Admin.create!(email: "operator-role-test@example.com", password: "correct-password", role: :operator) + @viewer = Admin.create!(email: "viewer-role-test@example.com", password: "correct-password", role: :viewer) + end + + # --------------------------------------------------------------------- + # Viewer: every back-office page is readable, and the read-only banner + # renders on all of them. + # --------------------------------------------------------------------- + + test "viewer can GET every back-office page and sees the read-only banner" do + sign_in_as(@viewer) + + question = Question.create!(number: 5, kind: :quiz, title: "第 5 題", boss: seed_boss_for(5), + answer_digest: Question.digest_for("answer")) + team = Team.create!(serial_no: "VIEWERPAGETEST01") + + [ + admin_root_path, + admin_teams_path, + admin_team_path(team), + admin_questions_path, + edit_admin_question_path(question), + admin_reward_codes_path, + admin_serial_codes_path + ].each do |path| + get path + assert_response :success, "expected #{path} to return 200 for a viewer" + assert_match "展示模式(唯讀)", response.body, "expected read-only banner on #{path}" + end + end + + test "operator does not see the read-only banner" do + sign_in_as(@operator) + + get admin_root_path + + assert_response :success + assert_no_match "展示模式(唯讀)", response.body + end + + # --------------------------------------------------------------------- + # Viewer: write controls are hidden in the view, replaced by a static + # read-only notice. + # --------------------------------------------------------------------- + + test "viewer sees the read-only notice instead of the serial code generator form" do + sign_in_as(@viewer) + + get admin_serial_codes_path + + assert_response :success + assert_match "唯讀模式不可操作", response.body + assert_no_match "產生序號", response.body + end + + test "viewer sees the read-only notice instead of the reward code generator form" do + sign_in_as(@viewer) + + get admin_reward_codes_path + + assert_response :success + assert_match "唯讀模式不可操作", response.body + end + + test "viewer sees the read-only notice instead of the question edit form" do + sign_in_as(@viewer) + question = Question.create!(number: 6, kind: :quiz, title: "第 6 題", boss: seed_boss_for(6), + answer_digest: Question.digest_for("answer")) + + get edit_admin_question_path(question) + + assert_response :success + assert_match "唯讀模式不可操作", response.body + assert_no_match "儲存變更", response.body + end + + test "viewer sees the read-only notice instead of the team delete button" do + sign_in_as(@viewer) + team = Team.create!(serial_no: "VIEWERNOBUTTON01", test_mode: true) + + get admin_team_path(team) + + assert_response :success + assert_match "唯讀模式不可操作", response.body + assert_no_match "刪除此測試隊伍", response.body + end + + # --------------------------------------------------------------------- + # Viewer: writes are refused server-side, with zero data changes, even + # when sent as a direct request (bypassing the UI entirely — CSRF is + # disabled in the test environment, so this exercises the exact same + # request a curl-level bypass attempt would make). + # --------------------------------------------------------------------- + + test "viewer cannot generate serial codes" do + sign_in_as(@viewer) + + assert_no_difference "Team.count" do + post admin_serial_codes_path, params: { count: 5 } + end + + assert_redirected_to admin_root_path + assert_equal "展示帳號為唯讀模式", flash[:alert] + end + + test "viewer cannot update a question" do + sign_in_as(@viewer) + question = Question.create!(number: 7, kind: :quiz, title: "原標題", boss: seed_boss_for(7), + answer_digest: Question.digest_for("answer")) + + patch admin_question_path(question), params: { question: { title: "被竄改的標題" } } + + assert_redirected_to admin_root_path + assert_equal "展示帳號為唯讀模式", flash[:alert] + assert_equal "原標題", question.reload.title + end + + test "viewer cannot delete a team" do + sign_in_as(@viewer) + team = Team.create!(serial_no: "VIEWERDELETEDEN1", test_mode: true) + + assert_no_difference "Team.count" do + delete admin_team_path(team) + end + + assert_redirected_to admin_root_path + assert_equal "展示帳號為唯讀模式", flash[:alert] + assert Team.exists?(team.id) + end + + test "viewer cannot generate reward codes" do + sign_in_as(@viewer) + + assert_no_difference "RewardCode.count" do + post admin_reward_codes_path, params: { count: 5 } + end + + assert_redirected_to admin_root_path + assert_equal "展示帳號為唯讀模式", flash[:alert] + end + + # --------------------------------------------------------------------- + # Viewer: login/logout is exempt from the write guard. + # --------------------------------------------------------------------- + + test "viewer can log in and log out normally" do + post admin_session_path, params: { email: @viewer.email, password: "correct-password" } + assert_redirected_to admin_root_path + + get admin_root_path + assert_response :success + + delete admin_session_path + assert_redirected_to admin_login_path + + get admin_root_path + assert_redirected_to admin_login_path + end + + # --------------------------------------------------------------------- + # Operator: fully unaffected regression check across all four write + # endpoints. + # --------------------------------------------------------------------- + + test "operator writes are unaffected by the viewer guard" do + sign_in_as(@operator) + question = Question.create!(number: 8, kind: :quiz, title: "原標題", boss: seed_boss_for(8), + answer_digest: Question.digest_for("answer")) + team = Team.create!(serial_no: "OPERATORDELETE01", test_mode: true) + + assert_difference "Team.count", 5 do + post admin_serial_codes_path, params: { count: 5 } + end + assert_redirected_to admin_serial_codes_path + + patch admin_question_path(question), params: { question: { title: "已更新標題" } } + assert_equal "已更新標題", question.reload.title + + assert_difference "RewardCode.count", 5 do + post admin_reward_codes_path, params: { count: 5 } + end + + assert_difference "Team.count", -1 do + delete admin_team_path(team) + end + end + + private + + def sign_in_as(admin) + post admin_session_path, params: { email: admin.email, password: "correct-password" } + end +end