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:
+281
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user