Files
bear-cli/lib/bear_cli/accounts.ex
alex a29b976f13 fix: stdin TTY 偵測改以 fd 0 實際裝置判定,escript 真終端機不再誤判(issue #19)
- 新增 BearCli.TTY:readlink /proc/self/fd/0 判定 pts/tty/console/ptmx;
  無 /proc 平台退回 :io.rows/0
- vault/self_service 的 tty_stdin?、accounts 的 tty? 改用 TTY.stdin_tty?
- 根因::io.rows/0/:io.columns/0 走 io 協定問 group leader,escript
  (noshell standard_io)不支援行列查詢,一律回 enotsup——真終端機被
  誤判成非 TTY,互動指令(vault unlock 等)無法讀 stdin 密碼
  (bear #52 驗收建議 2)
- 非 TTY(pipe/redirect)行為不變:明確提示+退出碼 2
- 新增 tty_test(4 測試);mix precommit 全綠(200 passed)
2026-09-14 19:26:23 +08:00

352 lines
11 KiB
Elixir
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
defmodule BearCli.Accounts do
@moduledoc """
`bear accounts` 指令群(帳號管理;docs/commands.md §3.9、issue #12)。
對應伺服器端帳號管理 JSON API(alterminal/bear#46,`/api/v1/accounts`、
PAT Bearer、admin 限定)。認證沿用既有 PAT 憑證(`BEAR_TOKEN` 或憑證檔)。
CLI 對外介面一律以 `email` 識別帳號(人類可讀、唯一);API 路徑參數為
UUID,由本模組先以 list 分頁比對解析。帳號 JSON 不含 `hash_password`、
`totp_secret` 等機密欄位(API 端保證),CLI 亦不顯示。
`create`/`set-password` 未給 `--password` 時由 CLI 產生隨機密碼,並只在
成功當下一次性輸出(不寫入憑證檔/log/`--verbose`);密碼一律不進命令列
(避免 shell history/`ps`),改以互動輸入(不回顧)或 `--generate-password`。
敏感規則與伺服器端一致:不可移除自己的 admin 角色(403)、不可刪除自己(403)。
退出碼:0 成功;1 404/422;2 用法錯誤;3 未登入或 401;6 網路/伺服器
錯誤;8 權限不足(403,僅限 admin)。
"""
import BearCli.Admin, only: [context: 1, fail: 3, format_api_error: 2]
alias BearCli.Api
alias BearCli.TTY
@list_per_page_fetch 100
@max_fetch_pages 50
# -- 入口 --
@doc """
執行 `bear accounts <動詞>`,回傳退出碼。`opts[:accounts_api]` 可注入假
API 模組供測試(需實作 `accounts_list/3`、`accounts_create/3`、
`accounts_get/3`、`accounts_update_role/4`、`accounts_set_password/4`、
`accounts_delete/3`)。
"""
def run(verb, opts) do
api = opts[:accounts_api] || Api
with {:ok, ctx} <- context(opts) do
execute(verb, ctx, opts, api)
else
{:error, message, code} -> fail(opts, message, code)
end
end
# -- 各動詞 --
defp execute(:list, ctx, opts, api) do
params = [page: opts[:page] || 1, per_page: opts[:per_page] || 20]
case api.accounts_list(ctx.issuer, ctx.token, params) do
{:ok, body} ->
accounts = body["data"] || []
page = body["page"] || params[:page]
per_page = body["per_page"] || params[:per_page]
total = body["total"] || length(accounts)
render_list(opts, accounts, page, per_page, total)
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
end
defp execute(:show, ctx, opts, api) do
with {:ok, account} <- resolve_account(ctx, opts[:email_arg], api) do
case api.accounts_get(ctx.issuer, ctx.token, account["id"]) do
{:ok, body} ->
show_account(body["data"] || %{}, opts)
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
else
{:error, message, code} -> fail(opts, message, code)
end
end
defp execute(:create, ctx, opts, api) do
with {:ok, password} <- read_password(opts) do
attrs = %{"email" => opts[:email], "hash_password" => password}
attrs = if opts[:role], do: Map.put(attrs, "role", opts[:role]), else: attrs
case api.accounts_create(ctx.issuer, ctx.token, attrs) do
{:ok, body} ->
account = body["data"] || %{}
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, account: account, initial_password: password}))
else
IO.puts("帳號已建立:#{account["email"]}(#{account["role"]})")
print_password_block(password)
end
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
else
{:error, message, code} -> fail(opts, message, code)
end
end
defp execute(:update, ctx, opts, api) do
with {:ok, account} <- resolve_account(ctx, opts[:email_arg], api) do
case api.accounts_update_role(ctx.issuer, ctx.token, account["id"], %{"role" => opts[:role]}) do
{:ok, body} ->
updated = body["data"] || %{}
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, account: updated}))
else
IO.puts("#{updated["email"]}:角色 → #{updated["role"]}")
end
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
else
{:error, message, code} -> fail(opts, message, code)
end
end
defp execute(:"set-password", ctx, opts, api) do
with {:ok, account} <- resolve_account(ctx, opts[:email_arg], api),
{:ok, password} <- read_password(opts) do
case api.accounts_set_password(ctx.issuer, ctx.token, account["id"], %{
"hash_password" => password
}) do
{:ok, _body} ->
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, email: account["email"], new_password: password}))
else
IO.puts("已為 #{account["email"]} 設定新密碼。")
print_password_block(password)
end
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
else
{:error, message, code} -> fail(opts, message, code)
end
end
defp execute(:delete, ctx, opts, api) do
with {:ok, account} <- resolve_account(ctx, opts[:email_arg], api) do
case api.accounts_delete(ctx.issuer, ctx.token, account["id"]) do
{:ok, _body} ->
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, email: account["email"], deleted: true}))
else
IO.puts("已刪除帳號:#{account["email"]}")
end
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
else
{:error, message, code} -> fail(opts, message, code)
end
end
# -- email → UUID 解析(分頁全量列舉比對;設頁數上限避免無限迴圈)--
defp resolve_account(_ctx, nil, _api), do: {:error, "缺少 <email> 參數", 2}
defp resolve_account(ctx, email, api) do
with {:ok, all} <- fetch_all(ctx, api) do
case Enum.find(all, &(&1["email"] == email)) do
nil -> {:error, "找不到 email 為 #{email} 的帳號", 1}
account -> {:ok, account}
end
else
{:error, message, code} -> {:error, message, code}
end
end
defp fetch_all(ctx, api) do
do_fetch_all(ctx, api, 1, [])
end
defp do_fetch_all(_ctx, _api, page, acc) when page > @max_fetch_pages, do: {:ok, acc}
defp do_fetch_all(ctx, api, page, acc) do
case api.accounts_list(ctx.issuer, ctx.token, page: page, per_page: @list_per_page_fetch) do
{:ok, body} ->
entries = body["data"] || []
acc = acc ++ entries
total = body["total"]
if entries == [] or length(entries) < @list_per_page_fetch or
(is_integer(total) and length(acc) >= total) do
{:ok, acc}
else
do_fetch_all(ctx, api, page + 1, acc)
end
{:error, _kind, _detail} = error ->
{message, code} = format_api_error(ctx, error)
{:error, message, code}
end
end
# -- 密碼輸入(不進命令列;非 TTY 且未 --generate-password → 用法錯誤)--
defp read_password(%{generate_password: true}), do: {:ok, generate_password()}
defp read_password(_opts) do
if tty?() do
password = prompt_password("新密碼(至少 8 字元,含字母與數字):")
confirm = prompt_password("再次輸入新密碼:")
cond do
password != confirm ->
{:error, "兩次輸入的密碼不一致", 2}
weak_password?(password) ->
{:error, "密碼至少 8 字元且須包含字母與數字", 2}
true ->
{:ok, password}
end
else
{:error, "非 TTY 環境無法互動輸入密碼;請加 --generate-password 由 CLI 產生", 2}
end
end
@password_chars "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789"
@password_length 16
defp generate_password do
@password_chars
|> String.codepoints()
|> Enum.take_random(@password_length)
|> Enum.join()
end
defp weak_password?(password) when is_binary(password) do
String.length(password) >= 8 and Regex.match?(~r/[a-zA-Z]/, password) and
Regex.match?(~r/[0-9]/, password)
end
defp weak_password?(_), do: true
# stdin 是否為 TTY:優先以 fd 0 實際裝置判定(escript 環境 :io.columns/0
# 一律 enotsup,真終端機會被誤判;見 BearCli.TTY,issue #19)。
defp tty? do
TTY.stdin_tty?()
end
defp prompt_password(prompt) do
IO.puts(:stderr, prompt)
IO.gets("") |> String.trim_trailing("\n")
end
# -- 輸出 --
defp render_list(opts, accounts, page, per_page, total) do
if opts[:json] do
IO.puts(
Jason.encode!(%{
ok: true,
page: page,
per_page: per_page,
total: total,
accounts: accounts
})
)
else
print_table(accounts)
end
end
@table_headers ["EMAIL", "ROLE", "TOTP", "CREATED AT"]
defp print_table(accounts) do
rows =
Enum.map(accounts, fn account ->
[
account["email"] || "",
account["role"] || "",
totp_label(account["totp_enabled"]),
account["created_at"] || ""
]
end)
widths =
Enum.with_index(@table_headers, fn _header, i ->
Enum.max([
String.length(Enum.at(@table_headers, i))
| Enum.map(rows, &String.length(Enum.at(&1, i)))
])
end)
render_row = fn cells ->
cells
|> Enum.with_index()
|> Enum.map(fn {cell, i} -> String.pad_trailing(cell, Enum.at(widths, i)) end)
|> Enum.join(" ")
|> String.trim_trailing()
end
IO.puts(render_row.(@table_headers))
Enum.each(rows, fn cells -> IO.puts(render_row.(cells)) end)
end
@account_fields ~w(id email role totp_enabled created_at updated_at)
defp show_account(account, opts) do
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, account: account}))
else
Enum.each(@account_fields, fn field ->
IO.puts("#{String.pad_trailing(field, 16)}: #{format_account_value(account[field])}")
end)
end
end
defp format_account_value(nil), do: "(無)"
defp format_account_value(true), do: "已啟用"
defp format_account_value(false), do: "未啟用"
defp format_account_value(value) when is_binary(value), do: value
defp format_account_value(value), do: Jason.encode!(value)
defp totp_label(true), do: "on"
defp totp_label(false), do: "off"
defp totp_label(_), do: "(未知)"
# 初始/新密碼只在成功當下一次性輸出(不寫入憑證檔/log/--verbose)。
defp print_password_block(password) do
IO.puts("初始密碼(只顯示這一次,請立即保存並轉交當事人):")
IO.puts(" #{password}")
end
# -- 錯誤處理 --
defp fail_api(opts, ctx, error) do
{message, code} = format_api_error(ctx, error)
fail(opts, message, code)
end
end