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