@7365admin1/layer-common 4.70.0 → 4.71.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.71.0
4
+
5
+ ### Minor Changes
6
+
7
+ - fe27542: Members list and Members → Service Providers: a "Last active" column (last sign-in: "Today, 9:05 AM", "Yesterday, …", or the date; "—" until the person next signs in).
8
+
9
+ ## 4.70.1
10
+
11
+ ### Patch Changes
12
+
13
+ - ba6869d: Create Role / Edit Role: a refused or failed save now shows "Couldn't save: <reason>" above the buttons (it was printed below the whole module list, out of sight). Catalogue snapshot test ties the dialog catalogues to core's full-catalogue create test.
14
+
3
15
  ## 4.70.0
4
16
 
5
17
  ### Minor Changes
@@ -1849,11 +1861,11 @@
1849
1861
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1850
1862
  own rule from two endpoints the console already calls, unprojected:
1851
1863
 
1852
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1853
- GET /api/roles/id/:role that membership's role document
1864
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1865
+ GET /api/roles/id/:role that membership's role document
1854
1866
 
1855
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1856
- staff = member.type === "admin" && role.type === "admin"
1867
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1868
+ staff = member.type === "admin" && role.type === "admin"
1857
1869
 
1858
1870
  `role.default` is the marker because it is the only property of a platform
1859
1871
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -113,6 +113,9 @@
113
113
  <template #item.dateInvited="{ item }">
114
114
  {{ formatDateInvited(item.dateInvited) }}
115
115
  </template>
116
+ <template #item.lastLoginAt="{ item }">
117
+ <span data-test="member-last-active">{{ formatLastActive(item.lastLoginAt) }}</span>
118
+ </template>
116
119
  <template #item.action-table="{ item }">
117
120
  <!--
118
121
  `props.assignRole` belongs in this test. The menu's only item that
@@ -331,6 +334,7 @@ import {
331
334
  safeReturnTo,
332
335
  withCurrentRole,
333
336
  } from "../utils/role";
337
+ import { formatLastActive } from "../utils/last-active";
334
338
 
