diff --git a/backend/common/tests/test_pat_org_api.py b/backend/common/tests/test_pat_org_api.py index 43da6937..194052cb 100644 --- a/backend/common/tests/test_pat_org_api.py +++ b/backend/common/tests/test_pat_org_api.py @@ -114,6 +114,32 @@ class TestOrgTokenList: body = admin_client.get(LIST_URL).json() assert body["totals"]["unused_90d"] == 1 + def test_a_token_just_issued_is_not_unused_for_90_days( + self, admin_client, admin_profile + ): + # last_used_at is null for a token issued three years ago AND for one + # issued a minute ago. Counting on null alone put the token you had just + # created into "unused for 90+ days" on the very next reload. + PersonalAccessToken.generate(profile=admin_profile, name="brand new") + + body = admin_client.get(LIST_URL).json() + assert body["totals"]["unused_90d"] == 0 + + def test_a_never_used_token_counts_once_it_is_old_enough( + self, admin_client, admin_profile + ): + # The other half: never used is still the strongest signal there is, so + # an old one has to count. created_at is what dates it. + _, forgotten = PersonalAccessToken.generate( + profile=admin_profile, name="forgotten" + ) + PersonalAccessToken.objects.filter(pk=forgotten.pk).update( + created_at=timezone.now() - timedelta(days=120) + ) + + body = admin_client.get(LIST_URL).json() + assert body["totals"]["unused_90d"] == 1 + def test_no_secret_ever_serialized(self, admin_client, admin_profile): raw, _ = PersonalAccessToken.generate(profile=admin_profile, name="cli") body = admin_client.get(LIST_URL).json() diff --git a/backend/common/views/pat_views.py b/backend/common/views/pat_views.py index 43baa6a5..9567d752 100644 --- a/backend/common/views/pat_views.py +++ b/backend/common/views/pat_views.py @@ -112,6 +112,13 @@ class OrgAccessTokenListView(APIView): dormant liability, not a live credential. It would authenticate again only if the account were reactivated. The count is surfaced so an admin can revoke it as part of offboarding, not because it is "still working". + + A NOTE ON "UNUSED 90+ DAYS": a token that has never been used is measured + from ``created_at``, not counted outright. ``last_used_at`` is null both for + a token issued three years ago and for one issued a minute ago, and the + count used to treat the two the same, so creating a token through the page + put the very token you just made into "unused for 90+ days" on the next + reload. A figure that names a duration has to have measured one. """ permission_classes = (IsAuthenticated, HasOrgContext, IsOrgAdmin) @@ -147,7 +154,9 @@ class OrgAccessTokenListView(APIView): totals["live"] += 1 if not owner.is_active: totals["orphaned"] += 1 - if pat.last_used_at is None or (now - pat.last_used_at).days > 90: + # Never used falls back to when it was issued, so the age is + # always a measured one. See the class docstring. + if (now - (pat.last_used_at or pat.created_at)).days > 90: totals["unused_90d"] += 1 return Response( diff --git a/frontend/src/lib/server/v2/tokens.js b/frontend/src/lib/server/v2/tokens.js index d30486cb..92df88f0 100644 --- a/frontend/src/lib/server/v2/tokens.js +++ b/frontend/src/lib/server/v2/tokens.js @@ -11,8 +11,13 @@ * spot the two rows worth acting on. One whose owner was deactivated, and one * nobody has used in months. That is inherently cross-user, so it reads a NEW * admin-gated endpoint (`GET /api/org/tokens/`). The pre-existing - * `/api/profile/tokens/` stays self-scoped and untouched. A user managing - * their own tokens is a separate surface, not this one. + * `/api/profile/tokens/` stays self-scoped and untouched. + * + * A consequence worth naming rather than implying: because this whole page is + * admin-gated, **a member has no way to issue themselves a token in either + * client**. `/api/profile/tokens/` is open to them and would work; nothing + * calls it on their behalf. A self-service surface is a page neither app has + * yet, not one that exists elsewhere. Recorded in `mobile/docs/PARITY.md`. * * WHAT THE MOCK ASSERTED THAT THE BACKEND DOES NOT * The fixture's headline was "a token whose owner was deactivated keeps working diff --git a/frontend/src/routes/(app)/settings/api-tokens/+page.server.js b/frontend/src/routes/(app)/settings/api-tokens/+page.server.js index a619727c..96e0dfcd 100644 --- a/frontend/src/routes/(app)/settings/api-tokens/+page.server.js +++ b/frontend/src/routes/(app)/settings/api-tokens/+page.server.js @@ -1,6 +1,7 @@ import { fail } from '@sveltejs/kit'; import { listOrgTokens, createToken, revokeToken } from '$lib/server/v2/tokens.js'; import { readableError } from '$lib/server/v2/form-errors.js'; +import { expiryFromChoice, scopesFromChoice } from './oversight.js'; /** * API tokens (admin oversight). @@ -15,40 +16,6 @@ export async function load({ cookies }) { return await listOrgTokens({ cookies }); } -/** - * Turn a coarse expiry choice into an ISO datetime the API will accept, or null - * for "never". Kept coarse on purpose. A date picker is more precision than a - * token expiry needs, and "never" is named rather than left as an empty field - * so the riskiest choice is a deliberate one. - * - * @param {string | undefined} choice - * @returns {string | null} - */ -function expiryFromChoice(choice) { - const days = { 30: 30, 90: 90, 365: 365 }[choice ?? '']; - if (!days) return null; - const d = new Date(); - d.setDate(d.getDate() + days); - return d.toISOString(); -} - -/** - * Turn the access choice into the scope list the API enforces. - * - * `common/scopes.py` reads `:` and treats an empty list as - * unrestricted, which is what every token issued before enforcement carries. - * The backend understands per-resource scopes too; this form deliberately - * offers only the two choices that are worth a radio button, because a picker - * listing thirty resources is a worse question than "may it change anything?". - * A caller who wants `leads:read` can create the token through the API. - * - * @param {string | undefined} choice - * @returns {string[]} - */ -function scopesFromChoice(choice) { - return choice === 'read' ? ['*:read'] : []; -} - /** @type {import('./$types').Actions} */ export const actions = { /** diff --git a/frontend/src/routes/(app)/settings/api-tokens/+page.svelte b/frontend/src/routes/(app)/settings/api-tokens/+page.svelte index e4bfa12b..7978b588 100644 --- a/frontend/src/routes/(app)/settings/api-tokens/+page.svelte +++ b/frontend/src/routes/(app)/settings/api-tokens/+page.svelte @@ -12,7 +12,10 @@ * WHO SEES THIS * This lists every token across the org, so it is admin-only: `/api/org/tokens/` * returns 403 to a member and the page renders "Admins only" rather than a - * broken table. Managing your own tokens is a separate, self-scoped surface. + * broken table. A consequence rather than a design: because this is the only + * token page either client has, a member currently has no way to issue + * themselves one. `/api/profile/tokens/` is open to them; nothing calls it on + * their behalf. Recorded in `mobile/docs/PARITY.md`. * * "OWNER DEACTIVATED" IS A DORMANT ROW, NOT A LIVE ONE * The mock this page replaced claimed such a token "keeps working with the @@ -44,10 +47,11 @@ import StatCard from '$lib/v2/components/StatCard.svelte'; import Pill from '$lib/v2/components/Pill.svelte'; import NextAction from '$lib/v2/components/NextAction.svelte'; - import { count, relativeDays, shortDate, daysSince } from '$lib/v2/format.js'; + import { count, relativeDays, shortDate } from '$lib/v2/format.js'; import { ROLE_LABEL } from '$lib/v2/enums.js'; import { enhance } from '$app/forms'; import { Plus, ShieldAlert, Copy, Check } from '@lucide/svelte'; + import { tokenStatus, staleness, scopeSummary, EXPIRY_CHOICES } from './oversight.js'; /** @type {{ data: any, form: any }} */ let { data, form } = $props(); @@ -88,27 +92,6 @@ // select by hand, nothing else to do, and no error worth alarming over. } } - - /** - * Revoked and expired are different reasons for the same outcome, so they - * read differently but tone the same. Neither is a live credential. - * - * @param {any} t - * @returns {{ label: string, tone: 'ink'|'slate'|'clay'|'rust'|'moss' }} - */ - function statusOf(t) { - if (t.revoked_at) return { label: 'Revoked', tone: 'slate' }; - if (!t.is_live) return { label: 'Expired', tone: 'slate' }; - return { label: 'Live', tone: 'moss' }; - } - - /** Never used, or not for a long time. Either way it is a key under a mat. */ - function staleness(t) { - if (!t.is_live) return null; - if (!t.last_used_at) return 'never used'; - const n = daysSince(t.last_used_at) ?? 0; - return n > 90 ? `unused for ${n} days` : null; - } {#if data.forbidden} @@ -233,10 +216,9 @@ Expires @@ -287,7 +269,7 @@ {#each data.tokens as t (t.id)} - {@const s = statusOf(t)} + {@const s = tokenStatus(t)} {@const stale = staleness(t)} {@const owner = t.owner ?? {}} @@ -308,15 +290,7 @@ - - {#if (t.scopes ?? []).length === 0} - Everything {(owner.name ?? '').split(' ')[0]} can - {:else if (t.scopes ?? []).every( (/** @type {string} */ s) => s.endsWith(':read') )} - Read only - {:else} - {(t.scopes ?? []).join(', ')} - {/if} - + {scopeSummary(t)} {#if t.last_used_at} diff --git a/frontend/src/routes/(app)/settings/api-tokens/oversight.js b/frontend/src/routes/(app)/settings/api-tokens/oversight.js new file mode 100644 index 00000000..4e30994c --- /dev/null +++ b/frontend/src/routes/(app)/settings/api-tokens/oversight.js @@ -0,0 +1,129 @@ +/** + * What an oversight row says about one token, and what the create form sends. + * + * Its own module rather than inline in `+page.svelte` and `+page.server.js`, + * because the harness cannot import a `.svelte` file and every rule here has a + * right answer that lives in the backend: `OrgAccessTokenListView` for the + * staleness rule, `common/scopes.py` for the grammar, and + * `PersonalAccessTokenCreateSerializer` for what a create body may carry. The + * routing, escalation and business-hours routes do the same with `rotation.js`, + * `outcome.js` and `week.js`. + * + * `mobile/lib/data/models/access_token.dart` carries the same rules. + */ + +import { daysSince } from '$lib/v2/format.js'; + +/** + * When this token last did anything, falling back to when it was issued. + * + * **`last_used_at` being null does not mean "long ago".** It is null both for a + * token issued three years ago and for one issued a minute ago, and the + * "Unused 90+ days" figure used to count on null alone, so creating a token + * through this page put the token you had just made into that count on the very + * next reload. `OrgAccessTokenListView` now measures the same fallback, and this + * is the row-level half of it: the card and the row have to agree about which + * rows they are counting. + * + * @param {{ last_used_at?: string | null, created_at?: string | null }} token + * @returns {string | null} + */ +export function lastActivityAt(token) { + return token?.last_used_at ?? token?.created_at ?? null; +} + +/** + * The clay line under "Last used", or null when there is nothing to say. + * + * Only for a live token: a revoked or expired one is already not a credential, + * and flagging it as neglected as well would bury the rows worth acting on. + * + * @param {any} token + * @param {Date} [now] + * @returns {string | null} + */ +export function staleness(token, now = new Date()) { + if (!token?.is_live) return null; + const days = daysSince(lastActivityAt(token), now); + if (days === null || days <= 90) return null; + return token.last_used_at ? `unused for ${days} days` : `never used, issued ${days} days ago`; +} + +/** + * Revoked and expired are different reasons for the same outcome, so they read + * differently and tone the same. Neither is a live credential. + * + * @param {any} token + * @returns {{ label: string, tone: 'ink'|'slate'|'clay'|'rust'|'moss' }} + */ +export function tokenStatus(token) { + if (token?.revoked_at) return { label: 'Revoked', tone: 'slate' }; + if (!token?.is_live) return { label: 'Expired', tone: 'slate' }; + return { label: 'Live', tone: 'moss' }; +} + +/** + * What this token may do, in the words the table uses. + * + * An empty scope list means unrestricted (see the `common.scopes` docstring for + * why), which is what every token issued before enforcement carries, so those + * rows say what they really are rather than being drawn as limited. + * + * @param {any} token + * @returns {string} + */ +export function scopeSummary(token) { + const scopes = token?.scopes ?? []; + if (scopes.length === 0) { + const first = (token?.owner?.name ?? '').split(' ')[0]; + return first ? `Everything ${first} can` : 'Everything its owner can'; + } + if (scopes.every((/** @type {string} */ s) => s.endsWith(':read'))) return 'Read only'; + return scopes.join(', '); +} + +/** + * The coarse expiry choices the form offers, in the order it offers them. + * + * Coarse on purpose: a date picker is more precision than a token expiry needs, + * and "never" is a named option rather than an empty field so the riskiest + * choice is a deliberate one. + */ +export const EXPIRY_CHOICES = [ + { value: '90', label: 'In 90 days', days: 90 }, + { value: '30', label: 'In 30 days', days: 30 }, + { value: '365', label: 'In 1 year', days: 365 }, + { value: 'never', label: 'Never', days: null } +]; + +/** + * Turn a coarse expiry choice into an ISO datetime the API will accept, or null + * for "never". Anything unrecognised is "never" rather than an error, matching + * the serializer, which treats an absent `expires_at` the same way. + * + * @param {string | undefined | null} choice + * @param {Date} [now] + * @returns {string | null} + */ +export function expiryFromChoice(choice, now = new Date()) { + const days = EXPIRY_CHOICES.find((c) => c.value === choice)?.days ?? null; + if (!days) return null; + const d = new Date(now.getTime()); + d.setDate(d.getDate() + days); + return d.toISOString(); +} + +/** + * Turn the access choice into the scope list the API enforces. + * + * The backend understands per-resource scopes (`leads:read`), but this form + * deliberately offers only the two choices worth a radio button, because a + * picker listing thirty resources is a worse question than "may it change + * anything?". A caller who wants finer scopes creates the token through the API. + * + * @param {string | undefined | null} choice + * @returns {string[]} + */ +export function scopesFromChoice(choice) { + return choice === 'read' ? ['*:read'] : []; +} diff --git a/frontend/src/routes/(app)/settings/api-tokens/oversight.test.js b/frontend/src/routes/(app)/settings/api-tokens/oversight.test.js new file mode 100644 index 00000000..6a1e9c51 --- /dev/null +++ b/frontend/src/routes/(app)/settings/api-tokens/oversight.test.js @@ -0,0 +1,152 @@ +import { describe, it, expect } from 'vitest'; +import { + lastActivityAt, + staleness, + tokenStatus, + scopeSummary, + expiryFromChoice, + scopesFromChoice, + EXPIRY_CHOICES +} from './oversight.js'; + +const NOW = new Date('2026-08-08T12:00:00Z'); + +/** Days before NOW, as the ISO string the API sends. */ +function ago(days) { + return new Date(NOW.getTime() - days * 86400000).toISOString(); +} + +/** A live token as `/api/org/tokens/` returns one. */ +function token(over = {}) { + return { + id: 't1', + name: 'Nightly export', + token_prefix: 'bcrm_pat_abc', + scopes: [], + expires_at: null, + last_used_at: null, + created_at: ago(1), + revoked_at: null, + is_live: true, + owner: { id: 'p1', name: 'Ada Lovelace', role: 'ADMIN', is_active: true }, + ...over + }; +} + +describe('lastActivityAt', () => { + it('prefers the last use', () => { + expect(lastActivityAt(token({ last_used_at: ago(3) }))).toBe(ago(3)); + }); + + it('falls back to when the token was issued', () => { + expect(lastActivityAt(token({ created_at: ago(5) }))).toBe(ago(5)); + }); + + it('is null when the row carries neither', () => { + expect(lastActivityAt({ last_used_at: null, created_at: null })).toBeNull(); + }); +}); + +describe('staleness', () => { + it('says nothing about a token issued today and not yet used', () => { + // The finding: null last_used_at is not "long ago". A token created a + // minute ago has to read as new on the row AND in the count. + expect(staleness(token(), NOW)).toBeNull(); + }); + + it('flags a never-used token once it is old enough, and dates it', () => { + const note = staleness(token({ created_at: ago(120) }), NOW); + expect(note).toBe('never used, issued 120 days ago'); + }); + + it('flags a token last used more than 90 days ago', () => { + expect(staleness(token({ last_used_at: ago(120) }), NOW)).toBe('unused for 120 days'); + }); + + it('says nothing at exactly 90 days, matching the backend > 90', () => { + expect(staleness(token({ last_used_at: ago(90) }), NOW)).toBeNull(); + expect(staleness(token({ last_used_at: ago(91) }), NOW)).toBe('unused for 91 days'); + }); + + it('says nothing about a token that is no longer live', () => { + // Revoked or expired is already not a credential. Calling it neglected too + // would bury the rows worth acting on. + expect(staleness(token({ is_live: false, created_at: ago(400) }), NOW)).toBeNull(); + }); +}); + +describe('tokenStatus', () => { + it('names revoked and expired apart, tones them the same', () => { + expect(tokenStatus(token({ revoked_at: ago(1), is_live: false }))).toEqual({ + label: 'Revoked', + tone: 'slate' + }); + expect(tokenStatus(token({ is_live: false }))).toEqual({ label: 'Expired', tone: 'slate' }); + }); + + it('a revoked token reads as revoked even if is_live is stale', () => { + expect(tokenStatus(token({ revoked_at: ago(1) })).label).toBe('Revoked'); + }); + + it('is Live otherwise', () => { + expect(tokenStatus(token())).toEqual({ label: 'Live', tone: 'moss' }); + }); +}); + +describe('scopeSummary', () => { + it('says an empty list is unrestricted, in the owner name', () => { + // Empty means unrestricted server-side, which is what every token issued + // before enforcement carries. Drawing those as limited would be a lie. + expect(scopeSummary(token())).toBe('Everything Ada can'); + }); + + it('falls back to a name-free phrasing when the owner block is missing', () => { + expect(scopeSummary(token({ owner: undefined }))).toBe('Everything its owner can'); + }); + + it('calls an all-read list read only', () => { + expect(scopeSummary(token({ scopes: ['*:read'] }))).toBe('Read only'); + expect(scopeSummary(token({ scopes: ['leads:read', 'cases:read'] }))).toBe('Read only'); + }); + + it('lists anything narrower verbatim', () => { + expect(scopeSummary(token({ scopes: ['leads:read', 'cases:write'] }))).toBe( + 'leads:read, cases:write' + ); + }); +}); + +describe('expiryFromChoice', () => { + it('turns each offered choice into a future instant', () => { + for (const choice of EXPIRY_CHOICES.filter((c) => c.days)) { + const iso = expiryFromChoice(choice.value, NOW); + expect(new Date(iso).getTime()).toBeGreaterThan(NOW.getTime()); + } + }); + + it('counts the days the label promises', () => { + const iso = expiryFromChoice('30', NOW); + expect(Math.round((new Date(iso).getTime() - NOW.getTime()) / 86400000)).toBe(30); + }); + + it('is null for never, and for anything it does not recognise', () => { + expect(expiryFromChoice('never', NOW)).toBeNull(); + expect(expiryFromChoice('tuesday', NOW)).toBeNull(); + expect(expiryFromChoice(undefined, NOW)).toBeNull(); + }); + + it('offers 90 days first, so the default is an expiry rather than never', () => { + expect(EXPIRY_CHOICES[0].value).toBe('90'); + }); +}); + +describe('scopesFromChoice', () => { + it('read means a read-only wildcard', () => { + expect(scopesFromChoice('read')).toEqual(['*:read']); + }); + + it('anything else means unrestricted, which is the empty list', () => { + expect(scopesFromChoice('full')).toEqual([]); + expect(scopesFromChoice(undefined)).toEqual([]); + }); +}); diff --git a/frontend/src/routes/(app)/settings/organization/+page.svelte b/frontend/src/routes/(app)/settings/organization/+page.svelte index f49e42ce..bc4bb466 100644 --- a/frontend/src/routes/(app)/settings/organization/+page.svelte +++ b/frontend/src/routes/(app)/settings/organization/+page.svelte @@ -207,11 +207,16 @@
Close child tickets with the parent - + - Sets how the prompt starts when you close a parent. It never closes a child on its - own. You still confirm. + Sets how the close prompt starts on the mobile app, which offers to close a parent's + open children with it. On the web there is no such prompt yet: closing a parent + leaves its children open.
diff --git a/frontend/src/routes/(app)/settings/organization/edit/+page.svelte b/frontend/src/routes/(app)/settings/organization/edit/+page.svelte index d2e4106c..5f54eadf 100644 --- a/frontend/src/routes/(app)/settings/organization/edit/+page.svelte +++ b/frontend/src/routes/(app)/settings/organization/edit/+page.svelte @@ -167,7 +167,11 @@ >
-
Two fields to check
+ +
+ {Object.keys(errors).length === 1 ? 'One field to check' : 'Two fields to check'} +
Nothing has been saved yet.
@@ -369,8 +373,9 @@

