feat: Implement close-with-children dialog and related settings
- Added `close_with_children_dialog.dart` to handle the close ticket prompt with linked tickets. - Updated `_closeWithChildren` method in `ticket_detail_screen.dart` to utilize the new dialog and respect organization settings for default checkbox state. - Enhanced ticket closing feedback messages based on whether linked tickets were also closed. - Introduced unit tests for `AccessToken` and `OrgSettings` to ensure proper serialization and functionality. - Added tests for the new close-with-children dialog to verify UI behavior and settings integration. - Updated existing tests to cover new functionality and ensure no regressions.
This commit is contained in:
parent
6bb00db95f
commit
3a4ef40ba5
25 changed files with 4354 additions and 127 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 `<resource>:<action>` 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 = {
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if data.forbidden}
|
||||
|
|
@ -233,10 +216,9 @@
|
|||
Expires
|
||||
</label>
|
||||
<select id="token-expiry" name="expiry" class="v2-input" style="width:150px">
|
||||
<option value="90" selected>In 90 days</option>
|
||||
<option value="30">In 30 days</option>
|
||||
<option value="365">In 1 year</option>
|
||||
<option value="never">Never</option>
|
||||
{#each EXPIRY_CHOICES as choice (choice.value)}
|
||||
<option value={choice.value}>{choice.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<button class="v2-btn v2-btn-primary" disabled={busy}>Create token</button>
|
||||
|
|
@ -287,7 +269,7 @@
|
|||
</thead>
|
||||
<tbody>
|
||||
{#each data.tokens as t (t.id)}
|
||||
{@const s = statusOf(t)}
|
||||
{@const s = tokenStatus(t)}
|
||||
{@const stale = staleness(t)}
|
||||
{@const owner = t.owner ?? {}}
|
||||
<tr style={t.is_live ? '' : 'opacity:.55'}>
|
||||
|
|
@ -308,15 +290,7 @@
|
|||
</span>
|
||||
</td>
|
||||
<td data-m="hide">
|
||||
<span class="v2-sub" style="font-size:12px">
|
||||
{#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}
|
||||
</span>
|
||||
<span class="v2-sub" style="font-size:12px">{scopeSummary(t)}</span>
|
||||
</td>
|
||||
<td data-m="hide">
|
||||
{#if t.last_used_at}
|
||||
|
|
|
|||
129
frontend/src/routes/(app)/settings/api-tokens/oversight.js
Normal file
129
frontend/src/routes/(app)/settings/api-tokens/oversight.js
Normal file
|
|
@ -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'] : [];
|
||||
}
|
||||
152
frontend/src/routes/(app)/settings/api-tokens/oversight.test.js
Normal file
152
frontend/src/routes/(app)/settings/api-tokens/oversight.test.js
Normal file
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -207,11 +207,16 @@
|
|||
<div class="v2-setting">
|
||||
<div class="v2-setting-body">
|
||||
<b>Close child tickets with the parent</b>
|
||||
<!-- Only the DEFAULT state of the prompt; the endpoint still
|
||||
requires explicit confirmation either way. -->
|
||||
<!-- Only the DEFAULT state of the prompt; the person closing the
|
||||
ticket still confirms. This used to claim the prompt without
|
||||
saying where it is, and there is no such prompt on the web:
|
||||
`close-with-children` has no caller in `frontend/src`, so
|
||||
closing a parent here leaves its children open. The phone is
|
||||
the client that asks, and the one this setting reaches. -->
|
||||
<span class="v2-sub" style="font-size:11.5px">
|
||||
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.
|
||||
</span>
|
||||
</div>
|
||||
<Pill tone={org.auto_close_children_on_parent_close ? 'clay' : 'slate'}>
|
||||
|
|
|
|||
|
|
@ -167,7 +167,11 @@
|
|||
>
|
||||
<TriangleAlert size={17} style="color:var(--v2-rust);flex:none" />
|
||||
<div class="v2-next-body">
|
||||
<div style="font-weight:600">Two fields to check</div>
|
||||
<!-- Counted, not assumed. Only two fields are checked here, but one
|
||||
of them failing on its own is the common case. -->
|
||||
<div style="font-weight:600">
|
||||
{Object.keys(errors).length === 1 ? 'One field to check' : 'Two fields to check'}
|
||||
</div>
|
||||
<div class="v2-sub" style="margin-top:2px">Nothing has been saved yet.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -369,8 +373,9 @@
|
|||
<option value="false">Offer it off. The close prompt starts unticked</option>
|
||||
</select>
|
||||
<p class="v2-hint">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ==========================================================================
|
||||
|
|
|
|||
289
mobile/lib/data/models/access_token.dart
Normal file
289
mobile/lib/data/models/access_token.dart
Normal file
|
|
@ -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<String, dynamic> 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;
|
||||
|
||||
/// `<resource>:<action>` 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<String> 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<String, dynamic> json) => AccessToken(
|
||||
id: json['id']?.toString() ?? '',
|
||||
name: json['name']?.toString() ?? '',
|
||||
tokenPrefix: json['token_prefix']?.toString() ?? '',
|
||||
owner: TokenOwner.fromJson(
|
||||
(json['owner'] as Map?)?.cast<String, dynamic>() ?? 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<String, dynamic> 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<AccessToken> sortedTokens(List<AccessToken> 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<String> orphanedTokenIds(List<AccessToken> 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<ExpiryChoice> 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<String> 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<String, dynamic> 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.';
|
||||
393
mobile/lib/data/models/org_settings.dart
Normal file
393
mobile/lib/data/models/org_settings.dart
Normal file
|
|
@ -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<String> 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<String> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<PickerOption> 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<PickerOption> withCurrent(List<PickerOption> 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<String, dynamic> 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<String, dynamic> json) {
|
||||
final report = (json['report'] as Map?)?.cast<String, dynamic>() ?? 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.';
|
||||
}
|
||||
|
|
@ -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<String, dynamic>? body) =>
|
||||
(body?['cascaded_case_ids'] as List?)?.length ?? 0;
|
||||
|
|
|
|||
|
|
@ -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, ReopenPolicy>(
|
||||
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<AccessToken> 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<String> get orphanedIds => orphanedTokenIds(tokens);
|
||||
}
|
||||
|
||||
class AccessTokensNotifier extends AsyncNotifier<AccessTokensState> {
|
||||
final ApiService _api = ApiService();
|
||||
|
||||
@override
|
||||
Future<AccessTokensState> build() => _fetch();
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(_fetch);
|
||||
}
|
||||
|
||||
Future<AccessTokensState> _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<String, dynamic>() ?? 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<String?> 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, AccessTokensState>(
|
||||
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<OrgSettings> {
|
||||
final ApiService _api = ApiService();
|
||||
|
||||
@override
|
||||
Future<OrgSettings> build() => _fetch();
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(_fetch);
|
||||
}
|
||||
|
||||
Future<OrgSettings> _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<String?> save(Map<String, dynamic> 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, OrgSettings>(
|
||||
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<List<VerticalPack>>((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<List<TimezoneOption>>((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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<GoRouter>((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',
|
||||
|
|
|
|||
179
mobile/lib/screens/settings/api_token_form_sheet.dart
Normal file
179
mobile/lib/screens/settings/api_token_form_sheet.dart
Normal file
|
|
@ -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<TokenDraft?> showApiTokenFormSheet(BuildContext context) {
|
||||
return showModalBottomSheet<TokenDraft>(
|
||||
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<String>(
|
||||
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<String>(
|
||||
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'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
631
mobile/lib/screens/settings/api_tokens_screen.dart
Normal file
631
mobile/lib/screens/settings/api_tokens_screen.dart
Normal file
|
|
@ -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<void> _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<void> _reveal(
|
||||
BuildContext context, {
|
||||
required String name,
|
||||
required String value,
|
||||
}) {
|
||||
return showDialog<void>(
|
||||
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<void> _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<void> _revoke(BuildContext context, WidgetRef ref) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
381
mobile/lib/screens/settings/organization_edit_screen.dart
Normal file
381
mobile/lib/screens/settings/organization_edit_screen.dart
Normal file
|
|
@ -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<String, TextEditingController> _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<void> _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 <TimezoneOption>[])
|
||||
(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<PickerOption> options,
|
||||
required ValueChanged<String> onChanged,
|
||||
String? helper,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: DropdownButtonFormField<String>(
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
574
mobile/lib/screens/settings/organization_screen.dart
Normal file
574
mobile/lib/screens/settings/organization_screen.dart
Normal file
|
|
@ -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<void> _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<bool>(
|
||||
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<void> _clearSampleData() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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 <VerticalPack>[];
|
||||
// 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')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
74
mobile/lib/screens/tickets/close_with_children_dialog.dart
Normal file
74
mobile/lib/screens/tickets/close_with_children_dialog.dart
Normal file
|
|
@ -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<bool>(
|
||||
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);
|
||||
}
|
||||
|
|
@ -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<TicketDetailScreen>
|
|||
}
|
||||
|
||||
Future<void> _closeWithChildren(Ticket c) async {
|
||||
bool cascade = true;
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<TicketDetailScreen>
|
|||
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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<String>).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<void> 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<Checkbox>(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<Checkbox>(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<Checkbox>(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<String, dynamic> row({
|
||||
required String id,
|
||||
required String name,
|
||||
String? lastUsed,
|
||||
bool ownerActive = true,
|
||||
}) => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'token_prefix': 'bcrm_pat_abc',
|
||||
'scopes': const <String>[],
|
||||
'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<AccessTokensState> build() async => _tokenState();
|
||||
}
|
||||
|
||||
class _FakeOrphanedTokens extends AccessTokensNotifier {
|
||||
@override
|
||||
Future<AccessTokensState> 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<OrgSettings> build() async => _org();
|
||||
}
|
||||
|
||||
class _FakeOrgCascadeOn extends OrgSettingsNotifier {
|
||||
@override
|
||||
Future<OrgSettings> build() async => _org(cascade: true);
|
||||
}
|
||||
|
|
|
|||
313
mobile/test/unit/access_token_test.dart
Normal file
313
mobile/test/unit/access_token_test.dart
Normal file
|
|
@ -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<String, dynamic> tokenJson({
|
||||
String id = 't1',
|
||||
String name = 'Nightly export',
|
||||
List<String> 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<String, dynamic>? 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
350
mobile/test/unit/org_settings_test.dart
Normal file
350
mobile/test/unit/org_settings_test.dart
Normal file
|
|
@ -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<String, dynamic> 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue