@hostlink/nuxt-light 1.76.0 → 1.78.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/README.md CHANGED
@@ -74,6 +74,14 @@ export default defineNuxtConfig({
74
74
  },
75
75
  business: {
76
76
  baseURL: 'https://business.example.com/graphql',
77
+ audience: 'business-api',
78
+ },
79
+ infra: {
80
+ baseURL: 'https://infra.example.com/graphql',
81
+ audience: 'infra-api',
82
+ healthURL: 'https://infra.example.com/health',
83
+ healthTimeout: 3000,
84
+ unavailableMessage: 'Connect to the company network or VPN.',
77
85
  },
78
86
  },
79
87
  },
@@ -94,6 +102,54 @@ clients: {
94
102
  }
95
103
  ```
96
104
 
105
+ Clients with an `audience` obtain a short-lived audience token lazily from the
106
+ default `auth` client. The token is cached by that client, refreshed shortly
107
+ before expiry, and sent as a Bearer token automatically:
108
+
109
+ ```http
110
+ Authorization: Bearer <audience-access-token>
111
+ ```
112
+
113
+ Components and composables do not need to fetch or attach tokens themselves.
114
+ For example, the first request made by this table automatically requests a
115
+ `business-api` token from Auth:
116
+
117
+ ```vue
118
+ <L-Table client="business" model-name="Order" />
119
+
120
+
121
+ ### Client availability and menus
122
+
123
+ When a client has a `healthURL`, nuxt-light checks it on startup, when the
124
+ browser comes back online, and when the tab becomes visible. Menu items can
125
+ declare that they require that client:
126
+
127
+
128
+ ```yaml
129
+ - label: Servers
130
+ icon: sym_o_dns
131
+ to: /Server
132
+ permission: server.list
133
+ requiresClient: infra
134
+ ```
135
+
136
+
137
+ `L-Menu` keeps the authorized item visible, but disables it and shows the
138
+ configured message while the client is checking or unavailable. Applications
139
+ can use the same state for route guards or page notices:
140
+
141
+
142
+ ```ts
143
+ const availability = useLightClientAvailability()
144
+
145
+ if (!availability.isAvailable('infra')) {
146
+ // Show a company-network/VPN notice.
147
+ }
148
+ ```
149
+
150
+ Clients without a `healthURL` remain available and keep the previous behavior.
151
+ ```
152
+
97
153
  ### Backwards-compatible single endpoint
98
154
 
99
155
  The existing `public.apiBase` setting is still supported. When no named
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "light",
3
3
  "configKey": "light",
4
- "version": "1.76.0",
4
+ "version": "1.78.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
@@ -5,7 +5,7 @@ import { useQuasar, setCssVar, getCssVar } from "quasar";
5
5
  import { useI18n } from "vue-i18n";
6
6
  import { ref, computed, reactive, provide, watch, toRaw, onMounted } from "vue";
7
7
  import { useRuntimeConfig } from "nuxt/app";
8
- import { logout } from "@hostlink/light";
8
+ import useLightClient from "../../composables/useLightClient";
9
9
  import { filterMenuItems } from "../../utils/filterMenuItems";
10
10
  const emits = defineEmits(["logout"]);
11
11
  defineProps({
@@ -249,7 +249,7 @@ if (route.fullPath == "/" && my.default_page) {
249
249
  router.push(my.default_page);
250
250
  }
251
251
  const onLogout = async () => {
252
- await logout();
252
+ await useLightClient().auth.logout();
253
253
  emits("logout");
254
254
  };
255
255
  </script>
@@ -2,7 +2,7 @@
2
2
  import { ref, reactive, onMounted, resolveComponent } from "vue";
3
3
  import { useQuasar } from "quasar";
4
4
  import { useHead } from "#imports";
5
- import { getApiClient, changeExpiredPassword, login } from "@hostlink/light";
5
+ import { getApiClient } from "@hostlink/light";
6
6
  const api = getApiClient();
7
7
  import { useI18n } from "vue-i18n";
8
8
  const { t } = useI18n();
@@ -42,7 +42,7 @@ const passwordExpiredProcess = (username, password) => {
42
42
  persistent: true
43
43
  }).onOk(async (newPassword) => {
44
44
  try {
45
- await changeExpiredPassword(username, password, newPassword);
45
+ await api.auth.changeExpiredPassword(username, password, newPassword);
46
46
  $q.notify({
47
47
  message: t("Your password has been changed successfully, please login again"),
48
48
  color: "positive",
@@ -81,7 +81,7 @@ const submit = async () => {
81
81
  if (await form1.value.validate()) {
82
82
  try {
83
83
  loading.value = true;
84
- await login(data.username, data.password, data.code);
84
+ await api.auth.login(data.username, data.password, data.code);
85
85
  emits("login");
86
86
  } catch (e) {
87
87
  data.code = "";
@@ -2,8 +2,18 @@
2
2
  import { useRoute } from "vue-router";
3
3
  import { ref, computed } from "vue";
4
4
  import { useLight } from "#imports";
5
+ import useLightClientAvailability from "../../composables/useLightClientAvailability";
5
6
  const props = defineProps(["value", "dense", "expandAll"]);
6
7
  const light = useLight();
8
+ const availability = useLightClientAvailability();
9
+ const requiredClient = (menu) => typeof menu?.requiresClient === "string" ? menu.requiresClient : void 0;
10
+ const isMenuAvailable = (menu) => availability.isAvailable(requiredClient(menu));
11
+ const availabilityMessage = (menu) => {
12
+ const client = requiredClient(menu);
13
+ if (!client) return "";
14
+ if (availability.isChecking(client)) return "Checking service availability\u2026";
15
+ return availability.message(client);
16
+ };
7
17
  const isShowExpansionItem = (menu) => {
8
18
  if (menu.children && menu.children.length > 0) {
9
19
  return true;
@@ -48,6 +58,7 @@ const hasChildLink = () => {
48
58
  <template v-for="menu in value">
49
59
 
50
60
  <q-expansion-item :default-opened="expandAll || hasLink(menu)" :label="$t(menu.label)" :icon="menu.icon" :dense="dense"
61
+ :disable="!isMenuAvailable(menu)" :caption="!isMenuAvailable(menu) ? availabilityMessage(menu) : undefined"
51
62
  v-if="isShowExpansionItem(menu)" :group="expandAll ? undefined : group">
52
63
  <l-menu class="q-pl-md" :value="menu.children" :dense="dense" :expand-all="expandAll"></l-menu>
53
64
  </q-expansion-item>
@@ -56,20 +67,34 @@ const hasChildLink = () => {
56
67
  <q-separator v-if="menu.type == 'separator'" :spaced="menu.spaced" />
57
68
  <q-item-label header v-if="menu.type == 'header'">{{ menu.label }}</q-item-label>
58
69
  <template v-if="menu.to && (menu.to.startsWith('http://') || menu.to.startsWith('https://'))">
59
- <q-item v-ripple :href="menu.to" v-if="!value.type" target="_blank">
70
+ <q-item v-ripple :href="isMenuAvailable(menu) ? menu.to : undefined" v-if="!value.type" target="_blank"
71
+ :disable="!isMenuAvailable(menu)">
60
72
  <q-item-section avatar>
61
73
  <q-icon :name="menu.icon" />
62
74
  </q-item-section>
63
- <q-item-section>{{ $t(menu.label) }}</q-item-section>
75
+ <q-item-section>
76
+ {{ $t(menu.label) }}
77
+ <q-item-label v-if="!isMenuAvailable(menu)" caption>{{ availabilityMessage(menu) }}</q-item-label>
78
+ </q-item-section>
79
+ <q-item-section v-if="!isMenuAvailable(menu)" side>
80
+ <q-icon name="sym_o_vpn_lock"><q-tooltip>{{ availabilityMessage(menu) }}</q-tooltip></q-icon>
81
+ </q-item-section>
64
82
  </q-item>
65
83
  </template>
66
84
 
67
85
  <template v-else>
68
- <q-item v-ripple :to="menu.to" v-if="!value.type">
86
+ <q-item v-ripple :to="isMenuAvailable(menu) ? menu.to : undefined" v-if="!value.type"
87
+ :disable="!isMenuAvailable(menu)">
69
88
  <q-item-section avatar>
70
89
  <q-icon :name="menu.icon" />
71
90
  </q-item-section>
72
- <q-item-section>{{ $t(menu.label) }}</q-item-section>
91
+ <q-item-section>
92
+ {{ $t(menu.label) }}
93
+ <q-item-label v-if="!isMenuAvailable(menu)" caption>{{ availabilityMessage(menu) }}</q-item-label>
94
+ </q-item-section>
95
+ <q-item-section v-if="!isMenuAvailable(menu)" side>
96
+ <q-icon name="sym_o_vpn_lock"><q-tooltip>{{ availabilityMessage(menu) }}</q-tooltip></q-icon>
97
+ </q-item-section>
73
98
  </q-item>
74
99
  </template>
75
100
 
@@ -0,0 +1,30 @@
1
+ export type LightClientAvailabilityStatus = 'checking' | 'available' | 'unavailable';
2
+ export interface LightClientAvailabilityOptions {
3
+ healthURL?: string;
4
+ healthTimeout?: number;
5
+ unavailableMessage?: string;
6
+ }
7
+ export interface LightClientAvailabilityState {
8
+ status: LightClientAvailabilityStatus;
9
+ message: string;
10
+ checkedAt: number | null;
11
+ }
12
+ export declare function registerLightClientAvailability(name: string, config?: LightClientAvailabilityOptions): void;
13
+ export declare function clearLightClientAvailability(): void;
14
+ export declare function checkLightClientAvailability(name: string): Promise<LightClientAvailabilityStatus>;
15
+ export declare function checkAllLightClientAvailability(): Promise<void>;
16
+ export default function useLightClientAvailability(): {
17
+ states: {
18
+ readonly [x: string]: {
19
+ readonly status: LightClientAvailabilityStatus;
20
+ readonly message: string;
21
+ readonly checkedAt: number | null;
22
+ };
23
+ };
24
+ status: (name?: string) => LightClientAvailabilityStatus;
25
+ isAvailable: (name?: string) => boolean;
26
+ isChecking: (name?: string) => boolean;
27
+ message: (name?: string) => string;
28
+ check: typeof checkLightClientAvailability;
29
+ checkAll: typeof checkAllLightClientAvailability;
30
+ };
@@ -0,0 +1,88 @@
1
+ import { reactive, readonly } from "vue";
2
+ const DEFAULT_TIMEOUT = 3e3;
3
+ const DEFAULT_MESSAGE = "This service is currently unavailable.";
4
+ const options = /* @__PURE__ */ new Map();
5
+ const checks = /* @__PURE__ */ new Map();
6
+ const states = reactive({});
7
+ export function registerLightClientAvailability(name, config = {}) {
8
+ options.set(name, config);
9
+ states[name] = {
10
+ status: config.healthURL ? "checking" : "available",
11
+ message: "",
12
+ checkedAt: null
13
+ };
14
+ }
15
+ export function clearLightClientAvailability() {
16
+ options.clear();
17
+ checks.clear();
18
+ Object.keys(states).forEach((name) => delete states[name]);
19
+ }
20
+ export async function checkLightClientAvailability(name) {
21
+ const config = options.get(name);
22
+ if (!config) {
23
+ states[name] = {
24
+ status: "unavailable",
25
+ message: `Light client "${name}" is not configured.`,
26
+ checkedAt: Date.now()
27
+ };
28
+ return "unavailable";
29
+ }
30
+ if (!config.healthURL) {
31
+ states[name] = { status: "available", message: "", checkedAt: Date.now() };
32
+ return "available";
33
+ }
34
+ const checkId = (checks.get(name) ?? 0) + 1;
35
+ checks.set(name, checkId);
36
+ states[name] = { ...states[name], status: "checking", message: "" };
37
+ const controller = new AbortController();
38
+ const timeout = globalThis.setTimeout(
39
+ () => controller.abort(),
40
+ Math.max(1, config.healthTimeout ?? DEFAULT_TIMEOUT)
41
+ );
42
+ try {
43
+ const response = await fetch(config.healthURL, {
44
+ method: "GET",
45
+ cache: "no-store",
46
+ credentials: "omit",
47
+ signal: controller.signal
48
+ });
49
+ const status = response.ok ? "available" : "unavailable";
50
+ if (checks.get(name) === checkId) {
51
+ states[name] = {
52
+ status,
53
+ message: response.ok ? "" : config.unavailableMessage ?? DEFAULT_MESSAGE,
54
+ checkedAt: Date.now()
55
+ };
56
+ }
57
+ return status;
58
+ } catch {
59
+ if (checks.get(name) === checkId) {
60
+ states[name] = {
61
+ status: "unavailable",
62
+ message: config.unavailableMessage ?? DEFAULT_MESSAGE,
63
+ checkedAt: Date.now()
64
+ };
65
+ }
66
+ return "unavailable";
67
+ } finally {
68
+ globalThis.clearTimeout(timeout);
69
+ }
70
+ }
71
+ export async function checkAllLightClientAvailability() {
72
+ await Promise.all([...options.keys()].map((name) => checkLightClientAvailability(name)));
73
+ }
74
+ export default function useLightClientAvailability() {
75
+ const status = (name) => {
76
+ if (!name) return "available";
77
+ return states[name]?.status ?? "unavailable";
78
+ };
79
+ return {
80
+ states: readonly(states),
81
+ status,
82
+ isAvailable: (name) => status(name) === "available",
83
+ isChecking: (name) => status(name) === "checking",
84
+ message: (name) => name ? states[name]?.message ?? `Light client "${name}" is not configured.` : "",
85
+ check: checkLightClientAvailability,
86
+ checkAll: checkAllLightClientAvailability
87
+ };
88
+ }
@@ -2,6 +2,7 @@
2
2
  import { useRouter, useRoute } from "vue-router";
3
3
  import { useQuasar } from "quasar";
4
4
  import { computed, model } from "#imports";
5
+ import { getFormClient } from "../utils/formClient";
5
6
  const route = useRoute();
6
7
  const router = useRouter();
7
8
  const $q = useQuasar();
@@ -24,6 +25,7 @@ if (props.context.attrs.onSubmitted) {
24
25
  }
25
26
  const modelName = props.context.modelName || (typeof route.name === "string" ? route.name.split("-")[0] : void 0);
26
27
  const id = props.context.modelId || route.params[typeof route.name === "string" ? route.name.split("-")[1] : "id"];
28
+ const client = getFormClient(props.context);
27
29
  if (!props.context.onSubmit) {
28
30
  props.context.node.props.onSubmit = async function() {
29
31
  const removeUndefined = (obj) => {
@@ -51,7 +53,7 @@ if (!props.context.onSubmit) {
51
53
  const v = removeUndefined(props.context.value);
52
54
  try {
53
55
  if (id) {
54
- if (await model(modelName).update(Number(id), v)) {
56
+ if (await model(modelName, client).update(Number(id), v)) {
55
57
  $q.notify({
56
58
  message: "Updated successfully",
57
59
  color: "positive",
@@ -62,7 +64,7 @@ if (!props.context.onSubmit) {
62
64
  }
63
65
  }
64
66
  } else {
65
- if (await model(modelName).add(v)) {
67
+ if (await model(modelName, client).add(v)) {
66
68
  $q.notify({
67
69
  message: "Added successfully",
68
70
  color: "positive",
@@ -117,7 +117,8 @@ export const createLightPlugin = () => {
117
117
  "layout",
118
118
  "bordered",
119
119
  "modelName",
120
- "modelId"
120
+ "modelId",
121
+ "client"
121
122
  ],
122
123
  features: [forms, disablesChildren]
123
124
  });
@@ -13,21 +13,50 @@ import { plugin, defaultConfig } from "@formkit/vue";
13
13
  import getApiBase from "./composables/getApiBase.js";
14
14
  import useLight from "./composables/useLight.js";
15
15
  import useLightClient, { clearLightClients, registerLightClient } from "./composables/useLightClient.js";
16
+ import {
17
+ checkAllLightClientAvailability,
18
+ checkLightClientAvailability,
19
+ clearLightClientAvailability,
20
+ registerLightClientAvailability
21
+ } from "./composables/useLightClientAvailability.js";
16
22
  import { zhTW } from "@formkit/i18n";
17
23
  export default defineNuxtPlugin((nuxtApp) => {
18
24
  const runtimeConfig = useRuntimeConfig();
19
25
  const lightConfig = runtimeConfig.public.light;
20
26
  const configuredClients = lightConfig?.clients ?? {};
21
27
  clearLightClients();
28
+ clearLightClientAvailability();
22
29
  for (const [name, config] of Object.entries(configuredClients)) {
23
30
  const baseURL = typeof config === "string" ? config : config.baseURL;
24
31
  if (!baseURL) throw new Error(`Light client "${name}" requires a baseURL`);
25
32
  registerLightClient(name, createClient(baseURL));
33
+ registerLightClientAvailability(name, typeof config === "string" ? {} : config);
26
34
  }
27
35
  if (!configuredClients.auth) {
28
36
  registerLightClient("auth", createClient(getApiBase()));
37
+ registerLightClientAvailability("auth");
38
+ }
39
+ const authClient = useLightClient();
40
+ setApiClient(authClient);
41
+ for (const [name, config] of Object.entries(configuredClients)) {
42
+ if (name === "auth" || typeof config === "string" || !config.audience) continue;
43
+ useLightClient(name).useAudience(authClient, config.audience);
44
+ }
45
+ if (import.meta.client) {
46
+ for (const name of Object.keys(configuredClients)) {
47
+ void checkLightClientAvailability(name);
48
+ }
49
+ const recheckClients = () => void checkAllLightClientAvailability();
50
+ const recheckVisibleClients = () => {
51
+ if (document.visibilityState === "visible") recheckClients();
52
+ };
53
+ window.addEventListener("online", recheckClients);
54
+ document.addEventListener("visibilitychange", recheckVisibleClients);
55
+ nuxtApp.hook("app:unmounted", () => {
56
+ window.removeEventListener("online", recheckClients);
57
+ document.removeEventListener("visibilitychange", recheckVisibleClients);
58
+ });
29
59
  }
30
- setApiClient(useLightClient());
31
60
  defineModel("Permission", {}).setDataPath("app.listPermission");
32
61
  defineModel("SystemValue", {}).setDataPath("app.listSystemValue");
33
62
  defineModel("Config", {}).setDataPath("app.listConfig");
@@ -0,0 +1,4 @@
1
+ export interface FormClientContext {
2
+ client?: string;
3
+ }
4
+ export declare function getFormClient(context: FormClientContext): string;
@@ -0,0 +1,3 @@
1
+ export function getFormClient(context) {
2
+ return context.client || "auth";
3
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hostlink/nuxt-light",
3
- "version": "1.76.0",
3
+ "version": "1.78.0",
4
4
  "description": "HostLink Nuxt Light Framework",
5
5
  "repository": {
6
6
  "type": "git",
@@ -38,7 +38,7 @@
38
38
  "@formkit/inputs": "^2.1.2",
39
39
  "@formkit/validation": "^2.1.2",
40
40
  "@formkit/vue": "^2.1.2",
41
- "@hostlink/light": "^3.3.1",
41
+ "@hostlink/light": "^3.4.0",
42
42
  "@nuxt/module-builder": "^1.0.1",
43
43
  "@quasar/extras": "^2.0.2",
44
44
  "@quasar/quasar-ui-qmarkdown": "^3.0.1",