Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 表示一對多):

Expand Down Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions app/controllers/admin/base_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
# is itself a kind of Module.
class Admin::BaseController < ApplicationController
before_action :require_admin
before_action :block_viewer_writes

private

Expand All @@ -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
5 changes: 5 additions & 0 deletions app/controllers/admin/sessions_controller.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
5 changes: 5 additions & 0 deletions app/models/admin.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
6 changes: 6 additions & 0 deletions app/views/admin/_header.html.erb
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
<% if current_admin&.viewer? %>
<div class="mb-6! rounded-lg! bg-amber-50! px-4! py-3! text-sm! font-semibold! text-amber-800! ring-1! ring-amber-200!">
展示模式(唯讀)— 這是公開的展示帳號,可以瀏覽後台所有頁面,但所有寫入操作都會被伺服器擋下。
</div>
<% end %>

<div class="mb-6! flex! items-center! justify-between!">
<h1 class="text-xl! font-bold! text-slate-800!"><%= title %></h1>
<%= button_to "登出", admin_session_path, method: :delete,
Expand Down
6 changes: 6 additions & 0 deletions app/views/admin/questions/edit.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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? %>
<div class="rounded-xl! bg-white! p-6! ring-1! ring-slate-200! shadow-sm!">
<p class="text-sm! text-slate-500!">唯讀模式不可操作。</p>
</div>
<% else %>
<%= form_with model: @question, url: admin_question_path(@question), method: :patch, local: true, class: "space-y-8!" do |f| %>
<div class="rounded-xl! bg-white! p-6! ring-1! ring-slate-200! shadow-sm!">
<h2 class="text-base! font-semibold! text-slate-800!">題目內容</h2>
Expand Down Expand Up @@ -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!" %>
</div>
<% end %>
<% end %>
</div>
</main>
30 changes: 17 additions & 13 deletions app/views/admin/reward_codes/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,25 @@
<div class="mb-8! rounded-xl! bg-white! p-6! ring-1! ring-slate-200! shadow-sm!">
<h2 class="text-base! font-semibold! text-slate-800!">批次產生</h2>

<%= form_with url: admin_reward_codes_path, method: :post, local: true, class: "mt-4! flex! flex-wrap! items-end! gap-4!" do |f| %>
<div>
<%= 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!" %>
</div>
<% if current_admin&.viewer? %>
<p class="mt-4! text-sm! text-slate-500!">唯讀模式不可操作。</p>
<% else %>
<%= form_with url: admin_reward_codes_path, method: :post, local: true, class: "mt-4! flex! flex-wrap! items-end! gap-4!" do |f| %>
<div>
<%= 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!" %>
</div>

<div class="flex! items-center! gap-2! pb-2!">
<%= 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!" %>
</div>
<div class="flex! items-center! gap-2! pb-2!">
<%= 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!" %>
</div>

<%= 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 %>
</div>

Expand Down
30 changes: 17 additions & 13 deletions app/views/admin/serial_codes/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,25 @@
<div class="mb-8! rounded-xl! bg-white! p-6! ring-1! ring-slate-200! shadow-sm!">
<h2 class="text-base! font-semibold! text-slate-800!">批量產生</h2>

<%= form_with url: admin_serial_codes_path, method: :post, local: true, class: "mt-4! flex! flex-wrap! items-end! gap-4!" do |f| %>
<div>
<%= 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!" %>
</div>
<% if current_admin&.viewer? %>
<p class="mt-4! text-sm! text-slate-500!">唯讀模式不可操作。</p>
<% else %>
<%= form_with url: admin_serial_codes_path, method: :post, local: true, class: "mt-4! flex! flex-wrap! items-end! gap-4!" do |f| %>
<div>
<%= 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!" %>
</div>

<div class="flex! items-center! gap-2! pb-2!">
<%= 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!" %>
</div>
<div class="flex! items-center! gap-2! pb-2!">
<%= 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!" %>
</div>

<%= 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 %>
</div>

Expand Down
4 changes: 3 additions & 1 deletion app/views/admin/teams/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@
</div>
</dl>

<% if @team.test_mode? %>
<% if current_admin&.viewer? %>
<p class="mt-6! text-sm! text-slate-500!">唯讀模式不可操作。</p>
<% elsif @team.test_mode? %>
<div class="mt-6!">
<%= button_to "刪除此測試隊伍", admin_team_path(@team), method: :delete,
data: { turbo_confirm: "確定要刪除測試隊伍 #{@team.serial_no} 嗎?此操作將一併清除隊員、解題紀錄、戰鬥紀錄與分數,且無法復原。" },
Expand Down
8 changes: 8 additions & 0 deletions db/migrate/20260901000000_add_role_to_admins.rb
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 21 additions & 0 deletions db/seeds.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading