@fishawack/lab-velocity 2.0.0-beta.43 → 2.0.0-beta.45

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.
Files changed (33) hide show
  1. package/README.md +25 -0
  2. package/_Build/vue/components/layout/PageHeader.vue +7 -6
  3. package/_Build/vue/components/layout/Table.vue +5 -0
  4. package/_Build/vue/components/layout/TableSorter.vue +1 -0
  5. package/_Build/vue/components/layout/TokenDisplay.vue +52 -0
  6. package/_Build/vue/modules/AuthModule/js/guest-request.js +32 -0
  7. package/_Build/vue/modules/AuthModule/js/impersonation-banner.js +102 -0
  8. package/_Build/vue/modules/AuthModule/js/router.js +61 -27
  9. package/_Build/vue/modules/AuthModule/js/store.js +8 -0
  10. package/_Build/vue/modules/AuthModule/routes/PCompanies/resource.js +2 -3
  11. package/_Build/vue/modules/AuthModule/routes/PIntegrations/columns.js +58 -0
  12. package/_Build/vue/modules/AuthModule/routes/PIntegrations/resource.js +53 -96
  13. package/_Build/vue/modules/AuthModule/routes/PTeams/columns.js +78 -0
  14. package/_Build/vue/modules/AuthModule/routes/PTeams/resource.js +206 -290
  15. package/_Build/vue/modules/AuthModule/routes/PUsers/SetPasswordAction.vue +51 -0
  16. package/_Build/vue/modules/AuthModule/routes/PUsers/SetPasswordDialog.vue +138 -0
  17. package/_Build/vue/modules/AuthModule/routes/PUsers/resource.js +39 -2
  18. package/_Build/vue/modules/AuthModule/routes/change-password.vue +6 -9
  19. package/_Build/vue/modules/AuthModule/routes/force-reset.vue +6 -9
  20. package/_Build/vue/modules/AuthModule/routes/forgot.vue +6 -1
  21. package/_Build/vue/modules/AuthModule/routes/login.vue +6 -9
  22. package/_Build/vue/modules/AuthModule/routes/loginsso.vue +7 -1
  23. package/_Build/vue/modules/AuthModule/routes/logout.vue +10 -2
  24. package/_Build/vue/modules/AuthModule/routes/register.vue +6 -8
  25. package/_Build/vue/modules/AuthModule/routes/reset.vue +6 -1
  26. package/_Build/vue/modules/AuthModule/routes/success-forgot.vue +6 -1
  27. package/_Build/vue/modules/resource/index.js +9 -1
  28. package/_Build/vue/modules/resource/trashable.js +104 -0
  29. package/components/_table.scss +3 -0
  30. package/components/_token-display.scss +41 -0
  31. package/general.scss +1 -0
  32. package/index.js +5 -0
  33. package/package.json +1 -1
package/README.md CHANGED
@@ -103,6 +103,7 @@ import { Auth } from "@fishawack/lab-velocity";
103
103
  // ...
104
104
  paths: ["auth.user"],
105
105
  }),
106
+ Auth.ImpersonationPlugin,
106
107
  ],
107
108
 
108
109
  modules: {
@@ -113,6 +114,8 @@ import { Auth } from "@fishawack/lab-velocity";
113
114
  }
114
115
  ```
115
116
 
117
+ `Auth.ImpersonationPlugin` displays a persistent banner when an admin is impersonating another user, persisted across page reloads.
118
+
116
119
  ### Base Styles
117
120
 
118
121
  @fishawack/lab-velocity extends @fishawack/lab-ui, for this reason you should replace the two references to variables & defaults with @fishawack/lab-velocity ones.
@@ -188,6 +191,7 @@ There are two different set of sass imports for the admin and the frontend route
188
191
  @import "@fishawack/lab-velocity/components/menu";
189
192
  @import "@fishawack/lab-velocity/components/layout";
190
193
  @import "@fishawack/lab-velocity/components/descriptions";
194
+ @import "@fishawack/lab-velocity/components/token-display";
191
195
  @import "element-plus/theme-chalk/el-tabs";
192
196
  @import "element-plus/theme-chalk/el-tab-pane";
193
197
  ```
@@ -478,3 +482,24 @@ import { Checkbox as VelCheckbox } from "@fishawack/lab-velocity";
478
482
  ),
479
483
  },
480
484
  ```
485
+
486
+ ## Local dev
487
+
488
+ To work locally mount a local clone of the project into the packages directory via an overridden docker compose file.
489
+
490
+ ### Docker setup
491
+
492
+ Create a docker compose file at `_Docker/docker-compose.yml`
493
+
494
+ ```yml
495
+ services:
496
+ core:
497
+ volumes:
498
+ - $PWD/../lab-velocity:/app/packages/lab-velocity
499
+ ```
500
+
501
+ ### Npm install
502
+
503
+ ```bash
504
+ npm install ./packages/lab-velocity
505
+ ```
@@ -1,6 +1,6 @@
1
1
  <template>
2
- <div class="grid justify-between">
3
- <div class="grid__4/6 flex justify-start">
2
+ <div class="flex flex-wrap items-start gap" style="row-gap: 16px">
3
+ <div class="flex justify-start items-center">
4
4
  <span v-if="icon">
5
5
  <div
6
6
  class="p-1.5 mr-2 border-radius border border-solid border-muted flex items-center justify-center"
@@ -17,10 +17,11 @@
17
17
  <h2 class="m-0 font-500 text-secondary">{{ title }}</h2>
18
18
  </div>
19
19
 
20
- <div class="grid__2/6 flex gap justify-end items-start">
21
- <div class="flex gap items-center">
22
- <slot />
23
- </div>
20
+ <div
21
+ class="flex flex-wrap gap items-center justify-end"
22
+ style="margin-left: auto"
23
+ >
24
+ <slot />
24
25
  </div>
25
26
  </div>
26
27
  </template>
@@ -2,6 +2,7 @@
2
2
  <el-table
3
3
  :data="$props.data"
4
4
  :height="fixedHeight ? 762 : undefined"
5
+ :row-class-name="rowClassName"
5
6
  style="width: 100%"
6
7
  @sort-change="handleSort"
7
8
  >
@@ -131,6 +132,10 @@ export default {
131
132
  type: Boolean,
132
133
  default: true,
133
134
  },
135
+ rowClassName: {
136
+ type: [Function, String],
137
+ default: undefined,
138
+ },
134
139
  },
135
140
  emits: ["sort", "reload"],
136
141
 
@@ -64,6 +64,7 @@
64
64
  :label="jsonData.label"
65
65
  :over-write-id="jsonData.overWriteId"
66
66
  :fixed-height="fixedHeight"
67
+ :row-class-name="jsonData.rowClassName"
67
68
  :target-action="
68
69
  (item) => ({
69
70
  name: `${jsonData.slug}.show`,
@@ -0,0 +1,52 @@
1
+ <template>
2
+ <el-dialog
3
+ :model-value="true"
4
+ title="Token Created"
5
+ width="560px"
6
+ class="token-display"
7
+ :close-on-click-modal="false"
8
+ :close-on-press-escape="false"
9
+ :show-close="false"
10
+ >
11
+ <p class="token-display__warning">
12
+ <strong>The token below will not be shown again.</strong> Ensure
13
+ you've taken a copy before closing this window.
14
+ </p>
15
+
16
+ <div class="token-display__block">
17
+ <div class="token-display__header">
18
+ <span class="token-display__label">Bearer Token</span>
19
+ <el-button size="small" plain @click="copy">
20
+ {{ copied ? "Copied ✓" : "Copy" }}
21
+ </el-button>
22
+ </div>
23
+ <pre class="token-display__value">{{ token }}</pre>
24
+ </div>
25
+
26
+ <template #footer>
27
+ <el-button type="primary" @click="$emit('close')">Done</el-button>
28
+ </template>
29
+ </el-dialog>
30
+ </template>
31
+
32
+ <script setup>
33
+ import { ref } from "vue";
34
+ import { ElDialog, ElButton } from "element-plus";
35
+
36
+ const props = defineProps({
37
+ token: {
38
+ type: String,
39
+ required: true,
40
+ },
41
+ });
42
+
43
+ defineEmits(["close"]);
44
+
45
+ const copied = ref(false);
46
+
47
+ async function copy() {
48
+ await navigator.clipboard.writeText(props.token);
49
+ copied.value = true;
50
+ setTimeout(() => (copied.value = false), 2000);
51
+ }
52
+ </script>
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ import axios from "axios";
3
+
4
+ /**
5
+ * Wraps a form POST to a guest-only route with automatic logged-in detection.
6
+ *
7
+ * If the server redirects to /hydrate/logged-in (because a stale session
8
+ * exists), the helper dispatches a logout, fetches a fresh CSRF cookie,
9
+ * and retries the original request once.
10
+ *
11
+ * @param {object} options
12
+ * @param {object} options.form - form-backend-validation Form instance
13
+ * @param {string} options.url - endpoint to POST to
14
+ * @param {string} [options.method] - HTTP method (default: "post")
15
+ * @param {import('vuex').Store} options.store - Vuex store (for dispatch("logout"))
16
+ * @returns {Promise<object>} resolved response data
17
+ */
18
+ export async function guestRequest({ form, url, method = "post", store }) {
19
+ const res = await form[method](url);
20
+
21
+ if (res && res["logged-in"]) {
22
+ try {
23
+ await store.dispatch("logout");
24
+ } catch (_) {}
25
+
26
+ await axios.get("/sanctum/csrf-cookie");
27
+
28
+ return await form[method](url);
29
+ }
30
+
31
+ return res;
32
+ }
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+
3
+ import { createApp, h, ref } from "vue";
4
+
5
+ let bannerApp = null;
6
+ let bannerContainer = null;
7
+ let storeRef = null;
8
+ const currentUser = ref(null);
9
+
10
+ function mountBanner() {
11
+ if (bannerApp) return;
12
+
13
+ bannerContainer = document.createElement("div");
14
+ document.body.insertBefore(bannerContainer, document.body.firstChild);
15
+
16
+ bannerApp = createApp({
17
+ setup() {
18
+ function stop() {
19
+ storeRef.dispatch("stopImpersonating");
20
+ }
21
+
22
+ return () =>
23
+ h(
24
+ "div",
25
+ {
26
+ style: {
27
+ background: "#c0392b",
28
+ color: "#fff",
29
+ display: "flex",
30
+ alignItems: "center",
31
+ justifyContent: "center",
32
+ gap: "16px",
33
+ padding: "12px 16px",
34
+ fontSize: "14px",
35
+ fontFamily: "sans-serif",
36
+ },
37
+ },
38
+ [
39
+ h(
40
+ "span",
41
+ `Impersonating: ${currentUser.value?.name} (${currentUser.value?.email})`,
42
+ ),
43
+ h(
44
+ "button",
45
+ {
46
+ onClick: stop,
47
+ style: {
48
+ background: "#fff",
49
+ color: "#c0392b",
50
+ border: "none",
51
+ padding: "4px 12px",
52
+ borderRadius: "4px",
53
+ cursor: "pointer",
54
+ fontWeight: "bold",
55
+ },
56
+ },
57
+ "Stop impersonating",
58
+ ),
59
+ ],
60
+ );
61
+ },
62
+ });
63
+
64
+ bannerApp.mount(bannerContainer);
65
+ }
66
+
67
+ function unmountBanner() {
68
+ if (!bannerApp) return;
69
+
70
+ bannerApp.unmount();
71
+ bannerContainer?.remove();
72
+ bannerApp = null;
73
+ bannerContainer = null;
74
+ }
75
+
76
+ export function syncImpersonationBanner(user) {
77
+ currentUser.value = user;
78
+
79
+ if (user?.impersonating_as) {
80
+ mountBanner();
81
+ } else {
82
+ unmountBanner();
83
+ }
84
+ }
85
+
86
+ export function setImpersonationStore(store) {
87
+ storeRef = store;
88
+ }
89
+
90
+ export function ImpersonationPlugin(store) {
91
+ setImpersonationStore(store);
92
+
93
+ // Handle persisted state on load (vuex-persistedstate bypasses mutations)
94
+ syncImpersonationBanner(store.state.auth?.user ?? store.state.user);
95
+
96
+ // Handle all future setUser mutations
97
+ store.subscribe((mutation) => {
98
+ if (mutation.type === "auth/setUser" || mutation.type === "setUser") {
99
+ syncImpersonationBanner(mutation.payload);
100
+ }
101
+ });
102
+ }
@@ -179,50 +179,84 @@ export function routes(node) {
179
179
  }
180
180
 
181
181
  export function beforeEach(router, store) {
182
+ let initialLoad = true;
183
+
184
+ // These routes must always be reachable regardless of auth/verification state.
185
+ // Without this, force_password_change or unverified state can trap the user with
186
+ // no way to log out or complete an SSO callback.
187
+ const ALWAYS_ALLOW = new Set(["auth.logout", "auth.callback"]);
188
+
182
189
  router.beforeEach(async (to, from, next) => {
183
- // If authenticated query param is present, assume authentication has happened elsewhere and attempt to fetch user data
184
- if (to.query.authenticated && !store.getters.authenticated) {
190
+ // Refresh user state on initial page load (handles cross-app state sync)
191
+ // or when authenticated query param is present (bypass/redirect flow)
192
+ if (
193
+ (store.getters.authenticated && initialLoad) ||
194
+ to.query.authenticated
195
+ ) {
185
196
  await store.dispatch("getUser", {
186
197
  errors: (e) => console.error(e),
187
198
  });
188
199
  }
189
200
 
201
+ initialLoad = false;
202
+
190
203
  const { user, redirect } = store.state.auth;
204
+ const isGuestRoute = to.matched.some((d) => d.meta.guest) === true;
191
205
 
192
- // User verification handling and redirect if user alrady verified but accessing an expired verification route
206
+ // Logout and SSO callback must always be reachable prevents every
207
+ // possible "stuck" scenario caused by state-based redirects below.
208
+ if (ALWAYS_ALLOW.has(to.name)) {
209
+ return next();
210
+ }
211
+
212
+ // Redirect already-verified users away from verification routes
193
213
  if (
194
214
  to.query.verified ||
195
215
  (to.name === "auth.expired-verification" && user?.email_verified_at)
196
216
  ) {
197
- next({ name: "auth.success-verify" });
198
- } else if (store.getters.authenticated) {
199
- // User is authenticated - check permissions and redirect appropriately
200
- if (user.force_password_change && to.name !== "auth.force-reset") {
201
- // User needs to change password - redirect to force reset
202
- next({ name: "auth.force-reset" });
203
- } else if (to.name === "auth.login") {
204
- // Already logged in user hitting login
205
- next({ name: redirect });
206
- } else if (
217
+ return next({ name: "auth.success-verify" });
218
+ }
219
+
220
+ if (store.getters.authenticated) {
221
+ // 1. Email verification is the highest priority redirect.
222
+ // Must come before force_password_change — there is no point forcing
223
+ // a password change on an account that hasn't been verified yet.
224
+ // Guest routes, auth.verify and auth.expired-verification are exempt
225
+ // so the user can always complete or re-request verification.
226
+ if (
207
227
  !user?.email_verified_at &&
208
- to.matched.some((d) => d.meta.guest) !== true
228
+ !isGuestRoute &&
229
+ to.name !== "auth.verify" &&
230
+ to.name !== "auth.expired-verification"
209
231
  ) {
210
- // User needs email verification and trying to access protected route
211
- next({ name: "auth.verify" });
212
- } else {
213
- // User is authenticated and authorized - proceed
214
- next();
232
+ return next({ name: "auth.verify" });
215
233
  }
216
- } else {
217
- // User is not authenticated - handle guest routes and login redirects
218
- if (to.matched.some((d) => d.meta.guest) === true) {
219
- // Route allows guest access - proceed
220
- next();
221
- } else {
222
- // Protected route requires authentication - redirect to standard login
223
- next({ name: "auth.login" });
234
+
235
+ // 2. Force password change applies only after verification is satisfied.
236
+ // Guest routes are exempt so the user can still navigate within /auth
237
+ // (e.g. auth.verify, auth.expired-verification) without looping.
238
+ if (
239
+ user?.force_password_change &&
240
+ !isGuestRoute &&
241
+ to.name !== "auth.force-reset"
242
+ ) {
243
+ return next({ name: "auth.force-reset" });
244
+ }
245
+
246
+ // 3. Redirect an already-authenticated user away from the login page
247
+ if (to.name === "auth.login") {
248
+ return next({ name: redirect });
224
249
  }
250
+
251
+ return next();
252
+ }
253
+
254
+ // Unauthenticated: allow guest routes, otherwise redirect to login
255
+ if (isGuestRoute) {
256
+ return next();
225
257
  }
258
+
259
+ return next({ name: "auth.login" });
226
260
  });
227
261
  }
228
262
 
@@ -65,6 +65,14 @@ const store = {
65
65
 
66
66
  return axios.post("/logout");
67
67
  },
68
+
69
+ stopImpersonating({ commit }) {
70
+ return axios.post("/api/users/impersonate/stop").then((res) => {
71
+ commit("setUser", res.data.data);
72
+
73
+ return res.data.data;
74
+ });
75
+ },
68
76
  },
69
77
  };
70
78
 
@@ -19,9 +19,7 @@ export default [
19
19
  {
20
20
  api: {
21
21
  params: {
22
- index: ({ $route }) => ({
23
- "filter[withTrashed]": $route.query.trashed,
24
- }),
22
+ index: () => ({}),
25
23
  show: () => ({
26
24
  include: "primary_contact",
27
25
  }),
@@ -35,6 +33,7 @@ export default [
35
33
  singular: "company",
36
34
  icon: "icon-cases",
37
35
  auditable: true,
36
+ trashable: true,
38
37
  ...merge(columns(companiesColumns), {
39
38
  index: {
40
39
  layout: [
@@ -0,0 +1,58 @@
1
+ import { h } from "vue";
2
+
3
+ import VelSelect from "../../../../components/form/Select.vue";
4
+
5
+ export default [
6
+ {
7
+ key: "name",
8
+ sortable: true,
9
+ },
10
+ {
11
+ key: "scopes",
12
+ endpoint: "api/scopes",
13
+ labelKey: "description",
14
+ filterable: true,
15
+ clearable: true,
16
+ multiple: true,
17
+ initial: (props) =>
18
+ props.model?.client?.scopes?.map((id) => ({
19
+ id,
20
+ description: id,
21
+ })) ?? [],
22
+ preparation: ({ form }) => form.scopes.map((d) => d.id),
23
+ render: {
24
+ read: ({ model }) => h("span", model.client?.scopes.join(", ")),
25
+ write: () => h(VelSelect),
26
+ },
27
+ condition: {
28
+ table: false,
29
+ },
30
+ },
31
+ {
32
+ key: "client_id",
33
+ label: "Client ID",
34
+ condition: {
35
+ form: false,
36
+ },
37
+ },
38
+ {
39
+ key: "user_id",
40
+ label: "Created by",
41
+ render: {
42
+ read: ({ model }) => h("span", model?.user?.name ?? "System"),
43
+ },
44
+ condition: {
45
+ form: false,
46
+ },
47
+ },
48
+ {
49
+ key: "access_logs_count",
50
+ label: "API Calls",
51
+ render: {
52
+ read: ({ model }) => h("span", model?.access_logs_count ?? 0),
53
+ },
54
+ condition: {
55
+ form: false,
56
+ },
57
+ },
58
+ ];
@@ -1,9 +1,11 @@
1
1
  import { merge } from "lodash";
2
- import { ElMessageBox } from "element-plus";
3
- import { h } from "vue";
2
+ import { h, ref } from "vue";
4
3
 
5
- import { columns } from "../../../resource/index.js";
6
- import VelSelect from "../../../../components/form/Select.vue";
4
+ import { columns, defaultResource } from "../../../resource/index.js";
5
+ import integrationsColumns from "./columns.js";
6
+ import TokenDisplay from "../../../../components/layout/TokenDisplay.vue";
7
+
8
+ const newToken = ref(null);
7
9
 
8
10
  export default [
9
11
  "integrations",
@@ -23,100 +25,55 @@ export default [
23
25
  edit: ({ $store }) => $store.getters.can("write integrations"),
24
26
  delete: ({ $store }) => $store.getters.can("delete integrations"),
25
27
  },
26
- ...merge(
27
- columns([
28
- {
29
- key: "name",
30
- sortable: true,
31
- },
32
- {
33
- key: "scopes",
34
- endpoint: "api/scopes",
35
- labelKey: "description",
36
- filterable: true,
37
- clearable: true,
38
- multiple: true,
39
- initial: () => [],
40
- preparation: ({ form }) => form.scopes.map((d) => d.id),
41
- render: {
42
- read: ({ model }) =>
43
- h("span", model.client?.scopes.join(", ")),
44
- write: () => h(VelSelect),
45
- },
46
- condition: {
47
- table: false,
48
- },
49
- },
50
- {
51
- key: "client_id",
52
- label: "Client ID",
53
- condition: {
54
- form: false,
55
- },
56
- },
57
- {
58
- key: "user_id",
59
- label: "Created by",
60
- render: {
61
- read: ({ model }) =>
62
- h("span", model?.user?.name ?? "System"),
63
- },
64
- condition: {
65
- form: false,
66
- },
67
- },
68
- {
69
- key: "access_logs_count",
70
- label: "API Calls",
71
- render: {
72
- read: ({ model }) =>
73
- h("span", model?.access_logs_count ?? 0),
74
- },
75
- condition: {
76
- form: false,
77
- },
78
- },
79
- ]),
80
- {
81
- form: {
82
- submit: async (props) => {
83
- const { form, resource, $router } = props;
84
- const hold = JSON.parse(JSON.stringify(form.data()));
85
- try {
86
- form.populate(resource.form.preparation(props));
87
- let res = await form.post(
88
- `${resource.api.endpoint(props)}`,
89
- );
90
- ElMessageBox.alert(
91
- `<p>The token below will not be shown again. Ensure you've taken a copy before closing this window.<br><br><strong>Token</strong>:</p><p><em>${res.data.token}</em></p>`,
92
- "Token minted",
93
- {
94
- confirmButtonText: "Ok",
95
- dangerouslyUseHTMLString: true,
96
- },
97
- )
98
- .then(() => {
99
- $router.replace({
100
- name: `${resource.name}.show`,
101
- params: {
102
- integrationsId: res.data.id,
103
- },
104
- });
105
- })
106
- .catch(() => {});
107
- } catch (e) {
108
- console.log(e);
109
- } finally {
110
- if (
111
- !form.successful ||
112
- !form.__options.resetOnSuccess
113
- ) {
114
- form.populate(hold);
115
- }
28
+ ...merge(columns(integrationsColumns), {
29
+ form: {
30
+ submit: async (props) => {
31
+ const { form, resource, $router, model } = props;
32
+ const hold = JSON.parse(JSON.stringify(form.data()));
33
+ const isEdit = !!model;
34
+ const endpoint = isEdit
35
+ ? `${resource.api.endpoint(props)}/${model.id}`
36
+ : `${resource.api.endpoint(props)}`;
37
+ try {
38
+ form.populate(resource.form.preparation(props));
39
+ let res = await form.post(endpoint);
40
+ newToken.value = res.data.token;
41
+ await $router.replace({
42
+ name: `${resource.name}.show`,
43
+ params: {
44
+ integrationsId: res.data.id,
45
+ },
46
+ });
47
+ } catch (e) {
48
+ console.log(e);
49
+ } finally {
50
+ if (
51
+ !form.successful ||
52
+ !form.__options.resetOnSuccess
53
+ ) {
54
+ form.populate(hold);
116
55
  }
117
- },
56
+ }
118
57
  },
119
58
  },
120
- ),
59
+ show: {
60
+ ...defaultResource.show,
61
+ layout: [
62
+ ...defaultResource.show.layout,
63
+ () =>
64
+ h({
65
+ setup: () => () => {
66
+ if (!newToken.value) return null;
67
+ return h(TokenDisplay, {
68
+ token: newToken.value,
69
+ onClose: () => {
70
+ newToken.value = null;
71
+ },
72
+ });
73
+ },
74
+ }),
75
+ ],
76
+ },
77
+ }),
121
78
  },
122
79
  ];