diff options
| -rw-r--r-- | clients/screens_settings.c | 132 | ||||
| -rw-r--r-- | docs/PROTOCOL.md | 11 | ||||
| -rw-r--r-- | docs/STATE.md | 6 | ||||
| -rwxr-xr-x | scripts/tui-golden.py | 21 | ||||
| -rw-r--r-- | src/cmd_org.c | 2 | ||||
| -rw-r--r-- | tests/test_core.c | 11 |
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\"}}", |