- Only sets how the prompt starts. Closing a parent never closes a child on its own, the - person still confirms. + Only sets how the prompt starts, and only on the mobile app, which is where closing a + parent offers to close its open children. The person still confirms. Closing a parent on + the web leaves its children open, with no prompt.

diff --git a/mobile/lib/config/api_config.dart b/mobile/lib/config/api_config.dart index 2b8a2af9..2bcd830a 100644 --- a/mobile/lib/config/api_config.dart +++ b/mobile/lib/config/api_config.dart @@ -127,6 +127,49 @@ class ApiConfig { /// use one vocabulary (see `common/org_time.py`). static String get timezones => '$apiBaseUrl/org/timezones/'; + /// The org's own settings. GET is open to any member (the company profile is + /// not a secret), PATCH is admin-only. Always acts on the caller's own org: + /// there is no id in the path, so there is nothing to scope. + /// + /// The org API key is not on this payload in any form. It authenticates as + /// the org's first admin and is rotated through `OrgApiKeyView` alone. + static String get orgSettings => '$apiBaseUrl/org/settings/'; + + /// Vertical packs available to apply. Needs authentication but no org + /// context, because its first caller is someone creating their first org. + static String get packs => '$apiBaseUrl/packs/'; + + /// Apply one pack. Admin-only, additive-only, and safe to repeat: a pack + /// already applied reports everything skipped rather than refusing. + static String packApply(String packId) => '$apiBaseUrl/packs/$packId/apply/'; + + /// Delete the demo records a pack created for this org, and nothing else. + /// Admin-only. A sample record the org has since attached real work to is + /// deliberately kept and reported in `retained_by_type`. + static String get packSampleData => '$apiBaseUrl/packs/sample-data/'; + + // ========================================================================== + // API TOKENS + // ========================================================================== + + /// **The admin's org-wide oversight list.** Every token in the org, so a + /// deactivated owner's row and a forgotten one can be found. ADMIN-only: a + /// member gets 403, which is why the screen gates on role. + /// + /// Only `token_prefix` is ever returned. The server keeps a SHA-256 hash and + /// no raw value exists to fetch. + static String get orgTokens => '$apiBaseUrl/org/tokens/'; + + /// Revoke any token in the org. Admin-only, org-scoped, idempotent. Separate + /// from the self-scoped path below so the "a user manages only their own" + /// guard there is never widened. + static String orgToken(String id) => '$apiBaseUrl/org/tokens/$id/'; + + /// Create a token **for yourself**. The server sets the owner from the + /// caller's own profile, so there is no way to mint one for someone else. + /// The raw value comes back once, in this response, and never again. + static String get profileTokens => '$apiBaseUrl/profile/tokens/'; + // ========================================================================== // DASHBOARD // ========================================================================== diff --git a/mobile/lib/data/models/access_token.dart b/mobile/lib/data/models/access_token.dart new file mode 100644 index 00000000..a9331828 --- /dev/null +++ b/mobile/lib/data/models/access_token.dart @@ -0,0 +1,289 @@ +/// Personal access tokens, as the org-wide oversight screen reads them. +/// +/// **A token value is shown once, in the response to the request that created +/// it, and never again.** The server keeps a SHA-256 hash plus a 13-character +/// prefix, so there is nothing to re-display even if someone asks. Nothing in +/// this file stores, caches or formats a raw value; the create call hands it +/// straight to the screen that shows it and it is gone with that screen. +/// +/// `frontend/src/routes/(app)/settings/api-tokens/oversight.js` carries the +/// same rules. +library; + +/// Who a token authenticates as. A token inherits its owner's role and org, +/// which is why the row has to name them. +class TokenOwner { + const TokenOwner({ + required this.id, + required this.name, + required this.role, + required this.isActive, + }); + + final String id; + final String name; + final String role; + + /// `resolve_valid_pat` refuses a token whose owner's profile is inactive, so + /// a token here is dormant rather than live. It would authenticate again if + /// the account were reactivated, which is why it is worth revoking anyway. + final bool isActive; + + bool get isAdmin => role == 'ADMIN'; + + String get roleLabel => isAdmin ? 'Admin' : 'Member'; + + factory TokenOwner.fromJson(Map json) => TokenOwner( + id: json['id']?.toString() ?? '', + name: json['name']?.toString() ?? 'Unknown', + role: json['role']?.toString() ?? 'USER', + isActive: json['is_active'] as bool? ?? true, + ); + + /// The first word of the display name, for "Everything Ada can". + String get firstName { + final parts = name.trim().split(RegExp(r'\s+')); + return parts.isEmpty ? '' : parts.first; + } +} + +class AccessToken { + const AccessToken({ + required this.id, + required this.name, + required this.tokenPrefix, + required this.owner, + this.scopes = const [], + this.expiresAt, + this.lastUsedAt, + this.createdAt, + this.revokedAt, + this.isLive = true, + }); + + final String id; + final String name; + + /// The first 13 characters, which is all the server keeps in the clear. The + /// row shows this and stops. + final String tokenPrefix; + + final TokenOwner owner; + + /// `:` strings. **An empty list means unrestricted**, which + /// is what every token issued before `common/scopes.py` carries, so it must + /// never be drawn as "no access". + final List scopes; + + final DateTime? expiresAt; + final DateTime? lastUsedAt; + final DateTime? createdAt; + final DateTime? revokedAt; + + /// Server-computed: not revoked and not past its expiry. + final bool isLive; + + factory AccessToken.fromJson(Map json) => AccessToken( + id: json['id']?.toString() ?? '', + name: json['name']?.toString() ?? '', + tokenPrefix: json['token_prefix']?.toString() ?? '', + owner: TokenOwner.fromJson( + (json['owner'] as Map?)?.cast() ?? const {}, + ), + scopes: + (json['scopes'] as List?)?.map((s) => s.toString()).toList() ?? + const [], + expiresAt: _date(json['expires_at']), + lastUsedAt: _date(json['last_used_at']), + createdAt: _date(json['created_at']), + revokedAt: _date(json['revoked_at']), + isLive: json['is_live'] as bool? ?? true, + ); + + /// Revoked and expired are different reasons for the same outcome, so they + /// read differently and tone the same. Neither is a live credential. + String get statusLabel { + if (revokedAt != null) return 'Revoked'; + if (!isLive) return 'Expired'; + return 'Live'; + } + + /// When this token last did anything, falling back to when it was issued. + /// + /// **`lastUsedAt` being null does not mean "long ago".** It is null both for + /// a token issued three years ago and for one issued a minute ago, and the + /// "Unused 90+ days" figure used to count on null alone, so creating a token + /// put the token you had just made into that count on the very next read. + /// `OrgAccessTokenListView` now measures the same fallback for its total, and + /// this is the row-level half: the card and the row have to be counting the + /// same rows. + DateTime? get lastActivityAt => lastUsedAt ?? createdAt; + + /// The warning line under a row, or null when there is nothing to say. + /// + /// Only for a live token. A revoked or expired one is already not a + /// credential, and calling it neglected as well would bury the rows worth + /// acting on. + String? stalenessNote(DateTime now) { + if (!isLive) return null; + final since = lastActivityAt; + if (since == null) return null; + final days = now.difference(since).inDays; + if (days <= 90) return null; + return lastUsedAt != null + ? 'unused for $days days' + : 'never used, issued $days days ago'; + } + + /// What this token may do, in the words the screen uses. + String get scopeSummary { + if (scopes.isEmpty) { + final first = owner.firstName; + return first.isEmpty + ? 'Everything its owner can' + : 'Everything $first can'; + } + if (scopes.every((s) => s.endsWith(':read'))) return 'Read only'; + return scopes.join(', '); + } + + /// True when this row is a live token on a deactivated account: the loose end + /// the screen offers to clear in one go. + bool get isOrphaned => isLive && !owner.isActive; +} + +/// The server's own totals, computed over the org's whole token set rather than +/// the rows on screen. +class TokenTotals { + const TokenTotals({ + this.count = 0, + this.live = 0, + this.orphaned = 0, + this.unused90d = 0, + }); + + final int count; + final int live; + final int orphaned; + + /// Live tokens whose last activity, or issue date when they have never been + /// used, is more than 90 days old. + final int unused90d; + + factory TokenTotals.fromJson(Map json) => TokenTotals( + count: _int(json['count']), + live: _int(json['live']), + orphaned: _int(json['orphaned']), + unused90d: _int(json['unused_90d']), + ); +} + +DateTime? _date(dynamic value) { + if (value == null) return null; + return DateTime.tryParse(value.toString())?.toLocal(); +} + +int _int(dynamic value) { + if (value is int) return value; + if (value is num) return value.round(); + if (value is String) return int.tryParse(value) ?? 0; + return 0; +} + +/// Live first, then most-recently-active first, which is the order an admin +/// scans in. The dormant and revoked rows sink to the bottom. +List sortedTokens(List tokens) { + final out = [...tokens]; + out.sort((a, b) { + if (a.isLive != b.isLive) return a.isLive ? -1 : 1; + final left = b.lastUsedAt?.millisecondsSinceEpoch ?? 0; + final right = a.lastUsedAt?.millisecondsSinceEpoch ?? 0; + return left.compareTo(right); + }); + return out; +} + +/// The ids "revoke them all" acts on, derived from the rows the server sent +/// rather than from anything the screen holds. +List orphanedTokenIds(List tokens) => + tokens.where((t) => t.isOrphaned).map((t) => t.id).toList(); + +/// One coarse expiry choice. +typedef ExpiryChoice = ({String value, String label, int? days}); + +/// The choices the form offers, in the order it offers them. +/// +/// Coarse on purpose: a date picker is more precision than a token expiry +/// needs, and "Never" is a named option rather than an empty field so the +/// riskiest choice is a deliberate one. 90 days is first, so the default is an +/// expiry rather than never. +const List expiryChoices = [ + (value: '90', label: 'In 90 days', days: 90), + (value: '30', label: 'In 30 days', days: 30), + (value: '365', label: 'In 1 year', days: 365), + (value: 'never', label: 'Never', days: null), +]; + +/// The two access choices. The backend grammar supports `leads:read`, but a +/// picker listing thirty resources is a worse question than "may it change +/// anything?". A caller wanting finer scopes creates the token through the API. +const List<({String value, String label})> accessChoices = [ + (value: 'read', label: 'Read only'), + (value: 'full', label: 'Everything the owner can'), +]; + +/// An ISO instant the API will accept, or null for "never". +/// +/// Anything unrecognised is "never" rather than an error, matching the +/// serializer, which treats an absent `expires_at` the same way. +String? expiryFromChoice(String? choice, DateTime now) { + final match = expiryChoices.where((c) => c.value == choice); + final days = match.isEmpty ? null : match.first.days; + if (days == null) return null; + return now.toUtc().add(Duration(days: days)).toIso8601String(); +} + +/// `common/scopes.py` treats an empty list as unrestricted, so "full" sends +/// nothing rather than trying to enumerate everything. +List scopesFromChoice(String? choice) => + choice == 'read' ? const ['*:read'] : const []; + +/// The body for `POST /api/profile/tokens/`. +/// +/// **Self-scoped: the server sets the owner from the caller's own profile.** +/// There is no "create on behalf of", by design, so there is no owner field +/// here to get wrong. +Map tokenCreatePayload({ + required String name, + required String expiryChoice, + required String accessChoice, + required DateTime now, +}) => { + 'name': name.trim(), + 'scopes': scopesFromChoice(accessChoice), + 'expires_at': expiryFromChoice(expiryChoice, now), +}; + +/// What a create has to fix before it is worth sending, or null. +/// +/// `PersonalAccessTokenCreateSerializer.validate_name` is the authority; this +/// exists so an empty field answers immediately rather than after a round trip. +String? tokenNameProblem(String name) { + final trimmed = name.trim(); + if (trimmed.isEmpty) return 'Give the token a name.'; + if (trimmed.length > 255) { + return 'That name is too long (255 characters max).'; + } + return null; +} + +/// What revoking does, said before it is done. +const String tokenRevokeExplanation = + 'The token stops working on the next request that uses it. Anything built ' + 'on it, a script or an integration, fails until it is given a new one. ' + 'This cannot be undone: a revoked token is never reissued.'; + +/// The line the screen shows beside a one-time reveal. +const String tokenRevealWarning = + 'This is the only time the full token is shown. Store it somewhere safe. ' + 'The server keeps only a hash, so it cannot be retrieved again.'; diff --git a/mobile/lib/data/models/org_settings.dart b/mobile/lib/data/models/org_settings.dart new file mode 100644 index 00000000..1b19ae69 --- /dev/null +++ b/mobile/lib/data/models/org_settings.dart @@ -0,0 +1,393 @@ +/// The organization's own settings: the company profile printed on documents, +/// the locale defaults, and the two org-wide case-handling switches. +/// +/// `GET/PATCH /api/org/settings/` always acts on `request.profile.org`. **There +/// is no org id in the URL**, so a caller can only ever read or edit their own +/// org. That is the tenant barrier, and there is nothing here to scope by hand. +/// +/// The org API key is absent from this payload entirely. It authenticates as +/// the org's first admin, so it is excluded from `OrgSettingsSerializer` and +/// rotated only through `OrgApiKeyView`. `is_active`, the org kill switch, is +/// likewise not editable here. +/// +/// The read is open to any member; the PATCH is admin-only and the backend is +/// what refuses. `frontend/src/lib/server/v2/organization.js` carries the same +/// allow-list. +library; + +/// Text and select fields the edit form may submit. +/// +/// An allow-list, so a field the form never offered cannot be smuggled into a +/// body, and so `api_key`, `is_active`, `id`, `vertical` and `terminology` are +/// never sent from a client even by accident. +const List editableOrgFields = [ + 'name', + 'company_name', + 'address_line', + 'city', + 'state', + 'postcode', + 'country', + 'phone', + 'email', + 'website', + 'tax_id', + 'default_currency', + 'default_country', + 'timezone', +]; + +/// The two org-wide behaviour switches. Kept apart from the text fields because +/// they are booleans and always travel explicitly: "off" is a real value, not +/// the "absent, so leave alone" convention a blank text field uses. +const List orgBooleanFields = [ + 'csat_enabled', + 'auto_close_children_on_parent_close', +]; + +class OrgSettings { + const OrgSettings({ + this.id = '', + this.name = '', + this.companyName = '', + this.addressLine = '', + this.city = '', + this.state = '', + this.postcode = '', + this.country = '', + this.phone = '', + this.email = '', + this.website = '', + this.taxId = '', + this.defaultCurrency = 'USD', + this.defaultCountry = '', + this.currencySymbol = '\$', + this.timezone = 'UTC', + this.csatEnabled = true, + this.autoCloseChildren = false, + this.vertical = '', + this.logoUrl, + this.memberCount = 0, + this.createdAt, + }); + + final String id; + + /// What the org is called across the app. `company_name` is the legal name + /// printed on a document; these are deliberately two fields. + final String name; + final String companyName; + + final String addressLine; + final String city; + final String state; + final String postcode; + final String country; + final String phone; + final String email; + final String website; + final String taxId; + + final String defaultCurrency; + final String defaultCountry; + final String currencySymbol; + final String timezone; + + /// Org-level kill switch. False short-circuits the post-close signal before + /// any survey email is sent, org-wide, with no per-team exception. + final bool csatEnabled; + + /// The DEFAULT state of the cascade checkbox when a parent ticket is closed. + /// See [cascadeDefaultExplanation]: it decides how the prompt starts, not + /// what happens without one. + final bool autoCloseChildren; + + /// Which vertical pack was applied. Written by the pack applier server-side + /// and read-only here. + final String vertical; + + final String? logoUrl; + final int memberCount; + final DateTime? createdAt; + + factory OrgSettings.fromJson(Map json) => OrgSettings( + id: json['id']?.toString() ?? '', + name: _text(json['name']), + companyName: _text(json['company_name']), + addressLine: _text(json['address_line']), + city: _text(json['city']), + state: _text(json['state']), + postcode: _text(json['postcode']), + country: _text(json['country']), + phone: _text(json['phone']), + email: _text(json['email']), + website: _text(json['website']), + taxId: _text(json['tax_id']), + defaultCurrency: json['default_currency']?.toString() ?? 'USD', + defaultCountry: _text(json['default_country']), + currencySymbol: json['currency_symbol']?.toString() ?? '\$', + timezone: json['timezone']?.toString() ?? 'UTC', + csatEnabled: json['csat_enabled'] as bool? ?? true, + autoCloseChildren: + json['auto_close_children_on_parent_close'] as bool? ?? false, + vertical: _text(json['vertical']), + logoUrl: json['logo_url']?.toString(), + memberCount: _intOr(json['member_count'], 0), + createdAt: DateTime.tryParse( + json['created_at']?.toString() ?? '', + )?.toLocal(), + ); + + /// The postal address on one line, in the order a label prints it. + String get addressSummary => [ + addressLine, + city, + state, + postcode, + country, + ].where((part) => part.trim().isNotEmpty).join(', '); + + /// The timezone with its underscores opened out, for reading. + String get timezoneLabel => timezone.replaceAll('_', ' '); +} + +String _text(dynamic value) => value?.toString() ?? ''; + +int _intOr(dynamic value, int fallback) { + if (value is int) return value; + if (value is num) return value.round(); + if (value is String) return int.tryParse(value) ?? fallback; + return fallback; +} + +/// One IANA zone as `/api/org/timezones/` sends it. +/// +/// Served rather than read from the device because the two vocabularies +/// disagree: a phone reports `Asia/Calcutta` where the server also knows +/// `Asia/Kolkata`, and a picker that cannot find the stored value would offer +/// to move the org somewhere it never chose. +class TimezoneOption { + const TimezoneOption({required this.name, required this.offsetMinutes}); + + final String name; + final int offsetMinutes; + + factory TimezoneOption.fromJson(Map json) => TimezoneOption( + name: json['name']?.toString() ?? 'UTC', + offsetMinutes: _intOr(json['offset_minutes'], 0), + ); + + /// "Asia/Kolkata (UTC+05:30)". The offset is what makes a 490-entry list + /// readable. + String get label => + '${name.replaceAll('_', ' ')} (${_offset(offsetMinutes)})'; +} + +String _offset(int minutes) { + if (minutes == 0) return 'UTC'; + final sign = minutes < 0 ? '-' : '+'; + final abs = minutes.abs(); + final hh = (abs ~/ 60).toString().padLeft(2, '0'); + final mm = (abs % 60).toString().padLeft(2, '0'); + return 'UTC$sign$hh:$mm'; +} + +/// The body for `PATCH /api/org/settings/`. +/// +/// Every field goes every time, because this form edits them all at once and a +/// partial body would make "cleared" and "not sent" the same thing. The two +/// booleans go as real booleans for the same reason. +/// +/// Nothing read-only is included: no `id`, no `api_key` (which the payload has +/// never carried), no `is_active`, no `vertical`, no `terminology`, +/// no `member_count`. +Map orgSettingsPayload({ + required String name, + required String companyName, + required String addressLine, + required String city, + required String state, + required String postcode, + required String country, + required String phone, + required String email, + required String website, + required String taxId, + required String defaultCurrency, + required String defaultCountry, + required String timezone, + required bool csatEnabled, + required bool autoCloseChildren, +}) => { + 'name': name.trim(), + 'company_name': companyName.trim(), + 'address_line': addressLine.trim(), + 'city': city.trim(), + 'state': state.trim(), + 'postcode': postcode.trim(), + 'country': country.trim(), + 'phone': phone.trim(), + 'email': email.trim(), + 'website': website.trim(), + 'tax_id': taxId.trim(), + 'default_currency': defaultCurrency.trim(), + 'default_country': defaultCountry.trim(), + 'timezone': timezone.trim(), + 'csat_enabled': csatEnabled, + 'auto_close_children_on_parent_close': autoCloseChildren, +}; + +/// What an org save has to fix before it is worth sending, or null. +/// +/// The serializer is the rule; these two exist so a typo answers immediately. +/// `email` is an `EmailField` and `website` a `URLField`, both of which reject +/// on the server with a message that names the field. +String? orgSettingsProblem({required String email, required String website}) { + final mail = email.trim(); + if (mail.isNotEmpty && + !RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(mail)) { + return 'That does not look like an email address.'; + } + final site = website.trim(); + if (site.isNotEmpty && !RegExp(r'^https?://.+\..+').hasMatch(site)) { + return 'Include the full website address, starting with http:// or https://.'; + } + return null; +} + +/// One entry in a picker. +typedef PickerOption = ({String value, String label}); + +/// The countries the org form offers, mirroring the web's list. +/// +/// Every value is a real code in the backend `COUNTRIES` set, so the picker can +/// never offer one the serializer rejects. Deliberately short: this is a +/// default for new addresses, not a data-entry field. +const List orgCountryOptions = [ + (value: 'US', label: 'United States'), + (value: 'GB', label: 'United Kingdom'), + (value: 'CA', label: 'Canada'), + (value: 'AU', label: 'Australia'), + (value: 'DE', label: 'Germany'), + (value: 'FR', label: 'France'), + (value: 'IN', label: 'India'), + (value: 'JP', label: 'Japan'), + (value: 'SG', label: 'Singapore'), + (value: 'AE', label: 'United Arab Emirates'), + (value: 'BR', label: 'Brazil'), + (value: 'MX', label: 'Mexico'), + (value: 'CH', label: 'Switzerland'), + (value: 'NL', label: 'Netherlands'), + (value: 'ES', label: 'Spain'), + (value: 'IT', label: 'Italy'), +]; + +/// [options], guaranteed to contain [current]. +/// +/// **A picker that cannot display the stored value is a data-loss bug.** A +/// Flutter dropdown whose value matches no item throws outright, and the usual +/// repair, quietly falling back to the first entry, saves a value the org never +/// chose. The stored value is a code the server accepted, so the honest move is +/// to keep offering it. Used for country and currency, where the offered list is +/// a short convenience subset of what the backend accepts. +List withCurrent(List options, String current) { + final value = current.trim(); + if (value.isEmpty) return options; + if (options.any((option) => option.value == value)) return options; + return [...options, (value: value, label: value)]; +} + +/// What the CSAT switch really does, in the words the screen uses. +String csatExplanation(bool enabled) => enabled + ? 'A survey goes out after a ticket closes. Turning this off stops every ' + 'survey org-wide, with no per-team exception.' + : 'No surveys are sent, anywhere in this organization. Nothing on a ticket ' + 'says so, and there is no per-team exception.'; + +/// What the cascade switch really does, which is narrower than it looks. +/// +/// **It sets how the close prompt starts, and only this app has that prompt.** +/// `CaseCloseWithChildrenView` reads +/// `Org.auto_close_children_on_parent_close` only when the caller omits +/// `cascade` entirely; both clients send it explicitly, so the setting reaches +/// a person through the checkbox and nowhere else. The web app has no such +/// prompt at all: closing a parent there leaves its children open. Saying "it +/// controls whether children close" would describe neither client. +String cascadeDefaultExplanation(bool enabled) => enabled + ? 'Closing a parent ticket here starts with "also close linked tickets" ' + 'ticked. You still confirm, and can untick it.' + : 'Closing a parent ticket here starts with "also close linked tickets" ' + 'unticked. You still confirm, and can tick it.'; + +/// The footnote both organization screens carry about the cascade switch. +const String cascadeWebGapNote = + 'The web app has no close prompt yet, so closing a parent there leaves its ' + 'children open whatever this is set to.'; + +/// A vertical pack as `/api/packs/` lists it. +class VerticalPack { + const VerticalPack({ + required this.id, + required this.name, + this.description = '', + this.version = '', + }); + + final String id; + final String name; + final String description; + final String version; + + factory VerticalPack.fromJson(Map json) => VerticalPack( + id: json['id']?.toString() ?? '', + name: json['name']?.toString() ?? '', + description: json['description']?.toString() ?? '', + version: json['version']?.toString() ?? '', + ); +} + +/// What an apply actually did. +/// +/// A pack applied to an org that already has some of this configured is the +/// normal case, not a partial failure, so both halves are reported. +class PackApplyReport { + const PackApplyReport({this.created = 0, this.skipped = 0}); + + final int created; + final int skipped; + + factory PackApplyReport.fromJson(Map json) { + final report = (json['report'] as Map?)?.cast() ?? json; + return PackApplyReport( + created: (report['created'] as List?)?.length ?? 0, + skipped: (report['skipped'] as List?)?.length ?? 0, + ); + } + + String get summary { + if (created == 0 && skipped == 0) { + return 'Nothing to add. This org already has all of it.'; + } + if (skipped == 0) return 'Created $created item${created == 1 ? '' : 's'}.'; + if (created == 0) { + return 'Already had everything from this pack, skipped $skipped ' + 'item${skipped == 1 ? '' : 's'}.'; + } + return 'Created $created item${created == 1 ? '' : 's'}, skipped $skipped ' + 'you already had.'; + } +} + +/// What a sample-data clear actually deleted, and what it kept. +/// +/// Retained rows are not a failure: they are demo records the org has since +/// attached real work to, which the applier deliberately keeps because deleting +/// one would take that work with it. +String sampleDataResult({required int deleted, required int retained}) { + final head = deleted == 0 + ? 'No sample data to clear.' + : 'Deleted $deleted sample record${deleted == 1 ? '' : 's'}.'; + if (retained == 0) return head; + return '$head Kept $retained record${retained == 1 ? '' : 's'} you have ' + 'since attached real work to.'; +} diff --git a/mobile/lib/data/models/ticket.dart b/mobile/lib/data/models/ticket.dart index e534ec5d..920db1f7 100644 --- a/mobile/lib/data/models/ticket.dart +++ b/mobile/lib/data/models/ticket.dart @@ -401,3 +401,25 @@ class TicketParentSummary { ); } } + +/// What to say after closing a parent ticket. +/// +/// **Counted from the response, not from `child_count`.** The server closes the +/// open *descendants*, the whole subtree, and returns their ids in +/// `cascaded_case_ids`. `child_count` is neither of those: it counts direct +/// children whether they were already closed or not. Reporting it claimed +/// "and 3 linked" when three children were already closed and nothing happened, +/// and understated a deep tree where more than the direct children closed. +String cascadeCloseMessage({required bool cascade, required int cascaded}) { + if (!cascade) return 'Ticket closed'; + if (cascaded == 0) { + return 'Ticket closed. Nothing linked was open, so nothing else changed.'; + } + return 'Ticket closed, and $cascaded linked ' + '${cascaded == 1 ? 'ticket' : 'tickets'} with it'; +} + +/// The ids the close endpoint reports as cascaded, counted defensively: a body +/// without the key means nothing was cascaded, not an unknown number. +int cascadedCount(Map? body) => + (body?['cascaded_case_ids'] as List?)?.length ?? 0; diff --git a/mobile/lib/providers/settings_provider.dart b/mobile/lib/providers/settings_provider.dart index ab8db017..2aee6f16 100644 --- a/mobile/lib/providers/settings_provider.dart +++ b/mobile/lib/providers/settings_provider.dart @@ -2,10 +2,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../config/api_config.dart'; import '../data/api_envelope.dart'; +import '../data/models/access_token.dart'; import '../data/models/business_calendar.dart'; import '../data/models/custom_field_definition.dart'; import '../data/models/escalation_policy.dart'; import '../data/models/macro.dart'; +import '../data/models/org_settings.dart'; import '../data/models/reopen_policy.dart'; import '../data/models/routing_rule.dart'; import '../data/models/tag.dart'; @@ -13,10 +15,11 @@ import '../services/api_service.dart'; /// The org settings cluster. /// -/// Custom fields, saved replies, tags, routing, escalation, business hours and -/// reopen rules are the pages of the cluster that have reached the phone. The -/// rest (inbound email, ticket approvals) share the same shape, so they will -/// land beside these rather than in files of their own. +/// Custom fields, saved replies, tags, routing, escalation, business hours, +/// reopen rules, API tokens and the organization itself are the pages of the +/// cluster that have reached the phone. The rest (inbound email, ticket +/// approvals) share the same shape, so they will land beside these rather than +/// in files of their own. /// /// Every write here is admin-only server-side. The screens hide the controls /// from everyone else, which is UX: `CustomFieldDefinitionListCreateView.post` @@ -828,3 +831,265 @@ final reopenPolicyProvider = AsyncNotifierProvider( ReopenPolicyNotifier.new, ); + +// --------------------------------------------------------------------------- +// API tokens, the admin's org-wide oversight +// --------------------------------------------------------------------------- + +/// Every token in the org, with the totals the screen ranks by. +/// +/// **`OrgAccessTokenListView` is admin-only**, like the reopen policy and +/// unlike the rest of this cluster, so the screen gates the read on role. +/// +/// A raw token value never lives in this state. [createToken] returns it to its +/// one caller and the screen shows it once; nothing here keeps a copy, and +/// nothing anywhere logs one. +class AccessTokensState { + const AccessTokensState({ + this.tokens = const [], + this.totals = const TokenTotals(), + }); + + final List tokens; + final TokenTotals totals; + + /// The ids "revoke them all" acts on, re-derived from the rows the server + /// sent rather than from anything a screen holds. + List get orphanedIds => orphanedTokenIds(tokens); +} + +class AccessTokensNotifier extends AsyncNotifier { + final ApiService _api = ApiService(); + + @override + Future build() => _fetch(); + + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(_fetch); + } + + Future _fetch() async { + final response = await _api.get(ApiConfig.orgTokens); + if (!response.success || response.data == null) { + throw Exception(response.message ?? 'Failed to load tokens'); + } + final rows = listFromEnvelope(response.data!, [ + 'tokens', + ]).map(AccessToken.fromJson).toList(); + return AccessTokensState( + tokens: sortedTokens(rows), + totals: TokenTotals.fromJson( + (response.data!['totals'] as Map?)?.cast() ?? const {}, + ), + ); + } + + /// Create a token for the signed-in user and return the raw value, once. + /// + /// Posts to the SELF-scoped endpoint: the owner is the caller's own profile, + /// set server-side. `value` is the only time the raw token exists on this + /// device; the caller shows it and drops it. + Future<({String? error, String? value, String? name})> createToken({ + required String name, + required String expiryChoice, + required String accessChoice, + }) async { + final response = await _api.post( + ApiConfig.profileTokens, + tokenCreatePayload( + name: name, + expiryChoice: expiryChoice, + accessChoice: accessChoice, + now: DateTime.now(), + ), + ); + if (!response.success) { + return (error: _message(response), value: null, name: null); + } + final raw = response.data?['token']?.toString(); + await refresh(); + if (raw == null || raw.isEmpty) { + // A 2xx with no value should not happen, and silently showing nothing + // would look like the token was never created. It was. + return ( + error: + 'The token was created but its value did not come back. ' + 'Revoke it and create another.', + value: null, + name: null, + ); + } + return ( + error: null, + value: raw, + name: response.data?['name']?.toString() ?? name.trim(), + ); + } + + /// Revoke one token: admin-scoped, so an admin can retire a colleague's. + /// Idempotent server-side. + Future revokeToken(String id) async { + final response = await _api.delete(ApiConfig.orgToken(id)); + if (!response.success) return _message(response); + await refresh(); + return null; + } + + /// Revoke every live token on a deactivated owner. + /// + /// The ids come from the loaded rows, not from the screen, so this can only + /// ever act on the orphaned set the server itself reported. One failure does + /// not strand the rest; the count returned is what actually went through. + Future<({String? error, int revoked})> revokeOrphaned() async { + final ids = state.value?.orphanedIds ?? const []; + if (ids.isEmpty) return (error: null, revoked: 0); + + var done = 0; + String? failure; + for (final id in ids) { + final response = await _api.delete(ApiConfig.orgToken(id)); + if (response.success) { + done += 1; + } else { + failure ??= _message(response); + } + } + await refresh(); + return (error: done == 0 ? failure : null, revoked: done); + } +} + +final accessTokensProvider = + AsyncNotifierProvider( + AccessTokensNotifier.new, + ); + +// --------------------------------------------------------------------------- +// Organization settings, and the vertical packs that seed one +// --------------------------------------------------------------------------- + +/// The org's own settings. +/// +/// The read is open to any member; every write is admin-only server-side. +/// +/// Kept apart from [orgPacksProvider] rather than fetched together, because the +/// ticket screen reads this to seed its cascade checkbox and has no use for a +/// pack list. One question, one request. +class OrgSettingsNotifier extends AsyncNotifier { + final ApiService _api = ApiService(); + + @override + Future build() => _fetch(); + + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(_fetch); + } + + Future _fetch() async { + final response = await _api.get(ApiConfig.orgSettings); + if (!response.success || response.data == null) { + throw Exception( + response.message ?? 'Failed to load organization settings', + ); + } + return OrgSettings.fromJson(response.data!); + } + + /// Save the org. Admin-only server-side; a member's PATCH is a 403 whatever + /// this app shows. + Future save(Map payload) async { + final response = await _api.patch(ApiConfig.orgSettings, payload); + if (!response.success) return _orgMessage(response); + await refresh(); + return null; + } + + /// Apply a pack. Additive-only and safe to repeat, so there is deliberately + /// no guard against re-applying the one already applied. + /// + /// The refresh matters: applying writes `org.vertical`, which is what the + /// screen reads to mark a pack as applied. + Future<({String? error, PackApplyReport? report})> applyPack( + String packId, + ) async { + final response = await _api.post(ApiConfig.packApply(packId), const {}); + if (!response.success || response.data == null) { + return (error: _orgMessage(response), report: null); + } + final report = PackApplyReport.fromJson(response.data!); + await refresh(); + return (error: null, report: report); + } + + /// Delete the demo records a pack created for this org. + /// + /// `retained` counts sample records kept because real work has since been + /// attached to them. That is a normal outcome, not a partial failure, and + /// reporting only `deleted` would show a smaller number than expected with + /// no explanation. + Future<({String? error, int deleted, int retained})> clearSampleData() async { + final response = await _api.delete(ApiConfig.packSampleData); + if (!response.success || response.data == null) { + return (error: _orgMessage(response), deleted: 0, retained: 0); + } + final retainedByType = + (response.data!['retained_by_type'] as Map?)?.values ?? const []; + var retained = 0; + for (final value in retainedByType) { + if (value is num) retained += value.round(); + } + await refresh(); + return ( + error: null, + deleted: (response.data!['deleted'] as num?)?.round() ?? 0, + retained: retained, + ); + } +} + +/// These endpoints refuse with a bare `{"error": "..."}` (the pack views and +/// `OrgSettingsView` both do) rather than the `errors` map `_message` reads, so +/// the reason reaches the user instead of a generic failure. +String _orgMessage(dynamic response) { + final error = response.data?['error']; + if (error is String && error.trim().isNotEmpty) return error; + return _message(response); +} + +final orgSettingsProvider = + AsyncNotifierProvider( + OrgSettingsNotifier.new, + ); + +/// The vertical packs available to apply. +/// +/// Its own provider, so the organization screen is the only thing that pays for +/// it. A failure yields an empty list rather than propagating: a pack list that +/// will not load must not be the reason the org details do not render, which is +/// the fallback the web load already established for the identical call. +final orgPacksProvider = FutureProvider>((ref) async { + final response = await ApiService().get(ApiConfig.packs); + if (!response.success || response.data == null) return const []; + return listFromEnvelope(response.data!, [ + 'packs', + ]).map(VerticalPack.fromJson).toList(); +}); + +/// The IANA zones the org form's timezone picker may offer. +/// +/// Fetched once per session. A failure falls back to UTC alone rather than an +/// empty picker, because a picker with no entries cannot save at all. +final orgTimezonesProvider = FutureProvider>((ref) async { + final response = await ApiService().get(ApiConfig.timezones); + if (!response.success || response.data == null) { + return const [TimezoneOption(name: 'UTC', offsetMinutes: 0)]; + } + final zones = listFromEnvelope(response.data!, [ + 'timezones', + ]).map(TimezoneOption.fromJson).toList(); + return zones.isEmpty + ? const [TimezoneOption(name: 'UTC', offsetMinutes: 0)] + : zones; +}); diff --git a/mobile/lib/routes/app_router.dart b/mobile/lib/routes/app_router.dart index a30ffb21..c782a5a5 100644 --- a/mobile/lib/routes/app_router.dart +++ b/mobile/lib/routes/app_router.dart @@ -39,8 +39,11 @@ import '../screens/settings/custom_fields_screen.dart'; import '../screens/settings/macros_screen.dart'; import '../screens/settings/more_screen.dart'; import '../screens/settings/profile_screen.dart'; +import '../screens/settings/api_tokens_screen.dart'; import '../screens/settings/business_hours_screen.dart'; import '../screens/settings/escalation_screen.dart'; +import '../screens/settings/organization_edit_screen.dart'; +import '../screens/settings/organization_screen.dart'; import '../screens/settings/reopen_screen.dart'; import '../screens/settings/routing_screen.dart'; import '../screens/settings/settings_hub_screen.dart'; @@ -118,6 +121,10 @@ class AppRoutes { static const String settingsEscalation = '/more/settings/escalation'; static const String settingsBusinessHours = '/more/settings/business-hours'; static const String settingsReopen = '/more/settings/reopen'; + static const String settingsApiTokens = '/more/settings/api-tokens'; + static const String settingsOrganization = '/more/settings/organization'; + static const String settingsOrganizationEdit = + '/more/settings/organization/edit'; /// Your own week of logged time. Not under `/more`: it is a workspace /// destination reached from the dashboard as often as from the menu, and a @@ -472,6 +479,26 @@ final appRouterProvider = Provider((ref) { parentNavigatorKey: _rootNavigatorKey, builder: (context, state) => const ReopenScreen(), ), + GoRoute( + path: AppRoutes.settingsApiTokens, + name: 'settingsApiTokens', + parentNavigatorKey: _rootNavigatorKey, + builder: (context, state) => const ApiTokensScreen(), + ), + // The edit route is declared before the read one so `/organization/edit` + // is never captured as a sub-path of `/organization`. + GoRoute( + path: AppRoutes.settingsOrganizationEdit, + name: 'settingsOrganizationEdit', + parentNavigatorKey: _rootNavigatorKey, + builder: (context, state) => const OrganizationEditScreen(), + ), + GoRoute( + path: AppRoutes.settingsOrganization, + name: 'settingsOrganization', + parentNavigatorKey: _rootNavigatorKey, + builder: (context, state) => const OrganizationScreen(), + ), GoRoute( path: AppRoutes.orgCreate, name: 'orgCreate', diff --git a/mobile/lib/screens/settings/api_token_form_sheet.dart b/mobile/lib/screens/settings/api_token_form_sheet.dart new file mode 100644 index 00000000..a7d659fd --- /dev/null +++ b/mobile/lib/screens/settings/api_token_form_sheet.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; + +import '../../core/theme/theme.dart'; +import '../../data/models/access_token.dart'; + +/// What the create form collected. The payload itself is built in the provider +/// from `tokenCreatePayload`, so the sheet never decides the wire format. +typedef TokenDraft = ({String name, String expiryChoice, String accessChoice}); + +/// Create a personal access token. +/// +/// Returns the draft, or null if dismissed. **Self-scoped underneath**: the +/// server sets the owner from the caller's own profile, so there is no owner +/// field here and no way to mint a token for someone else. +Future showApiTokenFormSheet(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => const _ApiTokenFormSheet(), + ); +} + +class _ApiTokenFormSheet extends StatefulWidget { + const _ApiTokenFormSheet(); + + @override + State<_ApiTokenFormSheet> createState() => _ApiTokenFormSheetState(); +} + +class _ApiTokenFormSheetState extends State<_ApiTokenFormSheet> { + final TextEditingController _name = TextEditingController(); + + /// 90 days, the first entry, so the default is an expiry rather than never. + String _expiry = expiryChoices.first.value; + String _access = accessChoices.first.value; + String? _error; + + @override + void dispose() { + _name.dispose(); + super.dispose(); + } + + void _submit() { + final problem = tokenNameProblem(_name.text); + if (problem != null) { + setState(() => _error = problem); + return; + } + Navigator.of(context).pop(( + name: _name.text.trim(), + expiryChoice: _expiry, + accessChoice: _access, + )); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'New token', + style: AppTypography.h3.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 4), + Text( + 'The token is created for you and authenticates as you. The ' + 'value is shown once, on the next screen.', + style: AppTypography.caption.copyWith( + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 14), + TextField( + controller: _name, + autofocus: true, + maxLength: 255, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration( + labelText: 'What is this token for?', + hintText: 'e.g. Nightly export job', + helperText: 'Names what breaks if you revoke it later', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 4), + DropdownButtonFormField( + initialValue: _access, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Access', + helperText: 'Finer scopes are available through the API', + border: OutlineInputBorder(), + ), + items: [ + for (final choice in accessChoices) + DropdownMenuItem( + value: choice.value, + child: Text(choice.label), + ), + ], + onChanged: (v) => setState(() => _access = v ?? _access), + ), + const SizedBox(height: 14), + DropdownButtonFormField( + initialValue: _expiry, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Expires', + border: OutlineInputBorder(), + ), + items: [ + for (final choice in expiryChoices) + DropdownMenuItem( + value: choice.value, + child: Text(choice.label), + ), + ], + onChanged: (v) => setState(() => _expiry = v ?? _expiry), + ), + if (_expiry == 'never') ...[ + const SizedBox(height: 8), + Text( + 'A token that never expires has to be revoked by hand to ever ' + 'stop working.', + style: AppTypography.caption.copyWith( + color: AppColors.warning600, + ), + ), + ], + if (_error != null) ...[ + const SizedBox(height: 8), + Text( + _error!, + style: AppTypography.caption.copyWith( + color: AppColors.danger600, + ), + ), + ], + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: SizedBox( + height: 48, + child: OutlinedButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: SizedBox( + height: 48, + child: FilledButton( + onPressed: _submit, + child: const Text('Create token'), + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/screens/settings/api_tokens_screen.dart b/mobile/lib/screens/settings/api_tokens_screen.dart new file mode 100644 index 00000000..49e65f84 --- /dev/null +++ b/mobile/lib/screens/settings/api_tokens_screen.dart @@ -0,0 +1,631 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../core/theme/theme.dart'; +import '../../data/models/access_token.dart'; +import '../../providers/auth_provider.dart'; +import '../../providers/settings_provider.dart'; +import 'api_token_form_sheet.dart'; + +/// Personal access tokens: the org-wide oversight view. +/// +/// **THE ONE RULE THIS SCREEN EXISTS TO KEEP.** A token value is shown once, in +/// the response to the request that created it, and never again. The server +/// keeps a SHA-256 hash plus a 13-character prefix, so there is nothing to +/// re-display even if someone asks. Every row here shows the prefix and stops; +/// the reveal below is the only place a full value appears, and it is gone with +/// the dialog. +/// +/// **Admin-only to read.** `/api/org/tokens/` lists every token across the org, +/// so it answers a member 403. The screen gates on role rather than issuing a +/// request it knows will fail. Creating is self-scoped underneath +/// (`POST /api/profile/tokens/` sets the owner from the caller's own profile), +/// so an admin here is always creating a token for themselves. +/// +/// **"Owner deactivated" is a dormant row, not a live one.** Deactivating an +/// account sets `profile.is_active` false, and that is exactly the flag the +/// token authenticator checks, so the token is already refused at login. It is +/// worth revoking anyway: it would come back if the account were reactivated. +class ApiTokensScreen extends ConsumerWidget { + const ApiTokensScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isAdmin = ref.watch(isOrgAdminProvider); + + return Scaffold( + backgroundColor: AppColors.surfaceDim, + appBar: AppBar( + title: const Text('API tokens'), + backgroundColor: AppColors.surface, + elevation: 0, + scrolledUnderElevation: 1, + ), + body: isAdmin ? const _AdminBody() : const _MemberNotice(), + floatingActionButton: isAdmin + ? FloatingActionButton.extended( + onPressed: () => _create(context, ref), + icon: const Icon(LucideIcons.plus), + label: const Text('New token'), + ) + : null, + ); + } + + Future _create(BuildContext context, WidgetRef ref) async { + final draft = await showApiTokenFormSheet(context); + if (draft == null || !context.mounted) return; + + final result = await ref + .read(accessTokensProvider.notifier) + .createToken( + name: draft.name, + expiryChoice: draft.expiryChoice, + accessChoice: draft.accessChoice, + ); + if (!context.mounted) return; + + if (result.error != null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(result.error!))); + return; + } + await _reveal( + context, + name: result.name ?? draft.name, + value: result.value!, + ); + } + + /// The one and only time the value is shown. Nothing keeps a copy after this + /// dialog closes, and nothing logs it on the way here. + Future _reveal( + BuildContext context, { + required String name, + required String value, + }) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AlertDialog( + title: Text('"$name" created, copy it now'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + tokenRevealWarning, + style: AppTypography.caption.copyWith( + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 14), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceDim, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: SelectableText( + value, + style: AppTypography.caption.copyWith( + fontFamily: 'monospace', + color: AppColors.textPrimary, + ), + ), + ), + ], + ), + ), + actions: [ + TextButton.icon( + onPressed: () async { + await Clipboard.setData(ClipboardData(text: value)); + if (!ctx.mounted) return; + ScaffoldMessenger.of( + ctx, + ).showSnackBar(const SnackBar(content: Text('Token copied'))); + }, + icon: const Icon(LucideIcons.copy, size: 16), + label: const Text('Copy'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Done'), + ), + ], + ), + ); + } +} + +/// The list itself is admin-gated server-side, so a member never issues it. +class _MemberNotice extends StatelessWidget { + const _MemberNotice(); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.lock, size: 40, color: AppColors.textTertiary), + const SizedBox(height: 16), + Text( + 'Administrators only', + style: AppTypography.h3, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Reviewing every token in the organization is limited to ' + 'administrators, because a token authenticates as its owner. Ask ' + 'an admin if one needs issuing or revoking.', + style: AppTypography.body.copyWith( + color: AppColors.textSecondary, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} + +class _AdminBody extends ConsumerWidget { + const _AdminBody(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(accessTokensProvider); + return async.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, _) => _ErrorState( + onRetry: () => ref.read(accessTokensProvider.notifier).refresh(), + ), + data: (state) => RefreshIndicator( + onRefresh: () => ref.read(accessTokensProvider.notifier).refresh(), + child: ListView( + padding: const EdgeInsets.only(bottom: 96), + children: [ + _Totals(totals: state.totals), + if (state.totals.orphaned > 0) + _OrphanBanner(count: state.totals.orphaned), + const _SectionHeader('Tokens'), + if (state.tokens.isEmpty) + const _Empty() + else + for (final token in state.tokens) _TokenRow(token: token), + const _Footnote(), + ], + ), + ), + ); + } +} + +class _Totals extends StatelessWidget { + const _Totals({required this.totals}); + + final TokenTotals totals; + + @override + Widget build(BuildContext context) { + return Container( + color: AppColors.surface, + margin: const EdgeInsets.only(bottom: 1), + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + child: Wrap( + spacing: 22, + runSpacing: 12, + children: [ + _Stat( + value: '${totals.live}', + label: 'live', + tone: AppColors.textPrimary, + ), + _Stat( + value: '${totals.orphaned}', + label: 'owner deactivated', + tone: totals.orphaned > 0 + ? AppColors.warning600 + : AppColors.textPrimary, + ), + _Stat( + // Measured from the last use, or from when the token was issued + // when it has never been used. A token created a minute ago is not + // "unused for 90 days", which is what this used to claim. + value: '${totals.unused90d}', + label: 'unused 90+ days', + tone: totals.unused90d > 0 + ? AppColors.warning600 + : AppColors.textPrimary, + ), + _Stat( + value: '${totals.count}', + label: 'issued in total', + tone: AppColors.textPrimary, + ), + ], + ), + ); + } +} + +class _OrphanBanner extends ConsumerStatefulWidget { + const _OrphanBanner({required this.count}); + + final int count; + + @override + ConsumerState<_OrphanBanner> createState() => _OrphanBannerState(); +} + +class _OrphanBannerState extends ConsumerState<_OrphanBanner> { + bool _busy = false; + + Future _revokeAll() async { + setState(() => _busy = true); + final result = await ref + .read(accessTokensProvider.notifier) + .revokeOrphaned(); + if (!mounted) return; + setState(() => _busy = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + result.error ?? + 'Revoked ${result.revoked} ' + '${result.revoked == 1 ? 'token' : 'tokens'} on deactivated ' + 'accounts.', + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final many = widget.count != 1; + return Container( + color: AppColors.surface, + margin: const EdgeInsets.only(bottom: 1), + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(LucideIcons.userX, size: 18, color: AppColors.warning600), + const SizedBox(width: 10), + Expanded( + child: Text( + '${widget.count} live ${many ? 'tokens belong' : 'token belongs'} ' + 'to a deactivated account. Deactivating already stops ' + '${many ? 'them' : 'it'} at login, but ' + '${many ? 'they are' : 'it is'} not revoked. Reactivating ' + 'the account would bring ${many ? 'them' : 'it'} back.', + style: AppTypography.caption.copyWith( + color: AppColors.textPrimary, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: _busy ? null : _revokeAll, + child: Text(many ? 'Revoke them all' : 'Revoke it'), + ), + ), + ], + ), + ); + } +} + +class _TokenRow extends ConsumerWidget { + const _TokenRow({required this.token}); + + final AccessToken token; + + Future _revoke(BuildContext context, WidgetRef ref) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text('Revoke "${token.name}"?'), + content: Text(tokenRevokeExplanation), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Revoke'), + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return; + + final error = await ref + .read(accessTokensProvider.notifier) + .revokeToken(token.id); + if (!context.mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error ?? 'Token revoked'))); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final stale = token.stalenessNote(DateTime.now()); + return Opacity( + opacity: token.isLive ? 1 : 0.6, + child: Container( + color: AppColors.surface, + margin: const EdgeInsets.only(bottom: 1), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + token.name, + style: AppTypography.body.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 10), + _StatusChip(label: token.statusLabel, isLive: token.isLive), + ], + ), + const SizedBox(height: 3), + // The prefix, and only the prefix. There is no full value to show: + // the server keeps a hash. + Text( + '${token.tokenPrefix}...', + style: AppTypography.caption.copyWith( + fontFamily: 'monospace', + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 6), + Text( + '${token.owner.name} · ${token.owner.roleLabel}' + '${token.owner.isActive ? '' : ' · deactivated'}', + style: AppTypography.caption.copyWith( + color: token.owner.isActive + ? AppColors.textSecondary + : AppColors.warning600, + ), + ), + const SizedBox(height: 2), + Text( + token.scopeSummary, + style: AppTypography.caption.copyWith( + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 2), + Text( + _activityLine(token), + style: AppTypography.caption.copyWith( + color: AppColors.textTertiary, + ), + ), + if (stale != null) ...[ + const SizedBox(height: 2), + Text( + stale, + style: AppTypography.caption.copyWith( + color: AppColors.warning600, + fontWeight: FontWeight.w500, + ), + ), + ], + if (token.isLive) ...[ + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: () => _revoke(context, ref), + child: const Text('Revoke'), + ), + ), + ], + ], + ), + ), + ); + } +} + +/// "Last used 12 days ago · expires 4 Nov 2026", or the honest absences. +String _activityLine(AccessToken token) { + final used = token.lastUsedAt == null + ? 'Never used' + : 'Last used ${_humanDate(token.lastUsedAt!)}'; + final expiry = token.expiresAt == null + ? 'never expires' + : 'expires ${_humanDate(token.expiresAt!)}'; + return '$used · $expiry'; +} + +String _humanDate(DateTime date) { + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + return '${date.day} ${months[date.month - 1]} ${date.year}'; +} + +class _StatusChip extends StatelessWidget { + const _StatusChip({required this.label, required this.isLive}); + + final String label; + final bool isLive; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: isLive ? AppColors.success50 : AppColors.surfaceDim, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: isLive ? AppColors.success200 : AppColors.border, + ), + ), + child: Text( + label, + style: AppTypography.caption.copyWith( + color: isLive ? AppColors.success600 : AppColors.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +class _Stat extends StatelessWidget { + const _Stat({required this.value, required this.label, required this.tone}); + + final String value; + final String label; + final Color tone; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + value, + style: AppTypography.h2.copyWith( + fontWeight: FontWeight.w600, + color: tone, + ), + ), + Text( + label, + style: AppTypography.caption.copyWith(color: AppColors.textSecondary), + ), + ], + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader(this.title); + + final String title; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 22, 16, 8), + child: Text( + title.toUpperCase(), + style: AppTypography.overline.copyWith( + color: AppColors.textSecondary, + letterSpacing: 1.2, + ), + ), + ); + } +} + +class _Empty extends StatelessWidget { + const _Empty(); + + @override + Widget build(BuildContext context) { + return Container( + color: AppColors.surface, + padding: const EdgeInsets.fromLTRB(16, 20, 16, 20), + child: Text( + 'No tokens have been issued in this organization.', + style: AppTypography.body.copyWith(color: AppColors.textSecondary), + ), + ); + } +} + +class _Footnote extends StatelessWidget { + const _Footnote(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 0), + child: Text( + 'A token authenticates as its owner and inherits their role and ' + 'organization. Issue one per integration so a single revocation stops ' + 'a single thing, set an expiry, and revoke anything you cannot name a ' + 'use for.', + style: AppTypography.caption.copyWith(color: AppColors.textTertiary), + ), + ); + } +} + +class _ErrorState extends StatelessWidget { + const _ErrorState({required this.onRetry}); + + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.triangleAlert, + size: 40, + color: AppColors.textTertiary, + ), + const SizedBox(height: 16), + Text( + 'Could not load tokens', + style: AppTypography.body, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + OutlinedButton(onPressed: onRetry, child: const Text('Try again')), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/screens/settings/organization_edit_screen.dart b/mobile/lib/screens/settings/organization_edit_screen.dart new file mode 100644 index 00000000..20d5c32f --- /dev/null +++ b/mobile/lib/screens/settings/organization_edit_screen.dart @@ -0,0 +1,381 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../core/theme/theme.dart'; +import '../../data/models/deal.dart' show Currency; +import '../../data/models/org_settings.dart'; +import '../../providers/auth_provider.dart'; +import '../../providers/settings_provider.dart'; + +/// Editing the organization. +/// +/// **Admin-only.** A member sees the notice rather than the form, but that is +/// the courtesy, not the control: `PATCH /api/org/settings/` refuses anyone +/// whose role is not ADMIN, whatever reaches it. +/// +/// What this form owns is the org's own settings: the company profile printed +/// on documents, the locale defaults, and the two org-wide switches. What it +/// deliberately does not own is the org API key (a credential, rotated through +/// its own action) and `is_active`, the org kill switch. Neither is a field +/// here and the server would not accept them if they were. +class OrganizationEditScreen extends ConsumerWidget { + const OrganizationEditScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isAdmin = ref.watch(isOrgAdminProvider); + final async = ref.watch(orgSettingsProvider); + + return Scaffold( + backgroundColor: AppColors.surfaceDim, + appBar: AppBar( + title: const Text('Edit organization'), + backgroundColor: AppColors.surface, + elevation: 0, + scrolledUnderElevation: 1, + ), + body: !isAdmin + ? const _MemberNotice() + : async.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, _) => const Center( + child: Padding( + padding: EdgeInsets.all(32), + child: Text('Could not load the organization'), + ), + ), + data: (org) => _Form(org: org), + ), + ); + } +} + +class _MemberNotice extends StatelessWidget { + const _MemberNotice(); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.lock, size: 40, color: AppColors.textTertiary), + const SizedBox(height: 16), + Text( + 'Administrators only', + style: AppTypography.h3, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Editing organization details is limited to administrators. Ask ' + 'one of yours if a company detail, currency, or survey setting ' + 'needs changing.', + style: AppTypography.body.copyWith( + color: AppColors.textSecondary, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} + +class _Form extends ConsumerStatefulWidget { + const _Form({required this.org}); + + final OrgSettings org; + + @override + ConsumerState<_Form> createState() => _FormState(); +} + +class _FormState extends ConsumerState<_Form> { + late final Map _text; + late String _country; + late String _defaultCountry; + late String _currency; + late String _timezone; + late bool _csat; + late bool _cascade; + String? _error; + bool _busy = false; + + @override + void initState() { + super.initState(); + final org = widget.org; + _text = { + 'company_name': TextEditingController(text: org.companyName), + 'name': TextEditingController(text: org.name), + 'tax_id': TextEditingController(text: org.taxId), + 'phone': TextEditingController(text: org.phone), + 'email': TextEditingController(text: org.email), + 'website': TextEditingController(text: org.website), + 'address_line': TextEditingController(text: org.addressLine), + 'city': TextEditingController(text: org.city), + 'state': TextEditingController(text: org.state), + 'postcode': TextEditingController(text: org.postcode), + }; + _country = org.country; + _defaultCountry = org.defaultCountry; + _currency = org.defaultCurrency; + _timezone = org.timezone; + _csat = org.csatEnabled; + _cascade = org.autoCloseChildren; + } + + @override + void dispose() { + for (final controller in _text.values) { + controller.dispose(); + } + super.dispose(); + } + + String _value(String key) => _text[key]!.text; + + Future _save() async { + final problem = orgSettingsProblem( + email: _value('email'), + website: _value('website'), + ); + if (problem != null) { + setState(() => _error = problem); + return; + } + setState(() { + _error = null; + _busy = true; + }); + + final error = await ref + .read(orgSettingsProvider.notifier) + .save( + orgSettingsPayload( + name: _value('name'), + companyName: _value('company_name'), + addressLine: _value('address_line'), + city: _value('city'), + state: _value('state'), + postcode: _value('postcode'), + country: _country, + phone: _value('phone'), + email: _value('email'), + website: _value('website'), + taxId: _value('tax_id'), + defaultCurrency: _currency, + defaultCountry: _defaultCountry, + timezone: _timezone, + csatEnabled: _csat, + autoCloseChildren: _cascade, + ), + ); + if (!mounted) return; + setState(() => _busy = false); + if (error != null) { + setState(() => _error = error); + return; + } + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Organization saved'))); + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final zones = ref.watch(orgTimezonesProvider); + final currencies = [ + for (final currency in Currency.values) + (value: currency.value, label: '${currency.label} ${currency.symbol}'), + ]; + + return ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 40), + children: [ + Text( + 'Printed on every invoice and estimate. Changes apply from now on; ' + 'documents already sent keep what they were sent with.', + style: AppTypography.caption.copyWith(color: AppColors.textSecondary), + ), + const SizedBox(height: 16), + _field('company_name', 'Legal name', maxLength: 255), + _field('name', 'Trading name', maxLength: 100), + _field('tax_id', 'Tax ID', maxLength: 50), + _field('phone', 'Phone', maxLength: 25, keyboard: TextInputType.phone), + _field('email', 'Email', keyboard: TextInputType.emailAddress), + _field('website', 'Website', keyboard: TextInputType.url), + _field('address_line', 'Address', maxLength: 255), + _field('city', 'City', maxLength: 100), + _field('state', 'State', maxLength: 100), + _field('postcode', 'Postcode', maxLength: 20), + _picker( + label: 'Country', + value: _country, + // "Not recorded" is a real choice: the column is blank-able and an + // org that has not said where it is should not be made to guess. + options: [ + (value: '', label: 'Not recorded'), + ...withCurrent(orgCountryOptions, _country), + ], + onChanged: (v) => setState(() => _country = v), + ), + const _SectionLabel('Defaults'), + _picker( + label: 'Currency', + value: _currency, + helper: + 'Applied to new invoices and estimates. Existing ones keep theirs.', + options: withCurrent(currencies, _currency), + onChanged: (v) => setState(() => _currency = v), + ), + _picker( + label: 'Country default', + value: _defaultCountry, + helper: 'Pre-filled on new addresses.', + options: [ + (value: '', label: 'Not set'), + ...withCurrent(orgCountryOptions, _defaultCountry), + ], + onChanged: (v) => setState(() => _defaultCountry = v), + ), + _picker( + label: 'Time zone', + value: _timezone, + helper: + 'When a day starts for this organization. Changing it moves what ' + 'counts as due today and overdue, for everyone here.', + // From the API, not the device: the two vocabularies disagree on + // aliases, and a picker missing the stored zone would move the org. + options: withCurrent([ + for (final zone in zones.value ?? const []) + (value: zone.name, label: zone.label), + ], _timezone), + onChanged: (v) => setState(() => _timezone = v), + ), + const _SectionLabel('Behaviour'), + SwitchListTile( + contentPadding: EdgeInsets.zero, + value: _csat, + onChanged: (v) => setState(() => _csat = v), + title: const Text('Satisfaction surveys'), + subtitle: Text( + csatExplanation(_csat), + style: AppTypography.caption.copyWith( + color: _csat ? AppColors.textSecondary : AppColors.warning600, + ), + ), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + value: _cascade, + onChanged: (v) => setState(() => _cascade = v), + title: const Text('Close child tickets with the parent'), + subtitle: Text( + '${cascadeDefaultExplanation(_cascade)} $cascadeWebGapNote', + style: AppTypography.caption.copyWith( + color: AppColors.textSecondary, + ), + ), + ), + if (_error != null) ...[ + const SizedBox(height: 12), + Text( + _error!, + style: AppTypography.caption.copyWith(color: AppColors.danger600), + ), + ], + const SizedBox(height: 20), + SizedBox( + height: 48, + child: FilledButton( + onPressed: _busy ? null : _save, + child: const Text('Save changes'), + ), + ), + const SizedBox(height: 10), + SizedBox( + height: 48, + child: OutlinedButton( + onPressed: _busy ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ), + ], + ); + } + + Widget _field( + String key, + String label, { + int? maxLength, + TextInputType? keyboard, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: TextField( + controller: _text[key], + maxLength: maxLength, + keyboardType: keyboard, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + counterText: '', + ), + ), + ); + } + + Widget _picker({ + required String label, + required String value, + required List options, + required ValueChanged onChanged, + String? helper, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: DropdownButtonFormField( + initialValue: options.any((o) => o.value == value) ? value : null, + isExpanded: true, + decoration: InputDecoration( + labelText: label, + helperText: helper, + helperMaxLines: 3, + border: const OutlineInputBorder(), + ), + items: [ + for (final option in options) + DropdownMenuItem(value: option.value, child: Text(option.label)), + ], + onChanged: (v) => onChanged(v ?? value), + ), + ); + } +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel(this.title); + + final String title; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 12), + child: Text( + title.toUpperCase(), + style: AppTypography.overline.copyWith( + color: AppColors.textSecondary, + letterSpacing: 1.2, + ), + ), + ); + } +} diff --git a/mobile/lib/screens/settings/organization_screen.dart b/mobile/lib/screens/settings/organization_screen.dart new file mode 100644 index 00000000..b9e76871 --- /dev/null +++ b/mobile/lib/screens/settings/organization_screen.dart @@ -0,0 +1,574 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../core/theme/theme.dart'; +import '../../data/models/org_settings.dart'; +import '../../providers/auth_provider.dart'; +import '../../providers/settings_provider.dart'; +import '../../routes/app_router.dart'; + +/// The organization itself, read view. +/// +/// Most of this is the company profile, and the thing worth saying about it is +/// where it ends up: these fields are printed on every invoice and estimate a +/// customer receives, so a wrong tax ID is a wrong document rather than a wrong +/// preference. +/// +/// **The org API key is not here, in any form.** It authenticates as the org's +/// first admin, so the API never serializes it onto this payload. Rotating it +/// belongs behind its own audited action. +/// +/// The read is open to any member: the company profile is not a secret, and +/// members legitimately see their org's address and currency. Editing, applying +/// a pack and clearing sample data are admin-only, enforced server-side; hiding +/// the buttons is UX. +class OrganizationScreen extends ConsumerWidget { + const OrganizationScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isAdmin = ref.watch(isOrgAdminProvider); + final async = ref.watch(orgSettingsProvider); + + return Scaffold( + backgroundColor: AppColors.surfaceDim, + appBar: AppBar( + title: const Text('Organization'), + backgroundColor: AppColors.surface, + elevation: 0, + scrolledUnderElevation: 1, + actions: [ + if (isAdmin && async.value != null) + IconButton( + icon: const Icon(LucideIcons.pencil), + tooltip: 'Edit details', + onPressed: () => context.push(AppRoutes.settingsOrganizationEdit), + ), + ], + ), + body: async.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, _) => _ErrorState( + onRetry: () => ref.read(orgSettingsProvider.notifier).refresh(), + ), + data: (org) => RefreshIndicator( + onRefresh: () async { + ref.invalidate(orgPacksProvider); + await ref.read(orgSettingsProvider.notifier).refresh(); + }, + child: ListView( + padding: const EdgeInsets.only(bottom: 40), + children: [ + _Header(org: org), + const _SectionHeader('What customers see'), + _Field(label: 'Legal name', value: org.companyName), + _Field(label: 'Trading as', value: org.name), + _Field(label: 'Address', value: org.addressSummary), + _Field(label: 'Tax ID', value: org.taxId), + _Field(label: 'Email', value: org.email), + _Field(label: 'Phone', value: org.phone), + _Field(label: 'Website', value: org.website), + const _PrintedNote(), + const _SectionHeader('Defaults'), + _Field( + label: 'Currency', + value: '${org.defaultCurrency} ${org.currencySymbol}', + detail: + 'Applied to new invoices and estimates. Existing ones ' + 'keep theirs.', + ), + _Field( + label: 'Country', + value: org.defaultCountry, + detail: 'Default for new addresses.', + ), + _Field( + label: 'Time zone', + value: org.timezoneLabel, + detail: + 'When a day starts here, so "due today" and "overdue" ' + 'mean what your team expects.', + ), + const _SectionHeader('Behaviour'), + _Field( + label: 'Satisfaction surveys', + value: org.csatEnabled ? 'Sending' : 'Off', + detail: csatExplanation(org.csatEnabled), + ), + _Field( + label: 'Close child tickets with the parent', + value: org.autoCloseChildren + ? 'Starts ticked' + : 'Starts unticked', + detail: + '${cascadeDefaultExplanation(org.autoCloseChildren)} ' + '$cascadeWebGapNote', + ), + const _ApiKeyNote(), + const _SectionHeader('Vertical pack'), + _PackSection(org: org, isAdmin: isAdmin), + ], + ), + ), + ), + ); + } +} + +class _Header extends StatelessWidget { + const _Header({required this.org}); + + final OrgSettings org; + + @override + Widget build(BuildContext context) { + return Container( + color: AppColors.surface, + margin: const EdgeInsets.only(bottom: 1), + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + org.name.isEmpty ? 'Unnamed organization' : org.name, + style: AppTypography.h3.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 3), + Text( + '${org.memberCount} member${org.memberCount == 1 ? '' : 's'}', + style: AppTypography.caption.copyWith( + color: AppColors.textSecondary, + ), + ), + ], + ), + ); + } +} + +class _Field extends StatelessWidget { + const _Field({required this.label, required this.value, this.detail}); + + final String label; + final String value; + final String? detail; + + @override + Widget build(BuildContext context) { + final shown = value.trim(); + return Container( + color: AppColors.surface, + margin: const EdgeInsets.only(bottom: 1), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: AppTypography.caption.copyWith( + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 3), + Text( + shown.isEmpty ? 'Not recorded' : shown, + style: AppTypography.body.copyWith( + fontWeight: FontWeight.w500, + color: shown.isEmpty + ? AppColors.textTertiary + : AppColors.textPrimary, + ), + ), + if (detail != null) ...[ + const SizedBox(height: 4), + Text( + detail!, + style: AppTypography.caption.copyWith( + color: AppColors.textTertiary, + ), + ), + ], + ], + ), + ); + } +} + +class _PrintedNote extends StatelessWidget { + const _PrintedNote(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 0), + child: Text( + 'These fields are printed on every invoice and estimate. Changing one ' + 'changes documents from that moment on; PDFs already sent keep what ' + 'they were sent with.', + style: AppTypography.caption.copyWith(color: AppColors.textTertiary), + ), + ); + } +} + +class _ApiKeyNote extends StatelessWidget { + const _ApiKeyNote(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + LucideIcons.shieldAlert, + size: 16, + color: AppColors.textTertiary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'The organization API key is not here. It authenticates as the ' + 'whole organization, so it is never shown on a page you can ' + 'reach by browsing. For per-person programmatic access, use API ' + 'tokens, which can be revoked one at a time.', + style: AppTypography.caption.copyWith( + color: AppColors.textTertiary, + ), + ), + ), + ], + ), + ); + } +} + +/// Packs: what is applied, what can be applied, and the sample data a pack +/// creates. +class _PackSection extends ConsumerStatefulWidget { + const _PackSection({required this.org, required this.isAdmin}); + + final OrgSettings org; + final bool isAdmin; + + @override + ConsumerState<_PackSection> createState() => _PackSectionState(); +} + +class _PackSectionState extends ConsumerState<_PackSection> { + bool _busy = false; + + Future _apply(VerticalPack pack) async { + // Additive-only and safe to repeat, so re-applying the current pack is + // allowed rather than blocked. It reports everything skipped. + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text('Apply "${pack.name}"?'), + content: const Text( + 'This adds starter pipelines, tags, custom fields, products and a ' + 'set of sample records. Anything already set up is left exactly as ' + 'it is.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Apply'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + + setState(() => _busy = true); + final result = await ref + .read(orgSettingsProvider.notifier) + .applyPack(pack.id); + if (!mounted) return; + setState(() => _busy = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(result.error ?? result.report!.summary)), + ); + } + + Future _clearSampleData() async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Clear sample data?'), + content: const Text( + 'Permanently deletes every sample record a pack created for this ' + 'organization. Your real records are never touched, and any sample ' + 'record you have since attached real work to is kept. This cannot ' + 'be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Clear'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + + setState(() => _busy = true); + final result = await ref + .read(orgSettingsProvider.notifier) + .clearSampleData(); + if (!mounted) return; + setState(() => _busy = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + result.error ?? + sampleDataResult( + deleted: result.deleted, + retained: result.retained, + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final org = widget.org; + final packs = ref.watch(orgPacksProvider).value ?? const []; + // The registry entry matching `org.vertical`, or null when no pack has been + // applied or the id is one this build does not know. + final applied = packs.where((pack) => pack.id == org.vertical).firstOrNull; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + color: AppColors.surface, + margin: const EdgeInsets.only(bottom: 1), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'First pack applied', + style: AppTypography.caption.copyWith( + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 3), + Text( + applied?.name ?? + (org.vertical.isEmpty ? 'None yet' : org.vertical), + style: AppTypography.body.copyWith(fontWeight: FontWeight.w500), + ), + const SizedBox(height: 6), + Text( + 'A pack adds starter pipelines, tags, custom fields, products ' + 'and sample records for one kind of business. Applying one ' + 'only fills in what this organization is missing.', + style: AppTypography.caption.copyWith( + color: AppColors.textTertiary, + ), + ), + ], + ), + ), + if (!widget.isAdmin) + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Text( + 'Applying a pack or clearing sample data is limited to ' + 'administrators.', + style: AppTypography.caption.copyWith( + color: AppColors.textTertiary, + ), + ), + ) + else ...[ + for (final pack in packs) + _PackRow( + pack: pack, + isApplied: pack.id == org.vertical, + busy: _busy, + onApply: () => _apply(pack), + ), + if (packs.isEmpty) + Container( + color: AppColors.surface, + padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), + child: Text( + 'No packs available right now.', + style: AppTypography.body.copyWith( + color: AppColors.textSecondary, + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 0), + child: SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _busy ? null : _clearSampleData, + icon: const Icon(LucideIcons.trash2, size: 16), + label: const Text('Clear sample data'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.danger600, + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Text( + 'Removes only the records a pack created as samples. Your real ' + 'records are never touched.', + style: AppTypography.caption.copyWith( + color: AppColors.textTertiary, + ), + ), + ), + ], + ], + ); + } +} + +class _PackRow extends StatelessWidget { + const _PackRow({ + required this.pack, + required this.isApplied, + required this.busy, + required this.onApply, + }); + + final VerticalPack pack; + final bool isApplied; + final bool busy; + final VoidCallback onApply; + + @override + Widget build(BuildContext context) { + return Container( + color: AppColors.surface, + margin: const EdgeInsets.only(bottom: 1), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + pack.name, + style: AppTypography.body.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + if (isApplied) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: AppColors.success50, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: AppColors.success200), + ), + child: Text( + 'Applied', + style: AppTypography.caption.copyWith( + color: AppColors.success600, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + if (pack.description.isNotEmpty) ...[ + const SizedBox(height: 3), + Text( + pack.description, + style: AppTypography.caption.copyWith( + color: AppColors.textSecondary, + ), + ), + ], + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: busy ? null : onApply, + child: Text(isApplied ? 'Apply again' : 'Apply'), + ), + ), + ], + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader(this.title); + + final String title; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 22, 16, 8), + child: Text( + title.toUpperCase(), + style: AppTypography.overline.copyWith( + color: AppColors.textSecondary, + letterSpacing: 1.2, + ), + ), + ); + } +} + +class _ErrorState extends StatelessWidget { + const _ErrorState({required this.onRetry}); + + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.triangleAlert, + size: 40, + color: AppColors.textTertiary, + ), + const SizedBox(height: 16), + Text( + 'Could not load the organization', + style: AppTypography.body, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + OutlinedButton(onPressed: onRetry, child: const Text('Try again')), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/screens/settings/settings_hub_screen.dart b/mobile/lib/screens/settings/settings_hub_screen.dart index 9b1bcdc6..683ba611 100644 --- a/mobile/lib/screens/settings/settings_hub_screen.dart +++ b/mobile/lib/screens/settings/settings_hub_screen.dart @@ -83,6 +83,19 @@ class SettingsHubScreen extends StatelessWidget { description: 'Canned answers to insert into a ticket reply', onTap: () => context.push(AppRoutes.settingsMacros), ), + const _SectionHeader('Organization'), + MenuRow( + icon: LucideIcons.building2, + label: 'Organization', + description: 'Company details printed on documents, and defaults', + onTap: () => context.push(AppRoutes.settingsOrganization), + ), + MenuRow( + icon: LucideIcons.keyRound, + label: 'API tokens', + description: 'Programmatic access, and what to revoke', + onTap: () => context.push(AppRoutes.settingsApiTokens), + ), const SizedBox(height: 24), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), diff --git a/mobile/lib/screens/tickets/close_with_children_dialog.dart b/mobile/lib/screens/tickets/close_with_children_dialog.dart new file mode 100644 index 00000000..46d7b340 --- /dev/null +++ b/mobile/lib/screens/tickets/close_with_children_dialog.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; + +import '../../core/theme/theme.dart'; + +/// Confirm closing a parent ticket, with an explicit choice about its children. +/// +/// **[startsTicked] comes from `Org.auto_close_children_on_parent_close`, and +/// that setting has no other effect on a person.** +/// `CaseCloseWithChildrenView` reads the org column only when the caller omits +/// `cascade` entirely, and this app always sends it, so the setting reaches +/// someone through this checkbox or nowhere. It was hardcoded to `true` here, +/// which made the setting inert and the settings screen's "starts unticked" +/// simply false. +/// +/// Its own function rather than a closure inside the ticket screen so that the +/// seeding can be tested: a checkbox that silently reverts to always-ticked is +/// exactly the regression this exists to catch. +/// +/// Returns null if dismissed, otherwise the choice that was confirmed. +Future<({bool cascade})?> showCloseWithChildrenDialog( + BuildContext context, { + required String ticketName, + required int childCount, + required bool startsTicked, +}) async { + var cascade = startsTicked; + final confirmed = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setLocal) => AlertDialog( + title: const Text('Close ticket'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Close "$ticketName"? This ticket has $childCount ' + 'linked ticket${childCount == 1 ? '' : 's'}.', + ), + const SizedBox(height: 16), + Row( + children: [ + Checkbox( + value: cascade, + onChanged: (v) => setLocal(() => cascade = v ?? startsTicked), + ), + const Expanded(child: Text('Also close linked tickets')), + ], + ), + Text( + 'Only the linked tickets that are still open are closed, ' + 'including any beneath them.', + style: AppTypography.caption.copyWith( + color: AppColors.textSecondary, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Close ticket'), + ), + ], + ), + ), + ); + if (confirmed != true) return null; + return (cascade: cascade); +} diff --git a/mobile/lib/screens/tickets/ticket_detail_screen.dart b/mobile/lib/screens/tickets/ticket_detail_screen.dart index 4fdb2700..912172b1 100644 --- a/mobile/lib/screens/tickets/ticket_detail_screen.dart +++ b/mobile/lib/screens/tickets/ticket_detail_screen.dart @@ -7,10 +7,13 @@ import '../../core/theme/theme.dart'; import '../../data/models/attachment.dart'; import '../../data/models/ticket.dart'; import '../../data/models/comment.dart'; +import 'close_with_children_dialog.dart'; import 'macro_picker_sheet.dart'; import '../../data/models/email_message.dart'; +import '../../data/models/org_settings.dart'; import '../../providers/auth_provider.dart'; import '../../providers/lookup_provider.dart'; +import '../../providers/settings_provider.dart'; import '../../providers/tickets_provider.dart'; import '../../services/attachment_upload.dart'; import '../../data/models/lookup_models.dart'; @@ -1808,46 +1811,30 @@ class _TicketDetailScreenState extends ConsumerState } Future _closeWithChildren(Ticket c) async { - bool cascade = true; - final confirmed = await showDialog( - context: context, - builder: (ctx) => StatefulBuilder( - builder: (ctx, setLocal) => AlertDialog( - title: const Text('Close ticket'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Close "${c.name}"? This ticket has ${c.childCount} ' - 'linked ticket${c.childCount == 1 ? '' : 's'}.', - ), - const SizedBox(height: 16), - Row( - children: [ - Checkbox( - value: cascade, - onChanged: (v) => setLocal(() => cascade = v ?? true), - ), - const Expanded(child: Text('Also close linked tickets')), - ], - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.pop(ctx, true), - child: const Text('Close ticket'), - ), - ], - ), - ), + // **The org setting decides how this prompt starts.** That is the whole of + // what `Org.auto_close_children_on_parent_close` does for a person: + // `CaseCloseWithChildrenView` reads it only when the caller omits + // `cascade`, and this app always sends it, so hardcoding `true` here made + // the setting inert. The settings screen says "starts ticked"/"starts + // unticked"; this is the line that has to make that true. + final orgSettings = await ref + .read(orgSettingsProvider.future) + .catchError( + // A settings read that fails must not block closing a ticket. Falling + // back to the model default (false) is the safer of the two: it offers + // not to close children rather than offering to. + (_) => const OrgSettings(), + ); + if (!mounted) return; + + final choice = await showCloseWithChildrenDialog( + context, + ticketName: c.name, + childCount: c.childCount, + startsTicked: orgSettings.autoCloseChildren, ); - if (confirmed != true) return; + if (choice == null || !mounted) return; + final cascade = choice.cascade; final res = await ref .read(ticketsProvider.notifier) @@ -1859,9 +1846,10 @@ class _TicketDetailScreenState extends ConsumerState ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - cascade - ? 'Closed ticket and ${c.childCount} linked' - : 'Ticket closed', + cascadeCloseMessage( + cascade: cascade, + cascaded: cascadedCount(res.data), + ), ), behavior: SnackBarBehavior.floating, ), diff --git a/mobile/test/screens/phone_viewport_test.dart b/mobile/test/screens/phone_viewport_test.dart index 187e854f..59a63472 100644 --- a/mobile/test/screens/phone_viewport_test.dart +++ b/mobile/test/screens/phone_viewport_test.dart @@ -1,8 +1,10 @@ +import 'package:bottle_crm/data/models/access_token.dart'; import 'package:bottle_crm/data/models/app_notification.dart'; import 'package:bottle_crm/data/models/business_calendar.dart'; import 'package:bottle_crm/data/models/custom_field_definition.dart'; import 'package:bottle_crm/data/models/escalation_policy.dart'; import 'package:bottle_crm/data/models/macro.dart'; +import 'package:bottle_crm/data/models/org_settings.dart'; import 'package:bottle_crm/data/models/reopen_policy.dart'; import 'package:bottle_crm/data/models/invoice.dart'; import 'package:bottle_crm/data/models/time_entry.dart'; @@ -27,12 +29,17 @@ import 'package:bottle_crm/screens/invoices/new_recurring_screen.dart'; import 'package:bottle_crm/screens/invoices/products_list_screen.dart'; import 'package:bottle_crm/screens/invoices/recurring_list_screen.dart'; import 'package:bottle_crm/screens/notifications/notifications_screen.dart'; +import 'package:bottle_crm/screens/settings/api_token_form_sheet.dart'; +import 'package:bottle_crm/screens/settings/api_tokens_screen.dart'; import 'package:bottle_crm/screens/settings/business_hours_form_sheet.dart'; import 'package:bottle_crm/screens/settings/business_hours_screen.dart'; import 'package:bottle_crm/screens/settings/custom_fields_screen.dart'; import 'package:bottle_crm/screens/settings/escalation_policy_form_sheet.dart'; import 'package:bottle_crm/screens/settings/escalation_screen.dart'; import 'package:bottle_crm/screens/settings/macros_screen.dart'; +import 'package:bottle_crm/screens/settings/organization_edit_screen.dart'; +import 'package:bottle_crm/screens/settings/organization_screen.dart'; +import 'package:bottle_crm/screens/tickets/close_with_children_dialog.dart'; import 'package:bottle_crm/screens/tickets/macro_picker_sheet.dart'; import 'package:bottle_crm/screens/settings/settings_hub_screen.dart'; import 'package:bottle_crm/screens/settings/reopen_screen.dart'; @@ -275,6 +282,69 @@ void main() { ), ); + /// The API tokens screen. The list is admin-only server-side, so `isAdmin` + /// here decides whether the screen issues it at all. + Widget tokensApp({required bool isAdmin, bool orphaned = false}) => + ProviderScope( + overrides: [ + accessTokensProvider.overrideWith( + orphaned ? _FakeOrphanedTokens.new : _FakeTokens.new, + ), + isOrgAdminProvider.overrideWithValue(isAdmin), + ], + child: const MaterialApp(home: ApiTokensScreen()), + ); + + /// A button that opens the create sheet, so it renders the way it is really + /// reached: over a screen, with the keyboard inset in play. + Widget tokenFormApp() => MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => showApiTokenFormSheet(context), + child: const Text('open'), + ), + ), + ), + ); + + /// The organization screen. The read is open to any member, so `isAdmin` only + /// decides which controls show. + Widget organizationApp({required bool isAdmin, bool cascadeOn = false}) => + ProviderScope( + overrides: [ + orgSettingsProvider.overrideWith( + cascadeOn ? _FakeOrgCascadeOn.new : _FakeOrg.new, + ), + orgPacksProvider.overrideWith( + (ref) async => [ + VerticalPack.fromJson(const { + 'id': 'real-estate', + 'name': 'Real estate', + 'description': 'Listings, viewings and offers', + 'version': '1', + }), + ], + ), + isOrgAdminProvider.overrideWithValue(isAdmin), + ], + child: const MaterialApp(home: OrganizationScreen()), + ); + + Widget organizationEditApp({required bool isAdmin}) => ProviderScope( + overrides: [ + orgSettingsProvider.overrideWith(_FakeOrg.new), + orgTimezonesProvider.overrideWith( + (ref) async => const [ + TimezoneOption(name: 'Asia/Kolkata', offsetMinutes: 330), + TimezoneOption(name: 'UTC', offsetMinutes: 0), + ], + ), + isOrgAdminProvider.overrideWithValue(isAdmin), + ], + child: const MaterialApp(home: OrganizationEditScreen()), + ); + /// The reopen screen. The read is admin-only server-side, so `isAdmin` here /// decides whether the screen issues it at all. Widget reopenApp({required bool isAdmin, bool policyOff = false}) => @@ -1735,6 +1805,254 @@ void main() { ); }); }); + + group('API tokens at 390px', () { + testWidgets('renders without overflowing', (tester) async { + await pump(tester, tokensApp(isAdmin: true)); + expect(tester.takeException(), isNull); + }); + + testWidgets('fits with the system font scaled up', (tester) async { + await pump(tester, tokensApp(isAdmin: true), textScale: 1.5); + expect(tester.takeException(), isNull); + }); + + testWidgets('a member is told why rather than shown a failed request', ( + tester, + ) async { + // `/api/org/tokens/` is admin-only: it lists every token in the org. + await pump(tester, tokensApp(isAdmin: false)); + + expect(find.text('Administrators only'), findsOneWidget); + expect(find.text('New token'), findsNothing); + }); + + testWidgets('shows the prefix and never a full value', (tester) async { + await pump(tester, tokensApp(isAdmin: true)); + + expect(find.textContaining('bcrm_pat_abc...'), findsWidgets); + // The row carries no raw token because the server keeps only a hash. + expect(find.textContaining('bcrm_pat_abcdefgh'), findsNothing); + }); + + testWidgets('does not call a token issued today unused for 90 days', ( + tester, + ) async { + // The finding: null last_used_at is not "long ago", and the row has to + // agree with the count the card shows. + await pump(tester, tokensApp(isAdmin: true)); + + // Any phrasing of it: the row must say nothing at all about neglect. + expect(find.textContaining('never used'), findsNothing); + expect(find.textContaining('unused for'), findsNothing); + }); + + testWidgets('offers to clear the loose end when an owner is gone', ( + tester, + ) async { + await pump(tester, tokensApp(isAdmin: true, orphaned: true)); + + expect(find.textContaining('deactivated account'), findsWidgets); + expect(find.text('Revoke it'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('the create sheet fits over the keyboard inset', ( + tester, + ) async { + await pump(tester, tokenFormApp()); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('Create token'), findsOneWidget); + }); + + testWidgets('warns before a token that never expires', (tester) async { + await pump(tester, tokenFormApp()); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect(find.textContaining('has to be revoked by hand'), findsNothing); + await tester.tap(find.byType(DropdownButtonFormField).last); + await tester.pumpAndSettle(); + await tester.tap(find.text('Never').last); + await tester.pumpAndSettle(); + + expect(find.textContaining('revoked by hand'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + }); + + group('organization at 390px', () { + testWidgets('renders without overflowing', (tester) async { + await pump(tester, organizationApp(isAdmin: true)); + expect(tester.takeException(), isNull); + }); + + testWidgets('fits with the system font scaled up', (tester) async { + await pump(tester, organizationApp(isAdmin: true), textScale: 1.5); + expect(tester.takeException(), isNull); + }); + + testWidgets('a member reads it, and is offered no write', (tester) async { + // GET /api/org/settings/ is open to any member: the company profile is + // not a secret, and members legitimately see the currency and address. + await pump(tester, organizationApp(isAdmin: false)); + + // Twice: the header, and again as the trading name. + expect(find.text('MicroPyramid'), findsNWidgets(2)); + expect(find.byTooltip('Edit details'), findsNothing); + expect(find.text('Clear sample data'), findsNothing); + await tester.scrollUntilVisible( + find.textContaining('limited to administrators'), + 300, + ); + expect(find.textContaining('limited to administrators'), findsOneWidget); + // Still nothing to press once the pack section is on screen. + expect(find.text('Apply'), findsNothing); + expect(find.text('Clear sample data'), findsNothing); + }); + + testWidgets('says the cascade switch starts the prompt unticked', ( + tester, + ) async { + await pump(tester, organizationApp(isAdmin: true)); + await tester.scrollUntilVisible(find.text('Starts unticked'), 300); + + expect(find.text('Starts unticked'), findsOneWidget); + expect(find.textContaining('starts with'), findsOneWidget); + }); + + testWidgets('says ticked when the setting is on', (tester) async { + // Two tests rather than one: re-pumping a second ProviderScope over the + // first keeps the element tree and the assertions cross-contaminate. + await pump(tester, organizationApp(isAdmin: true, cascadeOn: true)); + await tester.scrollUntilVisible(find.text('Starts ticked'), 300); + + expect(find.text('Starts ticked'), findsOneWidget); + }); + + testWidgets('never shows the organization API key', (tester) async { + await pump(tester, organizationApp(isAdmin: true)); + await tester.scrollUntilVisible( + find.textContaining('The organization API key is not here'), + 300, + ); + expect( + find.textContaining('The organization API key is not here'), + findsOneWidget, + ); + }); + }); + + group('the close-with-children prompt at 390px', () { + /// A button that opens the prompt, so it renders the way it is reached. + Widget closePromptApp({required bool startsTicked}) => MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => showCloseWithChildrenDialog( + context, + ticketName: 'Printer offline in the Hyderabad office', + childCount: 3, + startsTicked: startsTicked, + ), + child: const Text('open'), + ), + ), + ), + ); + + Future openPrompt(WidgetTester tester, {required bool ticked}) async { + await pump(tester, closePromptApp(startsTicked: ticked)); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + } + + testWidgets('starts unticked when the org setting is off', (tester) async { + // The finding: this was hardcoded to `true`, so + // `auto_close_children_on_parent_close` reached nobody. The settings + // screen says "starts unticked"; this is what makes that true. + await openPrompt(tester, ticked: false); + + expect(tester.widget(find.byType(Checkbox)).value, isFalse); + expect(tester.takeException(), isNull); + }); + + testWidgets('starts ticked when the org setting is on', (tester) async { + await openPrompt(tester, ticked: true); + + expect(tester.widget(find.byType(Checkbox)).value, isTrue); + expect(tester.takeException(), isNull); + }); + + testWidgets('the person can still override either way', (tester) async { + await openPrompt(tester, ticked: false); + await tester.tap(find.byType(Checkbox)); + await tester.pumpAndSettle(); + + expect(tester.widget(find.byType(Checkbox)).value, isTrue); + }); + + testWidgets('a long subject and the checkbox fit side by side', ( + tester, + ) async { + await openPrompt(tester, ticked: true); + expect(tester.takeException(), isNull); + expect(find.text('Also close linked tickets'), findsOneWidget); + }); + + testWidgets('fits with the system font scaled up', (tester) async { + await pump(tester, closePromptApp(startsTicked: true), textScale: 1.5); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + }); + + group('organization edit at 390px', () { + testWidgets('renders without overflowing', (tester) async { + await pump(tester, organizationEditApp(isAdmin: true)); + expect(tester.takeException(), isNull); + }); + + testWidgets('fits with the system font scaled up', (tester) async { + await pump(tester, organizationEditApp(isAdmin: true), textScale: 1.5); + expect(tester.takeException(), isNull); + }); + + testWidgets('a member sees the notice, not the form', (tester) async { + // The courtesy, not the control: PATCH refuses a non-admin regardless. + await pump(tester, organizationEditApp(isAdmin: false)); + + expect(find.text('Administrators only'), findsOneWidget); + expect(find.text('Save changes'), findsNothing); + }); + + testWidgets('shows the stored timezone even though it is one of many', ( + tester, + ) async { + // A picker that cannot display the stored value would either throw or + // quietly submit its first entry, moving the org somewhere it never + // chose. Scrolled explicitly on the form's own list: the text fields + // each carry a Scrollable of their own. + await pump(tester, organizationEditApp(isAdmin: true)); + await tester.scrollUntilVisible( + find.text('Save changes'), + 300, + scrollable: find + .descendant( + of: find.byType(ListView), + matching: find.byType(Scrollable), + ) + .first, + ); + + expect(find.textContaining('Asia/Kolkata'), findsWidgets); + expect(tester.takeException(), isNull); + }); + }); } /// A working week with one holiday, which is what most orgs look like. @@ -2372,3 +2690,98 @@ class _FakeMacros extends MacrosNotifier { brokenCount: 1, ); } + +/// Two live tokens, both used recently enough not to be flagged, and one issued +/// today that has never been used. +AccessTokensState _tokenState({bool orphaned = false}) { + final now = DateTime.now(); + String ago(int days) => now.subtract(Duration(days: days)).toIso8601String(); + + Map row({ + required String id, + required String name, + String? lastUsed, + bool ownerActive = true, + }) => { + 'id': id, + 'name': name, + 'token_prefix': 'bcrm_pat_abc', + 'scopes': const [], + 'expires_at': null, + 'last_used_at': lastUsed, + 'created_at': ago(1), + 'revoked_at': null, + 'is_live': true, + 'owner': { + 'id': 'p1', + 'name': 'Ada Lovelace', + 'role': 'ADMIN', + 'is_active': ownerActive, + }, + }; + + final tokens = [ + AccessToken.fromJson( + row(id: 't1', name: 'Nightly export', lastUsed: ago(2)), + ), + // Never used, issued yesterday: the row that used to read "never used" in + // warning colour the moment it was created. + AccessToken.fromJson( + row(id: 't2', name: 'Just made', ownerActive: !orphaned), + ), + ]; + return AccessTokensState( + tokens: sortedTokens(tokens), + totals: TokenTotals( + count: 2, + live: 2, + orphaned: orphaned ? 1 : 0, + unused90d: 0, + ), + ); +} + +class _FakeTokens extends AccessTokensNotifier { + @override + Future build() async => _tokenState(); +} + +class _FakeOrphanedTokens extends AccessTokensNotifier { + @override + Future build() async => _tokenState(orphaned: true); +} + +/// A configured org, with the cascade switch off, which is the model default. +OrgSettings _org({bool cascade = false}) => OrgSettings.fromJson({ + 'id': 'org-1', + 'name': 'MicroPyramid', + 'company_name': 'MicroPyramid Informatics Pvt Ltd', + 'address_line': '12 Road', + 'city': 'Hyderabad', + 'state': 'Telangana', + 'postcode': '500081', + 'country': 'IN', + 'phone': '+91 40 1234', + 'email': 'hello@example.com', + 'website': 'https://example.com', + 'tax_id': 'GSTIN123', + 'default_currency': 'USD', + 'default_country': 'IN', + 'currency_symbol': '\$', + 'timezone': 'Asia/Kolkata', + 'csat_enabled': true, + 'auto_close_children_on_parent_close': cascade, + 'vertical': '', + 'member_count': 4, + 'created_at': '2026-01-05T10:00:00Z', +}); + +class _FakeOrg extends OrgSettingsNotifier { + @override + Future build() async => _org(); +} + +class _FakeOrgCascadeOn extends OrgSettingsNotifier { + @override + Future build() async => _org(cascade: true); +} diff --git a/mobile/test/unit/access_token_test.dart b/mobile/test/unit/access_token_test.dart new file mode 100644 index 00000000..14b1d731 --- /dev/null +++ b/mobile/test/unit/access_token_test.dart @@ -0,0 +1,313 @@ +import 'package:bottle_crm/data/models/access_token.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final DateTime now = DateTime.utc(2026, 8, 8, 12); + +String ago(int days) => now.subtract(Duration(days: days)).toIso8601String(); + +/// A token as `/api/org/tokens/` returns one: the safe field set, an owner +/// block, and the derived `is_live`. +Map tokenJson({ + String id = 't1', + String name = 'Nightly export', + List scopes = const [], + String? lastUsedAt, + String? createdAt, + String? expiresAt, + String? revokedAt, + bool isLive = true, + String ownerName = 'Ada Lovelace', + String ownerRole = 'ADMIN', + bool ownerActive = true, +}) => { + 'id': id, + 'name': name, + 'token_prefix': 'bcrm_pat_abc', + 'scopes': scopes, + 'expires_at': expiresAt, + 'last_used_at': lastUsedAt, + 'created_at': createdAt ?? ago(1), + 'revoked_at': revokedAt, + 'is_live': isLive, + 'owner': { + 'id': 'p1', + 'name': ownerName, + 'role': ownerRole, + 'is_active': ownerActive, + }, +}; + +AccessToken build([Map? json]) => + AccessToken.fromJson(json ?? tokenJson()); + +void main() { + group('AccessToken.fromJson', () { + test('reads the safe field set and the owner block', () { + final token = build( + tokenJson(scopes: const ['*:read'], ownerRole: 'USER'), + ); + expect(token.name, 'Nightly export'); + expect(token.tokenPrefix, 'bcrm_pat_abc'); + expect(token.scopes, ['*:read']); + expect(token.owner.name, 'Ada Lovelace'); + expect(token.owner.roleLabel, 'Member'); + }); + + test('a row missing its owner block parses rather than throwing', () { + final json = tokenJson()..remove('owner'); + expect(AccessToken.fromJson(json).owner.name, 'Unknown'); + }); + + test('never carries a raw value, because none is ever sent', () { + // The list serializer has no `token` field: the server keeps a hash. + // Nothing on this model could hold one even if a body carried it. + final json = tokenJson()..['token'] = 'bcrm_pat_leaked'; + final token = AccessToken.fromJson(json); + expect(token.tokenPrefix, 'bcrm_pat_abc'); + expect(token.scopeSummary, isNot(contains('leaked'))); + }); + }); + + group('statusLabel', () { + test('names revoked and expired apart', () { + expect( + build(tokenJson(revokedAt: ago(1), isLive: false)).statusLabel, + 'Revoked', + ); + expect(build(tokenJson(isLive: false)).statusLabel, 'Expired'); + }); + + test('is Live otherwise', () { + expect(build().statusLabel, 'Live'); + }); + }); + + group('stalenessNote', () { + test('says nothing about a token issued today and not yet used', () { + // The finding: a null last_used_at is not "long ago". A token created a + // minute ago has to read as new on the row AND in the count. + expect(build().stalenessNote(now), isNull); + }); + + test('flags a never-used token once it is old enough, and dates it', () { + final token = build(tokenJson(createdAt: ago(120))); + expect(token.stalenessNote(now), 'never used, issued 120 days ago'); + }); + + test('flags a token last used more than 90 days ago', () { + final token = build(tokenJson(lastUsedAt: ago(120))); + expect(token.stalenessNote(now), 'unused for 120 days'); + }); + + test('says nothing at exactly 90 days, matching the backend > 90', () { + expect(build(tokenJson(lastUsedAt: ago(90))).stalenessNote(now), isNull); + expect( + build(tokenJson(lastUsedAt: ago(91))).stalenessNote(now), + 'unused for 91 days', + ); + }); + + test('says nothing about a token that is no longer live', () { + final token = build(tokenJson(isLive: false, createdAt: ago(400))); + expect(token.stalenessNote(now), isNull); + }); + }); + + group('scopeSummary', () { + test('an empty list is unrestricted, said in the owner name', () { + // Empty means unrestricted server-side, which is what every token issued + // before enforcement carries. Drawing those as limited would be a lie. + expect(build().scopeSummary, 'Everything Ada can'); + }); + + test('falls back to a name-free phrasing without an owner', () { + final json = tokenJson()..remove('owner'); + expect(AccessToken.fromJson(json).scopeSummary, 'Everything Unknown can'); + }); + + test('an all-read list is read only', () { + expect( + build(tokenJson(scopes: const ['*:read'])).scopeSummary, + 'Read only', + ); + expect( + build( + tokenJson(scopes: const ['leads:read', 'cases:read']), + ).scopeSummary, + 'Read only', + ); + }); + + test('anything narrower is listed verbatim', () { + expect( + build( + tokenJson(scopes: const ['leads:read', 'cases:write']), + ).scopeSummary, + 'leads:read, cases:write', + ); + }); + }); + + group('isOrphaned', () { + test('is a live token on a deactivated account', () { + expect(build(tokenJson(ownerActive: false)).isOrphaned, isTrue); + }); + + test('is not a revoked token on a deactivated account', () { + expect( + build(tokenJson(ownerActive: false, isLive: false)).isOrphaned, + isFalse, + ); + }); + + test('is not a live token on an active account', () { + expect(build().isOrphaned, isFalse); + }); + }); + + group('sortedTokens', () { + test('live rows come before dormant ones', () { + final tokens = [ + build(tokenJson(id: 'dead', isLive: false, lastUsedAt: ago(1))), + build(tokenJson(id: 'alive', lastUsedAt: ago(30))), + ]; + expect(sortedTokens(tokens).first.id, 'alive'); + }); + + test('most recently used first inside the live rows', () { + final tokens = [ + build(tokenJson(id: 'old', lastUsedAt: ago(30))), + build(tokenJson(id: 'recent', lastUsedAt: ago(1))), + ]; + expect(sortedTokens(tokens).map((t) => t.id).toList(), ['recent', 'old']); + }); + + test('does not mutate the list it was given', () { + final tokens = [ + build(tokenJson(id: 'dead', isLive: false)), + build(tokenJson(id: 'alive')), + ]; + sortedTokens(tokens); + expect(tokens.first.id, 'dead'); + }); + }); + + group('orphanedTokenIds', () { + test('is only the live rows on deactivated accounts', () { + final tokens = [ + build(tokenJson(id: 'a', ownerActive: false)), + build(tokenJson(id: 'b', ownerActive: false, isLive: false)), + build(tokenJson(id: 'c')), + ]; + expect(orphanedTokenIds(tokens), ['a']); + }); + }); + + group('TokenTotals', () { + test('reads the four figures', () { + final totals = TokenTotals.fromJson(const { + 'count': 5, + 'live': 3, + 'orphaned': 1, + 'unused_90d': 2, + }); + expect(totals.count, 5); + expect(totals.live, 3); + expect(totals.orphaned, 1); + expect(totals.unused90d, 2); + }); + + test('a missing block reads as zeros rather than throwing', () { + final totals = TokenTotals.fromJson(const {}); + expect(totals.count, 0); + expect(totals.unused90d, 0); + }); + }); + + group('expiryFromChoice', () { + test('counts the days the label promises', () { + final iso = expiryFromChoice('30', now)!; + expect(DateTime.parse(iso).difference(now).inDays, 30); + }); + + test( + 'every offered choice lands in the future, which the API requires', + () { + for (final choice in expiryChoices.where((c) => c.days != null)) { + final iso = expiryFromChoice(choice.value, now)!; + expect( + DateTime.parse(iso).isAfter(now), + isTrue, + reason: choice.value, + ); + } + }, + ); + + test('is null for never, and for anything unrecognised', () { + expect(expiryFromChoice('never', now), isNull); + expect(expiryFromChoice('tuesday', now), isNull); + expect(expiryFromChoice(null, now), isNull); + }); + + test('offers 90 days first, so the default is an expiry not never', () { + expect(expiryChoices.first.value, '90'); + }); + }); + + group('scopesFromChoice', () { + test('read means a read-only wildcard', () { + expect(scopesFromChoice('read'), ['*:read']); + }); + + test('anything else means unrestricted, the empty list', () { + expect(scopesFromChoice('full'), isEmpty); + expect(scopesFromChoice(null), isEmpty); + }); + }); + + group('tokenCreatePayload', () { + test('sends name, scopes and expiry, and nothing else', () { + final body = tokenCreatePayload( + name: ' Nightly export ', + expiryChoice: '90', + accessChoice: 'read', + now: now, + ); + expect(body.keys.toSet(), {'name', 'scopes', 'expires_at'}); + expect(body['name'], 'Nightly export'); + expect(body['scopes'], ['*:read']); + }); + + test('never carries an owner, because the server sets it', () { + // A "create on behalf of" field is the one thing this endpoint must not + // accept, and the surest way not to send it is not to have it. + final body = tokenCreatePayload( + name: 'x', + expiryChoice: 'never', + accessChoice: 'full', + now: now, + ); + expect(body.containsKey('profile'), isFalse); + expect(body.containsKey('owner'), isFalse); + expect(body.containsKey('org'), isFalse); + expect(body['expires_at'], isNull); + }); + }); + + group('tokenNameProblem', () { + test('accepts an ordinary name', () { + expect(tokenNameProblem('Nightly export'), isNull); + }); + + test('refuses an empty or blank name, which the serializer refuses', () { + expect(tokenNameProblem(''), isNotNull); + expect(tokenNameProblem(' '), isNotNull); + }); + + test('refuses more than 255 characters', () { + expect(tokenNameProblem('x' * 256), isNotNull); + expect(tokenNameProblem('x' * 255), isNull); + }); + }); +} diff --git a/mobile/test/unit/org_settings_test.dart b/mobile/test/unit/org_settings_test.dart new file mode 100644 index 00000000..5d8f9a56 --- /dev/null +++ b/mobile/test/unit/org_settings_test.dart @@ -0,0 +1,350 @@ +import 'package:bottle_crm/data/models/org_settings.dart'; +import 'package:bottle_crm/data/models/ticket.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// The org as `OrgSettingsSerializer` sends it. +Map orgJson({ + String name = 'MicroPyramid', + String companyName = 'MicroPyramid Informatics Pvt Ltd', + String country = 'IN', + String defaultCountry = 'IN', + String currency = 'USD', + String timezone = 'Asia/Kolkata', + bool csat = true, + bool cascade = false, + String vertical = '', +}) => { + 'id': 'org-1', + 'name': name, + 'company_name': companyName, + 'address_line': '12 Road', + 'city': 'Hyderabad', + 'state': 'Telangana', + 'postcode': '500081', + 'country': country, + 'phone': '+91 40 1234', + 'email': 'hello@example.com', + 'website': 'https://example.com', + 'tax_id': 'GSTIN123', + 'default_currency': currency, + 'default_country': defaultCountry, + 'currency_symbol': '\$', + 'timezone': timezone, + 'csat_enabled': csat, + 'auto_close_children_on_parent_close': cascade, + 'vertical': vertical, + 'terminology': const {}, + 'member_count': 4, + 'created_at': '2026-01-05T10:00:00Z', +}; + +void main() { + group('OrgSettings.fromJson', () { + test('reads the company profile and the two switches', () { + final org = OrgSettings.fromJson(orgJson(csat: false, cascade: true)); + expect(org.companyName, 'MicroPyramid Informatics Pvt Ltd'); + expect(org.taxId, 'GSTIN123'); + expect(org.csatEnabled, isFalse); + expect(org.autoCloseChildren, isTrue); + expect(org.memberCount, 4); + }); + + test('a null text field reads as empty rather than the string "null"', () { + final json = orgJson()..['tax_id'] = null; + expect(OrgSettings.fromJson(json).taxId, ''); + }); + + test('an org with no api_key on the payload is the normal case', () { + // The key authenticates as the org's first admin, so it is excluded from + // the serializer entirely. Nothing on this model could hold one. + final org = OrgSettings.fromJson(orgJson()); + expect(org.id, 'org-1'); + expect(org.addressSummary, contains('Hyderabad')); + }); + + test('defaults are sane when the payload is empty', () { + final org = OrgSettings.fromJson(const {}); + expect(org.defaultCurrency, 'USD'); + expect(org.timezone, 'UTC'); + expect(org.csatEnabled, isTrue); + expect(org.autoCloseChildren, isFalse); + }); + }); + + group('addressSummary', () { + test('joins what is set, in printing order', () { + expect( + OrgSettings.fromJson(orgJson()).addressSummary, + '12 Road, Hyderabad, Telangana, 500081, IN', + ); + }); + + test('drops the parts that are blank rather than leaving gaps', () { + final json = orgJson() + ..['city'] = '' + ..['state'] = null; + expect(OrgSettings.fromJson(json).addressSummary, '12 Road, 500081, IN'); + }); + }); + + group('timezoneLabel', () { + test('opens the underscores out for reading', () { + final json = orgJson(timezone: 'America/New_York'); + expect(OrgSettings.fromJson(json).timezoneLabel, 'America/New York'); + }); + }); + + group('orgSettingsPayload', () { + final body = orgSettingsPayload( + name: ' MicroPyramid ', + companyName: 'MicroPyramid Informatics', + addressLine: '12 Road', + city: 'Hyderabad', + state: 'Telangana', + postcode: '500081', + country: 'IN', + phone: '+91 40 1234', + email: ' hello@example.com ', + website: 'https://example.com', + taxId: 'GSTIN123', + defaultCurrency: 'INR', + defaultCountry: 'IN', + timezone: 'Asia/Kolkata', + csatEnabled: false, + autoCloseChildren: true, + ); + + test('trims every text field', () { + expect(body['name'], 'MicroPyramid'); + expect(body['email'], 'hello@example.com'); + }); + + test('sends both booleans explicitly, so off is a real value', () { + expect(body['csat_enabled'], false); + expect(body['auto_close_children_on_parent_close'], true); + }); + + test('never sends anything the server owns', () { + for (final key in [ + 'id', + 'api_key', + 'is_active', + 'vertical', + 'terminology', + 'member_count', + 'created_at', + 'currency_symbol', + ]) { + expect(body.containsKey(key), isFalse, reason: key); + } + }); + + test('covers exactly the allow-list the web sends', () { + expect(body.keys.toSet(), {...editableOrgFields, ...orgBooleanFields}); + }); + }); + + group('orgSettingsProblem', () { + test('accepts an ordinary pair', () { + expect( + orgSettingsProblem( + email: 'hello@example.com', + website: 'https://example.com', + ), + isNull, + ); + }); + + test('accepts both being empty, which the model allows', () { + expect(orgSettingsProblem(email: '', website: ''), isNull); + }); + + test('refuses an address that is not one', () { + expect( + orgSettingsProblem(email: 'hello', website: ''), + contains('email'), + ); + }); + + test('refuses a website without a scheme, which URLField rejects', () { + expect( + orgSettingsProblem(email: '', website: 'example.com'), + contains('http'), + ); + }); + }); + + group('withCurrent', () { + const options = [ + (value: 'US', label: 'United States'), + (value: 'IN', label: 'India'), + ]; + + test('leaves the list alone when the stored value is offered', () { + expect(withCurrent(options, 'IN'), options); + }); + + test('appends a stored value the short list does not carry', () { + // The offered list is a convenience subset of what the backend accepts. + // Dropping a stored value would make the picker save one the org never + // chose, and a Flutter dropdown with no matching item throws outright. + final out = withCurrent(options, 'NZ'); + expect(out.last.value, 'NZ'); + expect(out.length, 3); + }); + + test('an empty current value adds nothing', () { + expect(withCurrent(options, ''), options); + expect(withCurrent(options, ' '), options); + }); + }); + + group('TimezoneOption', () { + test('labels the offset in hours and minutes', () { + const zone = TimezoneOption(name: 'Asia/Kolkata', offsetMinutes: 330); + expect(zone.label, 'Asia/Kolkata (UTC+05:30)'); + }); + + test('a negative offset reads as negative', () { + const zone = TimezoneOption( + name: 'America/New_York', + offsetMinutes: -240, + ); + expect(zone.label, 'America/New York (UTC-04:00)'); + }); + + test('zero is plain UTC, not UTC+00:00', () { + const zone = TimezoneOption(name: 'UTC', offsetMinutes: 0); + expect(zone.label, 'UTC (UTC)'); + }); + }); + + group('cascadeDefaultExplanation', () { + test('says the prompt starts ticked when the setting is on', () { + expect(cascadeDefaultExplanation(true), contains('starts with')); + expect(cascadeDefaultExplanation(true), contains('ticked')); + expect(cascadeDefaultExplanation(true), contains('still confirm')); + }); + + test('says unticked when it is off', () { + expect(cascadeDefaultExplanation(false), contains('unticked')); + }); + + test('never claims the setting closes children on its own', () { + // `CaseCloseWithChildrenView` honours the org default only when the + // caller omits `cascade`, and both clients send it. The setting reaches + // a person through the checkbox and nowhere else. + for (final enabled in [true, false]) { + final text = cascadeDefaultExplanation(enabled); + expect(text, isNot(contains('automatically'))); + } + }); + }); + + group('csatExplanation', () { + test('names the org-wide reach either way', () { + expect(csatExplanation(true), contains('org-wide')); + expect(csatExplanation(false), contains('anywhere in this organization')); + }); + }); + + group('PackApplyReport', () { + test('counts created and skipped from the report block', () { + final report = PackApplyReport.fromJson(const { + 'report': { + 'created': [ + {'name': 'a'}, + {'name': 'b'}, + ], + 'skipped': [ + {'name': 'c'}, + ], + }, + }); + expect(report.created, 2); + expect(report.skipped, 1); + expect(report.summary, 'Created 2 items, skipped 1 you already had.'); + }); + + test('all-skipped reads as already having it, not as a failure', () { + final report = PackApplyReport.fromJson(const { + 'report': { + 'created': [], + 'skipped': [ + {'name': 'c'}, + ], + }, + }); + expect(report.summary, contains('Already had everything')); + }); + + test('an empty report says so rather than claiming a create', () { + final report = PackApplyReport.fromJson(const {'report': {}}); + expect(report.summary, contains('already has all of it')); + }); + }); + + group('sampleDataResult', () { + test('reports the deletion', () { + expect( + sampleDataResult(deleted: 3, retained: 0), + 'Deleted 3 sample records.', + ); + }); + + test('explains what was kept, so the number is not a surprise', () { + final text = sampleDataResult(deleted: 3, retained: 2); + expect(text, contains('Kept 2 records')); + expect(text, contains('attached real work')); + }); + + test('says nothing to clear rather than "deleted 0"', () { + expect( + sampleDataResult(deleted: 0, retained: 0), + 'No sample data to clear.', + ); + }); + }); + + group('cascadeCloseMessage', () { + test('counts what the server actually cascaded', () { + expect( + cascadeCloseMessage(cascade: true, cascaded: 3), + 'Ticket closed, and 3 linked tickets with it', + ); + expect( + cascadeCloseMessage(cascade: true, cascaded: 1), + 'Ticket closed, and 1 linked ticket with it', + ); + }); + + test('says nothing else changed when nothing was open', () { + // The old message reported `child_count`, so a parent whose children were + // all closed already announced "and 3 linked" while nothing happened. + expect( + cascadeCloseMessage(cascade: true, cascaded: 0), + contains('Nothing linked was open'), + ); + }); + + test('an unticked cascade says only that the ticket closed', () { + expect(cascadeCloseMessage(cascade: false, cascaded: 0), 'Ticket closed'); + }); + }); + + group('cascadedCount', () { + test('counts the ids the endpoint returned', () { + expect( + cascadedCount(const { + 'cascaded_case_ids': ['a', 'b'], + }), + 2, + ); + }); + + test('a body without the key means none, not unknown', () { + expect(cascadedCount(const {}), 0); + expect(cascadedCount(null), 0); + }); + }); +}