merge: rebase 到最新 main(吸納 PR #15 個人自助指令群)後新增管理端指令群

- main 已合併 PR #15(profile/password/email/sessions/tokens/mfa 與 eqrcode 依賴)
- 保留 main 的 self_service 模組與 api.ex 個人自助端點,於其上重套管理端:
  jwks/accounts/audit-logs 指令、API 端點、CLI 接線、docs §3.8–§3.10、README 表格
- mix precommit 全綠(150 tests:admin 100 + self-service 50)
This commit is contained in:
2026-09-10 03:15:11 +08:00
parent 464db21745
commit 9784e18c24
8 changed files with 1888 additions and 50 deletions
+281 -1
View File
@@ -8,7 +8,7 @@ defmodule BearCli.CLI do
優先使用該值,不讀寫本機憑證檔。
"""
alias BearCli.{Accounts, Admin, Api, Apps, AuditLogs, Config, Credentials, Jwks}
alias BearCli.{Accounts, Admin, Api, Apps, AuditLogs, Config, Credentials, Jwks, SelfService}
@global_switches [
issuer: :string,
@@ -65,6 +65,39 @@ defmodule BearCli.CLI do
@statuses ~w(active inactive)
@methods ~w(client_secret PKCE)
# -- 個人自助指令群(docs/commands.md §3.7)--
@profile_verbs ~w(show set)
@sessions_verbs ~w(list revoke revoke-others)
@tokens_verbs ~w(list create revoke)
@mfa_verbs ~w(status setup disable recovery-codes)
@profile_set_switches [
name: :string,
given_name: :string,
family_name: :string,
nickname: :string,
preferred_username: :string,
profile: :string,
picture: :string,
website: :string,
gender: :string,
birthdate: :string,
zoneinfo: :string,
locale: :string,
phone_number: :string,
phone_verified: :boolean,
address_formatted: :string,
address_street_address: :string,
address_locality: :string,
address_region: :string,
address_postal_code: :string,
address_country: :string
]
@email_switches [new: :string]
@tokens_create_switches [name: :string, expires_in: :string]
# -- escript 入口 --
@doc "escript 主函式。執行後以 `System.halt/1` 設定退出碼。"
@@ -177,6 +210,115 @@ defmodule BearCli.CLI do
end
end
defp parse_command("profile", args, global) do
case args do
[verb | rest] when verb in @profile_verbs ->
switches = if verb == "set", do: @profile_set_switches, else: []
case OptionParser.parse(rest, strict: @global_switches ++ switches) do
{opts, positionals, []} ->
validate_self_service(
:profile,
verb,
positionals,
merge_opts(global, opts_to_map(opts))
)
{_opts, _extra, [{opt, _} | _]} ->
{:error, "無法解析選項:#{opt}", 2}
end
[] ->
{:error, "profile 缺少子指令(可用:#{Enum.join(@profile_verbs, " ")})", 2}
[unknown | _] ->
{:error, "未知的 profile 子指令:#{unknown}(可用:#{Enum.join(@profile_verbs, " ")})", 2}
end
end
defp parse_command("password", args, global) do
case args do
["change" | rest] ->
case OptionParser.parse(rest, strict: @global_switches) do
{opts, [], []} ->
{:ok, {:self_service, :password, :change}, merge_opts(global, opts_to_map(opts))}
{_opts, extra, []} ->
{:error, "password change 不接受參數:#{Enum.join(extra, " ")}", 2}
{_opts, _extra, [{opt, _} | _]} ->
{:error, "無法解析選項:#{opt}", 2}
end
[] ->
{:error, "password 缺少子指令(可用:change)", 2}
[unknown | _] ->
{:error, "未知的 password 子指令:#{unknown}(可用:change)", 2}
end
end
defp parse_command("email", args, global) do
case args do
["change" | rest] ->
case OptionParser.parse(rest, strict: @global_switches ++ @email_switches) do
{opts, [], []} ->
{:ok, {:self_service, :email, :change}, merge_opts(global, opts_to_map(opts))}
{_opts, extra, []} ->
{:error, "email change 不接受位置參數:#{Enum.join(extra, " ")}", 2}
{_opts, _extra, [{opt, _} | _]} ->
{:error, "無法解析選項:#{opt}", 2}
end
[] ->
{:error, "email 缺少子指令(可用:change)", 2}
[unknown | _] ->
{:error, "未知的 email 子指令:#{unknown}(可用:change)", 2}
end
end
defp parse_command("sessions", args, global) do
case args do
[verb | rest] when verb in @sessions_verbs ->
parse_group_verb("sessions", verb, rest, [], global)
[] ->
{:error, "sessions 缺少子指令(可用:#{Enum.join(@sessions_verbs, " ")})", 2}
[unknown | _] ->
{:error, "未知的 sessions 子指令:#{unknown}(可用:#{Enum.join(@sessions_verbs, " ")})", 2}
end
end
defp parse_command("tokens", args, global) do
case args do
[verb | rest] when verb in @tokens_verbs ->
parse_group_verb("tokens", verb, rest, [], global)
[] ->
{:error, "tokens 缺少子指令(可用:#{Enum.join(@tokens_verbs, " ")})", 2}
[unknown | _] ->
{:error, "未知的 tokens 子指令:#{unknown}(可用:#{Enum.join(@tokens_verbs, " ")})", 2}
end
end
defp parse_command("mfa", args, global) do
case args do
[verb | rest] when verb in @mfa_verbs ->
parse_group_verb("mfa", verb, rest, [], global)
[] ->
{:error, "mfa 缺少子指令(可用:#{Enum.join(@mfa_verbs, " ")})", 2}
[unknown | _] ->
{:error, "未知的 mfa 子指令:#{unknown}(可用:#{Enum.join(@mfa_verbs, " ")})", 2}
end
end
# 管理端指令群(jwks/accounts/audit-logs;解析委由 BearCli.Admin)
defp parse_command(group, args, global) when group in ~w(jwks accounts audit-logs) do
case Admin.parse([group | args], global) do
@@ -189,6 +331,22 @@ defmodule BearCli.CLI do
{:error, "未知指令:#{unknown}(可用 bear --help 查看說明)", 2}
end
# sessions/tokens/mfa 共用:verb 已驗證合法後解析選項與位置參數。
defp parse_group_verb(group, verb, rest, extra_switches, global) do
switches = group_switches(group, verb) ++ extra_switches
case OptionParser.parse(rest, strict: @global_switches ++ switches) do
{opts, positionals, []} ->
validate_self_service(group, verb, positionals, merge_opts(global, opts_to_map(opts)))
{_opts, _extra, [{opt, _} | _]} ->
{:error, "無法解析選項:#{opt}", 2}
end
end
defp group_switches("tokens", "create"), do: @tokens_create_switches
defp group_switches(_group, _verb), do: []
defp parse_opts(args, switches) do
case OptionParser.parse(args, strict: switches) do
{opts, [], []} ->
@@ -310,6 +468,92 @@ defmodule BearCli.CLI do
{:error, "#{flag} 不接受的值:#{value}(可用:#{Enum.join(allowed, "、")})", 2}
end
# -- 個人自助指令群解析輔助(§3.7)--
# 各群組的位置參數驗證;通過後回 {:ok, {:self_service, group, verb}, opts}。
defp validate_self_service(:profile, "show", [], opts),
do: {:ok, {:self_service, :profile, :show}, opts}
defp validate_self_service(:profile, "show", extra, _opts),
do: {:error, "profile show 不接受位置參數:#{Enum.join(extra, " ")}", 2}
defp validate_self_service(:profile, "set", [], opts),
do: {:ok, {:self_service, :profile, :set}, opts}
defp validate_self_service(:profile, "set", extra, _opts),
do: {:error, "profile set 不接受位置參數:#{Enum.join(extra, " ")}", 2}
defp validate_self_service("sessions", "list", [], opts),
do: {:ok, {:self_service, :sessions, :list}, opts}
defp validate_self_service("sessions", "list", extra, _opts),
do: {:error, "sessions list 不接受位置參數:#{Enum.join(extra, " ")}", 2}
defp validate_self_service("sessions", "revoke", [id], opts),
do: {:ok, {:self_service, :sessions, :revoke}, Map.put(opts, :id_arg, id)}
defp validate_self_service("sessions", "revoke", [], _opts),
do: {:error, "sessions revoke 缺少 <id>", 2}
defp validate_self_service("sessions", "revoke", extra, _opts),
do: {:error, "sessions revoke 只接受一個 <id>(多餘:#{Enum.join(Enum.drop(extra, 1), " ")})", 2}
defp validate_self_service("sessions", "revoke-others", [], opts),
do: {:ok, {:self_service, :sessions, :"revoke-others"}, opts}
defp validate_self_service("sessions", "revoke-others", extra, _opts),
do: {:error, "sessions revoke-others 不接受位置參數:#{Enum.join(extra, " ")}", 2}
defp validate_self_service("tokens", "list", [], opts),
do: {:ok, {:self_service, :tokens, :list}, opts}
defp validate_self_service("tokens", "list", extra, _opts),
do: {:error, "tokens list 不接受位置參數:#{Enum.join(extra, " ")}", 2}
defp validate_self_service("tokens", "create", [], opts) do
if blank?(opts[:name]) do
{:error, "tokens create 缺少 --name NAME", 2}
else
{:ok, {:self_service, :tokens, :create}, opts}
end
end
defp validate_self_service("tokens", "create", extra, _opts),
do: {:error, "tokens create 不接受位置參數:#{Enum.join(extra, " ")}", 2}
defp validate_self_service("tokens", "revoke", [id], opts),
do: {:ok, {:self_service, :tokens, :revoke}, Map.put(opts, :id_arg, id)}
defp validate_self_service("tokens", "revoke", [], _opts),
do: {:error, "tokens revoke 缺少 <id>", 2}
defp validate_self_service("tokens", "revoke", extra, _opts),
do: {:error, "tokens revoke 只接受一個 <id>(多餘:#{Enum.join(Enum.drop(extra, 1), " ")})", 2}
defp validate_self_service("mfa", "status", [], opts),
do: {:ok, {:self_service, :mfa, :status}, opts}
defp validate_self_service("mfa", "status", extra, _opts),
do: {:error, "mfa status 不接受位置參數:#{Enum.join(extra, " ")}", 2}
defp validate_self_service("mfa", "setup", [], opts),
do: {:ok, {:self_service, :mfa, :setup}, opts}
defp validate_self_service("mfa", "setup", extra, _opts),
do: {:error, "mfa setup 不接受位置參數:#{Enum.join(extra, " ")}", 2}
defp validate_self_service("mfa", "disable", [], opts),
do: {:ok, {:self_service, :mfa, :disable}, opts}
defp validate_self_service("mfa", "disable", extra, _opts),
do: {:error, "mfa disable 不接受位置參數:#{Enum.join(extra, " ")}", 2}
defp validate_self_service("mfa", "recovery-codes", [], opts),
do: {:ok, {:self_service, :mfa, :"recovery-codes"}, opts}
defp validate_self_service("mfa", "recovery-codes", extra, _opts),
do: {:error, "mfa recovery-codes 不接受位置參數:#{Enum.join(extra, " ")}", 2}
defp merge_opts(global, opts) do
Map.merge(Map.new(global), Map.new(opts))
end
@@ -353,6 +597,12 @@ defmodule BearCli.CLI do
apps_api = cmd_opts[:apps_api] || opts[:apps_api] || Api
Apps.run(verb, Map.put(cmd_opts, :apps_api, apps_api))
# 個人自助指令群({:ok, {:self_service, group, verb}, opts} 形狀)
{:ok, {:self_service, group, verb}, cmd_opts} when is_atom(group) and is_atom(verb) ->
inject_opts = opts |> Map.new() |> Map.take([:ss_api, :io, :tty?])
ss_opts = Map.merge(inject_opts, Map.take(cmd_opts, [:ss_api, :io, :tty?]))
SelfService.run(group, verb, Map.merge(cmd_opts, ss_opts))
# 管理端指令群(jwks/accounts/audit-logs;各自注入對應的 api 模組)
{:ok, {Jwks, verb}, cmd_opts} when is_atom(verb) ->
jwks_api = cmd_opts[:jwks_api] || opts[:jwks_api] || Api
@@ -677,6 +927,12 @@ defmodule BearCli.CLI do
jwks <動詞> 簽章金鑰管理(admin)
accounts <動詞> 帳號管理(admin)
audit-logs list 稽核日誌(admin)
profile <動詞> 個人資料(show/set)
password change 變更密碼(互動輸入,不進 shell history)
email change --new E 變更 email(雙向驗證碼流程)
sessions <動詞> 管理網頁 session(list/revoke/revoke-others)
tokens <動詞> 管理 PAT(list/create/revoke)
mfa <動詞> TOTP 兩因子(status/setup/disable/recovery-codes)
apps 指令群(需 admin 權限的 PAT):
apps list [--visibility public|internal] [--status active|inactive]
@@ -690,6 +946,30 @@ defmodule BearCli.CLI do
apps rotate-secret <client-id>
apps toggle <client-id>
個人自助指令群(任何有效 PAT 皆可):
profile show
profile set [--name NAME] [--given-name NAME] [--family-name NAME]
[--nickname NAME] [--preferred-username NAME]
[--profile URL] [--picture URL] [--website URL]
[--gender male|female|other] [--birthdate YYYY-MM-DD]
[--zoneinfo TZ] [--locale LOC] [--phone-number E164]
[--phone-verified] [--address-formatted TEXT]
[--address-street-address TEXT] [--address-locality TEXT]
[--address-region TEXT] [--address-postal-code TEXT]
[--address-country TEXT]
password change
email change --new EMAIL
sessions list
sessions revoke <id>
sessions revoke-others
tokens list
tokens create --name NAME [--expires-in DAYS|never]
tokens revoke <id>
mfa status
mfa setup
mfa disable
mfa recovery-codes
全域選項:
--issuer URL Bear(OIDC Provider)位址(預設 https://alterminal.com)
--config PATH 設定檔路徑(預設 ~/.config/bear/config.json)