feat: 實作個人自助指令群 profile/password/email/sessions/tokens/mfa(issue #11)
- lib/bear_cli/self_service.ex:六個子群組(profile show/set、password change、 email change、sessions list/revoke/revoke-others、tokens list/create/revoke、 mfa status/setup/disable/recovery-codes),對接 alterminal/bear#38 的 /api/v1/profile* JSON API(PAT Bearer、任何有效帳號) - 敏感輸入(密碼/驗證碼)互動提示讀取,非 TTY → 退出碼 2; 一次性明文(PAT 明文、recovery codes)僅於成功當下輸出 - mfa setup:otpauth URI 本地生成 ASCII QR(新增 eqrcode 依賴), 確認走 /mfa/setup/confirm(依 #38 實作) - Api 模組新增 profile 系列端點與 429 分流;CLI 分派接上六個指令群組 - docs/commands.md §3.7 對齊 #38 實際 API(sessions 欄位、tokens create 無 --scope、mfa setup/confirm、email 需重帶 new_email);§7/§8/§9 與 README 同步更新 - 測試 50 例(fake API 注入):解析、輸出、退出碼、互動輸入、錯誤分流; mix precommit 全綠(114 passed)
This commit is contained in:
+281
-1
@@ -8,7 +8,7 @@ defmodule BearCli.CLI do
|
||||
優先使用該值,不讀寫本機憑證檔。
|
||||
"""
|
||||
|
||||
alias BearCli.{Api, Apps, Config, Credentials}
|
||||
alias BearCli.{Api, Apps, Config, Credentials, 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,10 +210,135 @@ 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
|
||||
|
||||
defp parse_command(unknown, _args, _global) 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, [], []} ->
|
||||
@@ -302,6 +460,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
|
||||
@@ -345,6 +589,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))
|
||||
|
||||
{:ok, command, cmd_opts} ->
|
||||
cond do
|
||||
cmd_opts[:version] ->
|
||||
@@ -653,6 +903,12 @@ defmodule BearCli.CLI do
|
||||
logout 登出(清除本機憑證)
|
||||
status 顯示登入狀態
|
||||
apps <動詞> App 管理(admin;bear apps --help 查看)
|
||||
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]
|
||||
@@ -666,6 +922,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