summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-23 13:44:11 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-23 13:44:11 +0200
commita78b03f9c2c68eec7fae5eda328ffdbef90b6487 (patch)
tree2ad5384002058c389b8d49702d1e18cff518714f
parent01feeb96c342f216fd5bf7702037fbd98ca49337 (diff)
downloadbokf-a78b03f9c2c68eec7fae5eda328ffdbef90b6487.tar.gz
bokf-a78b03f9c2c68eec7fae5eda328ffdbef90b6487.zip
tui: Bolaget → Användare (read-only member list for owners); org.member_list owner-onlyv0.1.71
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--clients/screens_settings.c132
-rw-r--r--docs/PROTOCOL.md11
-rw-r--r--docs/STATE.md6
-rwxr-xr-xscripts/tui-golden.py21
-rw-r--r--src/cmd_org.c2
-rw-r--r--tests/test_core.c11
6 files changed, 155 insertions, 28 deletions
diff --git a/clients/screens_settings.c b/clients/screens_settings.c
index f47a51b..27582c0 100644
--- a/clients/screens_settings.c
+++ b/clients/screens_settings.c
@@ -482,52 +482,142 @@ static void company_data_screen(struct app *a)
}
}
+static const char *role_label(const char *role)
+{
+ if (!role)
+ return "";
+ if (strcmp(role, "owner") == 0)
+ return "Ägare";
+ if (strcmp(role, "bookkeeper") == 0)
+ return "Bokförare";
+ if (strcmp(role, "viewer") == 0)
+ return "Läsare";
+ return role;
+}
+
+/* Members of the org and their roles; read-only for now. */
+static void users_screen(struct app *a)
+{
+ int cursor = 0;
+ for (;;) {
+ if (g_quit)
+ return;
+ char *resp = client_rpc(&a->conn, "org.member_list", a->session,
+ a->org, "{}");
+ if (!resp || !client_ok(resp)) {
+ show_error("Användare", resp);
+ free(resp);
+ return;
+ }
+ size_t n = jarr_size(resp, "result.items");
+ char **lines = xcalloc(n ? n : 1, sizeof(char *));
+ for (size_t i = 0; i < n; i++) {
+ char path[64];
+ snprintf(path, sizeof path, "result.items.%zu.username", i);
+ char *user = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.display_name", i);
+ char *name = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.role", i);
+ char *role = jstr_dup(resp, path);
+ char ubuf[160], nbuf[224], line[512];
+ snprintf(ubuf, sizeof ubuf, "%s", user ? user : "");
+ tui_pad_field(ubuf, sizeof ubuf, 20);
+ snprintf(nbuf, sizeof nbuf, "%s", name ? name : "");
+ tui_pad_field(nbuf, sizeof nbuf, 28);
+ snprintf(line, sizeof line, "%s %s %s", ubuf, nbuf,
+ role_label(role));
+ lines[i] = xstrdup(line);
+ free(user);
+ free(name);
+ free(role);
+ }
+ free(resp);
+ int s = tui_select_list("Användare", lines, (int)n, cursor, 1,
+ &cursor, 0, NULL, 0);
+ for (size_t i = 0; i < n; i++)
+ free(lines[i]);
+ free(lines);
+ if (s == -1)
+ return;
+ }
+}
+
+enum company_item {
+ CI_COMPANY,
+ CI_INVOICE,
+ CI_SERIES,
+ CI_SMTP,
+ CI_BOARD,
+ CI_USERS,
+ CI_REGISTER,
+ CI_EMPLOYEES,
+ CI_CUSTOMERS,
+ CI_RULES,
+};
+
static const char *const COMPANY_ITEMS[] = {
- "Företagsuppgifter",
- "Fakturauppgifter",
- "Verifikationsserier",
- "E-post (SMTP)",
- "Styrelseledamöter",
- MK_SECTION "Register",
- "Anställda",
- "Kunder",
- "Momsregler",
+ [CI_COMPANY] = "Företagsuppgifter",
+ [CI_INVOICE] = "Fakturauppgifter",
+ [CI_SERIES] = "Verifikationsserier",
+ [CI_SMTP] = "E-post (SMTP)",
+ [CI_BOARD] = "Styrelseledamöter",
+ [CI_USERS] = "Användare",
+ [CI_REGISTER] = MK_SECTION "Register",
+ [CI_EMPLOYEES] = "Anställda",
+ [CI_CUSTOMERS] = "Kunder",
+ [CI_RULES] = "Momsregler",
};
void company_screen(struct app *a)
{
static int sel = 0;
+ const int all = (int)(sizeof COMPANY_ITEMS / sizeof COMPANY_ITEMS[0]);
for (;;) {
if (g_quit)
return;
- int r = tui_menu("Bolaget", COMPANY_ITEMS,
- (int)(sizeof COMPANY_ITEMS / sizeof COMPANY_ITEMS[0]),
- 0, &sel);
+ int owner = a->role[0] && strcmp(a->role, "owner") == 0;
+ const char *items[sizeof COMPANY_ITEMS / sizeof COMPANY_ITEMS[0]];
+ int map[sizeof COMPANY_ITEMS / sizeof COMPANY_ITEMS[0]];
+ int n = 0;
+ for (int i = 0; i < all; i++) {
+ if (i == CI_USERS && !owner)
+ continue;
+ items[n] = COMPANY_ITEMS[i];
+ map[n++] = i;
+ }
+ if (sel >= n)
+ sel = 0;
+ int r = tui_menu("Bolaget", items, n, 0, &sel);
if (r < 0)
return;
- switch (r) {
- case 0:
+ if (r >= n)
+ continue;
+ switch (map[r]) {
+ case CI_COMPANY:
company_data_screen(a);
break;
- case 1:
+ case CI_INVOICE:
invoice_settings_screen(a);
break;
- case 2:
+ case CI_SERIES:
series_settings_screen(a);
break;
- case 3:
+ case CI_SMTP:
smtp_settings_screen(a);
break;
- case 4:
+ case CI_BOARD:
board_screen(a);
break;
- case 6:
+ case CI_USERS:
+ users_screen(a);
+ break;
+ case CI_EMPLOYEES:
employees_screen(a);
break;
- case 7:
+ case CI_CUSTOMERS:
customers_screen(a);
break;
- case 8:
+ case CI_RULES:
rules_screen(a);
break;
default:
diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md
index 3c8ba54..2e4ea80 100644
--- a/docs/PROTOCOL.md
+++ b/docs/PROTOCOL.md
@@ -299,7 +299,7 @@ Arguments are shown abbreviated; `describe` is authoritative.
| `org.create` | `name`, `org_nr?`, `fiscal_year_start_month?`, `moms_period?`, `framework?` | `org` |
| `org.get` / `org.list` | `org?` | `org` / `items[]` |
| `org.update` | `org`, fields | `org` (owner) |
-| `org.member_list` | `org` | `items[{user,role}]` |
+| `org.member_list` | `org` | `items[{user_id,username,display_name,role}]` (owner) |
| `org.member_add` | `org`, `username`, `role` | `{}` |
| `org.member_set_role` | `org`, `username`, `role` | `{}` |
| `org.member_remove` | `org`, `username` | `{}` |
@@ -844,7 +844,7 @@ Args: `name:type(values)[!][=default]`, `!` = required.
| `org.list` | viewer | no | no | no | — |
| `org.get` | viewer | yes | no | no | — |
| `org.update` | owner | yes | yes | yes | `name:string`, `org_nr:string`, `vat_nr:string`, `address:string`, `postal_code:string`, `city:string`, `country:string`, `email:string`, `phone:string`, `moms_period:enum(month\|quarter\|year)`, `framework:enum(K2\|K3)`, `description:string`, `fiscal_year_start_month:int`, `shares:int` |
-| `org.member_list` | viewer | yes | no | no | — |
+| `org.member_list` | owner | yes | no | no | — |
| `org.member_add` | owner | yes | yes | yes | `username:string!`, `role:enum(owner\|bookkeeper\|viewer)!` |
| `org.member_set_role` | owner | yes | yes | yes | `username:string!`, `role:enum(owner\|bookkeeper\|viewer)!` |
| `org.member_remove` | owner | yes | yes | yes | `username:string!` |
@@ -1054,8 +1054,9 @@ commands. Implemented screens (0.1.0-dev):
referens); **Verifikationsserier** (the per-feature series); and
**E-post (SMTP)** (host, port, user, sender, reply-to, security,
password), the last three `settings.set` and open to bookkeepers;
- **Styrelseledamöter**; and the registers **Anställda**, **Kunder** and
- **Momsregler**.
+ **Styrelseledamöter**; **Användare** (owners only: the org's members
+ with user name, name and role from `org.member_list`, read-only for
+ now); and the registers **Anställda**, **Kunder** and **Momsregler**.
- **System** — the hub with **Skattetabeller** and **Revision**. The file
browser starts in the directory of the last picked attachment (remembered
in the client's `tui.conf`) and falls back to `$HOME` when it is gone.
@@ -1079,7 +1080,7 @@ year and current top-level view come back without a new login. The same
flags can be passed manually (`--session ID` is also accepted).
Still missing from the UI (API already supports): SIE export/import,
-period locks, member/token administration. The TUI holds no local state
+period locks, member changes (add/set role/remove) and token administration. The TUI holds no local state
beyond the session and calls nothing but public commands.
## 9. Versioning
diff --git a/docs/STATE.md b/docs/STATE.md
index 8a631b5..fd01b05 100644
--- a/docs/STATE.md
+++ b/docs/STATE.md
@@ -14,6 +14,12 @@ unit tests and the docs consistency check.
## Resume here (2026-09-23)
+- **Bolaget → Användare (2026-09-23, not deployed)**: owners see the org's
+ members (user name, name, role as Ägare/Bokförare/Läsare) in a read-only
+ list; the menu item is hidden for other roles. `org.member_list` is now
+ owner-only on the server too (it was open to viewers, contrary to the
+ §4.3 table). Next: add/change role/remove members from the view
+ (`org.member_add`/`_set_role`/`_remove` exist), then user creation.
- **Web åäö fix (2026-09-23, `v0.1.70`)**: the web TUI showed no åäö on
the NAS: the cross-built static glibc bokftui found no locale data in
the Alpine image (setlocale failed, åäö were invalid bytes). The image
diff --git a/scripts/tui-golden.py b/scripts/tui-golden.py
index deca6ef..3049ed2 100755
--- a/scripts/tui-golden.py
+++ b/scripts/tui-golden.py
@@ -416,7 +416,7 @@ SCENARIOS = [
"name": "company",
"screen": "company",
"expect": ["Bolaget", "Företagsuppgifter", "Fakturauppgifter",
- "Verifikationsserier", "E-post (SMTP)", "Anställda",
+ "Verifikationsserier", "E-post (SMTP)", "Användare", "Anställda",
"Kunder", "Momsregler"],
"steps": [
{
@@ -447,6 +447,15 @@ SCENARIOS = [
},
{
"keys": ["6"],
+ "expect": ["Användare", "admin", "Ägare",
+ "bob", "Bob Bokförsson", "Bokförare"],
+ },
+ {
+ "keys": ["esc"],
+ "expect": ["Bolaget", "Styrelseledamöter"],
+ },
+ {
+ "keys": ["7"],
"expect": ["Anställda", "Testanställd"],
},
{
@@ -1318,6 +1327,16 @@ def setup_rig(repo, tmp, env):
"tax_table": 30,
"tax_column": 1,
"email": "test@example.com"})], env)
+ # a second member for Bolaget -> Användare
+ run_checked([str(bind / "bokfctl"), "--socket", str(sock),
+ "--user", "admin", "--password", env["BOKFD_PASSWORD"],
+ "user.create",
+ json.dumps({"username": "bob", "password": "bob-password-1",
+ "display_name": "Bob Bokförsson"})], env)
+ run_checked([str(bind / "bokfctl"), "--socket", str(sock),
+ "--user", "admin", "--password", env["BOKFD_PASSWORD"],
+ "--org", str(org_id), "org.member_add",
+ json.dumps({"username": "bob", "role": "bookkeeper"})], env)
# a second org for "Byt bolag"; the TUI always starts in the first
run_checked([str(bind / "bokfctl"), "--socket", str(sock),
"--user", "admin", "--password", env["BOKFD_PASSWORD"],
diff --git a/src/cmd_org.c b/src/cmd_org.c
index 0fe8601..03a0146 100644
--- a/src/cmd_org.c
+++ b/src/cmd_org.c
@@ -641,7 +641,7 @@ const struct command g_cmd_org[] = {
{ "org.get", "Get org details", PERM_READ, 1, 0, 0, h_org_get, NULL, 0 },
{ "org.update", "Update org details (owner)", PERM_OWNER, 1, 1, 1,
h_org_update, CMD_ARGS(args_org_update) },
- { "org.member_list", "List org members", PERM_READ, 1, 0, 0,
+ { "org.member_list", "List org members", PERM_OWNER, 1, 0, 0,
h_org_member_list, NULL, 0 },
{ "org.member_add", "Add a member to the org", PERM_OWNER, 1, 1, 1,
h_org_member_add, CMD_ARGS(args_member_role) },
diff --git a/tests/test_core.c b/tests/test_core.c
index a82c833..ac4211b 100644
--- a/tests/test_core.c
+++ b/tests/test_core.c
@@ -1149,8 +1149,19 @@ static void test_tokens(struct tctx *t)
"\"session\":\"%s\",\"org\":%d}",
g_session, (int)t->org_id));
CHECK(yyjson_arr_size(jget(d, "result.items")) == 2);
+ CHECK_STR(d, "result.items.1.username", "bob");
+ CHECK_STR(d, "result.items.1.display_name", "Bob");
+ CHECK_STR(d, "result.items.1.role", "viewer");
yyjson_doc_free(d);
+ CHECK(login("bob", "hunter22"));
+ d = call(reqf("{\"v\":1,\"id\":\"22b\",\"cmd\":\"org.member_list\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)t->org_id));
+ CHECK_STR(d, "error.code", "FORBIDDEN");
+ yyjson_doc_free(d);
+ CHECK(login("admin", "secret123"));
+
d = call(reqf("{\"v\":1,\"id\":\"23\",\"cmd\":\"org.member_set_role\","
"\"session\":\"%s\",\"org\":%d,\"args\":"
"{\"username\":\"bob\",\"role\":\"bookkeeper\"}}",