335
339
  const props = defineProps({
336
340
  /** The design's page title. Every app that mounts this screen calls it
@@ -415,6 +419,10 @@ const props = defineProps({
415
419
 
416
420
  value: "dateInvited",
417
421
  },
422
+ {
423
+ title: "Last active",
424
+ value: "lastLoginAt",
425
+ },
418
426
  {
419
427
  title: "Action",
420
428
  value: "action-table",
@@ -111,6 +111,9 @@
111
111
  <template #item.addedAt="{ item }">
112
112
  {{ spDay(item.addedAt) || "-" }}
113
113
  </template>
114
+ <template #item.lastLoginAt="{ item }">
115
+ <span data-test="sp-last-active">{{ formatLastActive(item.lastLoginAt) }}</span>
116
+ </template>
114
117
  <template #item.status="{ item }">
115
118
  <StatusChip
116
119
  :tone="item.status === 'active' ? 'ok' : 'neutral'"
@@ -155,6 +158,7 @@ import {
155
158
  serviceTypeOptions,
156
159
  } from "../utils/site-provider-members";
157
160
  import { spDay } from "../utils/sp-inviter-row";
161
+ import { formatLastActive } from "../utils/last-active";
158
162
  import { saveBlob } from "../composables/useInventory";
159
163
 
160
164
  const props = defineProps({
@@ -195,6 +199,7 @@ const headers = [
195
199
  { title: "Service", key: "typeLabel", sortable: true },
196
200
  { title: "Role at this site", key: "role", sortable: true },
197
201
  { title: "Added", key: "addedAt", sortable: true },
202
+ { title: "Last active", key: "lastLoginAt", sortable: true },
198
203
  { title: "Status", key: "status", sortable: true },
199
204
  { title: "", key: "actions", sortable: false, align: "end" as const },
200
205
  ];
@@ -216,17 +216,21 @@
216
216
  </div>
217
217
  </v-col>
218
218
 
219
- <v-col cols="12" class="my-2">
220
- <v-row no-gutters>
221
- <v-col cols="12" class="text-center">
222
- <span class="role-create__error">{{ message }}</span>
223
- </v-col>
224
- </v-row>
225
- </v-col>
226
219
  </v-row>
227
220
  </v-form>
228
221
  </div>
229
222
 
223
+ <!--
224
+ WHY A SAVE FAILED, where it can be seen (owner, 2026-09-29: Wing Tai pressed
225
+ Submit on a 35-module role and "nothing happened"). The server's answer
226
+ used to be printed at the END of the scrolling list, below every module,
227
+ so a refused save looked like an ignored click. It now sits above the
228
+ buttons, outside the scroll, and is read out by screen readers.
229
+ -->
230
+ <div v-if="message" class="role-create__save-error" role="alert">
231
+ Couldn't save: {{ message }}
232
+ </div>
233
+
230
234
  <div class="role-create__footer">
231
235
  <AppButton
232
236
  v-if="disabledModuleCount > 0 || !showDisabledModules"
@@ -257,6 +261,11 @@ import {
257
261
  import useRole from "../composables/useRole";
258
262
  import useUtils from "../composables/useUtils";
259
263
  import useSite from "../composables/useSite";
264
+ import { readApiError } from "../utils/subscription-form";
265
+
266
+ /** Shown after "Couldn't save:" when the server gave no reason (network, timeout). */
267
+ const SAVE_FALLBACK =
268
+ "the server could not be reached. Please check your connection and try again.";
260
269
 
261
270
  const props = defineProps({
262
271
  permissions: {
@@ -487,6 +496,7 @@ watch(
487
496
 
488
497
  async function submit() {
489
498
  disable.value = true;
499
+ message.value = "";
490
500
  try {
491
501
  await createRole({
492
502
  name: name.value,
@@ -500,9 +510,7 @@ async function submit() {
500
510
  // Same unguarded chain as the preview dialog had: on a network or timeout
501
511
  // failure `error.response` is undefined and the catch threw inside itself,
502
512
  // leaving the user with no message at all.
503
- message.value =
504
- error?.response?._data?.message ??
505
- "Could not create this role. Please check your connection and try again.";
513
+ message.value = readApiError(error, SAVE_FALLBACK);
506
514
  } finally {
507
515
  disable.value = false;
508
516
  }
@@ -703,10 +711,15 @@ const isAllSelected = computed(() => {
703
711
  color: var(--text2);
704
712
  }
705
713
 
706
- .role-create__error {
714
+ .role-create__save-error {
715
+ margin: 0 16px;
716
+ padding: 8px 12px;
717
+ border-radius: 6px;
707
718
  font-size: var(--fs-cell);
708
719
  font-weight: 600;
709
720
  color: var(--err);
721
+ background: var(--err-bg);
722
+ border: 1px solid var(--err-border, transparent);
710
723
  }
711
724
 
712
725
  /* The design's dialog footer. Deliberately not the shared
@@ -216,22 +216,23 @@
216
216
  </div>
217
217
  </v-col>
218
218
 
219
- <!--
220
- One message, once. This block used to appear twice -- inside
221
- `v-if="edit"` and again unconditionally -- so a refused save printed
222
- the server's sentence back to back.
223
- -->
224
- <v-col v-if="message" cols="12" class="my-2">
225
- <v-row no-gutters>
226
- <v-col cols="12" class="text-center">
227
- <span class="role-perm__error">{{ message }}</span>
228
- </v-col>
229
- </v-row>
230
- </v-col>
231
219
  </v-row>
232
220
  </v-form>
233
221
  </div>
234
222
 
223
+ <!--
224
+ WHY A SAVE FAILED - or why Submit is switched off - where it can be seen:
225
+ above the buttons, outside the scrolling list (owner, 2026-09-29: a refused
226
+ save printed below 35 modules looked like an ignored click). One message,
227
+ once; read out by screen readers.
228
+ -->
229
+ <div v-if="message" class="role-perm__save-error" role="alert">
230
+ Couldn't save: {{ message }}
231
+ </div>
232
+ <div v-else-if="edit && errorMessages" class="role-perm__save-error" role="alert">
233
+ {{ errorMessages }}
234
+ </div>
235
+
235
236
  <div class="role-perm__footer">
236
237
  <AppButton
237
238
  v-if="disabledModuleCount > 0 || showDisabledModules"
@@ -266,6 +267,7 @@ import {
266
267
  } from "../utils/permission-labels";
267
268
  import useRole from "../composables/useRole";
268
269
  import useUtils from "../composables/useUtils";
270
+ import { readApiError } from "../utils/subscription-form";
269
271
  import {
270
272
  holdsPermission,
271
273
  withoutPermission,
@@ -692,8 +694,13 @@ const errorMessages = computed(() => {
692
694
 
693
695
  const { updatePermissionById } = useRole();
694
696
 
697
+ /** Shown after "Couldn't save:" when the server gave no reason (network, timeout). */
698
+ const SAVE_FALLBACK =
699
+ "the server could not be reached. Please check your connection and try again.";
700
+
695
701
  async function submit() {
696
702
  disable.value = true;
703
+ message.value = "";
697
704
  try {
698
705
  await updatePermissionById(props.id, definedModel.value);
699
706
  emit("success");
@@ -701,9 +708,7 @@ async function submit() {
701
708
  // `error.response` is undefined on a network or timeout failure, so the old
702
709
  // unguarded chain threw inside its own catch: nothing was shown, the button
703
710
  // came back enabled, and the save looked like it had simply been ignored.
704
- message.value =
705
- error?.response?._data?.message ??
706
- "Could not save this role. Please check your connection and try again.";
711
+ message.value = readApiError(error, SAVE_FALLBACK);
707
712
  } finally {
708
713
  disable.value = false;
709
714
  }
@@ -779,6 +784,17 @@ async function submit() {
779
784
  color: var(--err);
780
785
  }
781
786
 
787
+ .role-perm__save-error {
788
+ margin: 0 16px;
789
+ padding: 8px 12px;
790
+ border-radius: 6px;
791
+ font-size: var(--fs-cell);
792
+ font-weight: 600;
793
+ color: var(--err);
794
+ background: var(--err-bg);
795
+ border: 1px solid var(--err-border, transparent);
796
+ }
797
+
782
798
  /* The design's dialog footer. Deliberately not the shared
783
799
  `.screen-modal__footer` - that class is on the batch-D branch (#1150), not
784
800
  on this base, and a second copy in `screens.css` would collide with it. */
@@ -11,6 +11,8 @@ export type TSiteProviderMember = {
11
11
  role: string;
12
12
  status: string;
13
13
  addedAt?: string | null;
14
+ /** Last sign-in ("Last active"); absent until the next sign-in. */
15
+ lastLoginAt?: string | null;
14
16
  /** a subcontractor: the provider that brought its company onto the site */
15
17
  invitedVia?: string;
16
18
  };
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "4.70.0",
5
+ "version": "4.71.0",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
@@ -0,0 +1,16 @@
1
+ /**
2
+ * "Last active" on the Members lists: the last time the person signed in
3
+ * (server field `lastLoginAt`). Never signed in since the field existed -> "—".
4
+ */
5
+ export function formatLastActive(value?: string | Date | null, now: Date = new Date()): string {
6
+ if (!value) return "—";
7
+ const date = new Date(value);
8
+ if (Number.isNaN(date.getTime())) return "—";
9
+
10
+ const time = date.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
11
+ const dayStart = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
12
+ const days = Math.round((dayStart(now) - dayStart(date)) / 86400000);
13
+ if (days === 0) return `Today, ${time}`;
14
+ if (days === 1) return `Yesterday, ${time}`;
15
+ return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
16
+ }