@7365admin1/layer-common 4.116.0 → 4.118.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 +16 -4
- package/components/Robot/Detail.vue +10 -7
- package/components/Robot/SiteScreen.vue +6 -5
- package/composables/useTermsGate.ts +65 -0
- package/middleware/01.auth.ts +11 -0
- package/package.json +1 -1
- package/pages/accept-terms.vue +126 -0
- package/utils/terms-gate.ts +45 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.118.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 42930fa: Robot screens: Add robot, Edit, Remove and the building-map editor show only for Seven365 Super Admin. Commands on a HERO Fleet Core robot show only for Seven365 Super Admin; other users see status and position (view only). Matches the server rule that Fleet Core and robot setup are Super Admin only.
|
|
8
|
+
|
|
9
|
+
## 4.117.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 48657c4: Post-login "Accept Terms & Privacy Policy" screen for every web app. `01-auth` asks `GET /api/users/terms` once per page load; if the account has never accepted the live version (or a newer one was published) it goes to `/accept-terms`, which shows the Super Admin text and records acceptance with `POST /api/users/terms/accept`. An account that accepted is never asked again on any device or app. Fails open on any error or timeout. Print, PDF, preview, kiosk and public pages (plain layouts) and headless browsers are never interrupted.
|
|
14
|
+
|
|
3
15
|
## 4.116.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
|
@@ -3111,11 +3123,11 @@ Monthly report: a month that had patrols opens the list of that month's patrol r
|
|
|
3111
3123
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
3112
3124
|
own rule from two endpoints the console already calls, unprojected:
|
|
3113
3125
|
|
|
3114
|
-
|
|
3115
|
-
|
|
3126
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
3127
|
+
GET /api/roles/id/:role that membership's role document
|
|
3116
3128
|
|
|
3117
|
-
|
|
3118
|
-
|
|
3129
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
3130
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
3119
3131
|
|
|
3120
3132
|
`role.default` is the marker because it is the only property of a platform
|
|
3121
3133
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -138,7 +138,7 @@
|
|
|
138
138
|
<div v-if="commands.length" class="hero-detail__commands">
|
|
139
139
|
<RobotCommandBar
|
|
140
140
|
:capabilities="caps"
|
|
141
|
-
:can-command="
|
|
141
|
+
:can-command="cmdOk"
|
|
142
142
|
:online="live?.ok === true && live.online === true"
|
|
143
143
|
:mode="statusMode"
|
|
144
144
|
:type="robotType"
|
|
@@ -151,7 +151,7 @@
|
|
|
151
151
|
{{ commandResult.text }}
|
|
152
152
|
</div>
|
|
153
153
|
</div>
|
|
154
|
-
<RobotViewOnlyNotice v-if="!
|
|
154
|
+
<RobotViewOnlyNotice v-if="!cmdOk" />
|
|
155
155
|
</section>
|
|
156
156
|
</div>
|
|
157
157
|
|
|
@@ -369,11 +369,13 @@ const props = withDefaults(
|
|
|
369
369
|
canEdit?: boolean;
|
|
370
370
|
canCommand?: boolean;
|
|
371
371
|
canDelete?: boolean;
|
|
372
|
+
/** Seven365 Super Admin: the only one who may command a HERO Fleet Core robot (owner, 12 Oct 2026). */
|
|
373
|
+
superAdmin?: boolean;
|
|
372
374
|
/** The robot types this app presents - the edit dialog shows Type only with both. */
|
|
373
375
|
types?: Array<"cleaning" | "security">;
|
|
374
376
|
tickMs?: number;
|
|
375
377
|
}>(),
|
|
376
|
-
{ backLabel: "", canEdit: false, canCommand: false, canDelete: false, types: () => [], tickMs: 1200 },
|
|
378
|
+
{ backLabel: "", canEdit: false, canCommand: false, canDelete: false, superAdmin: false, types: () => [], tickMs: 1200 },
|
|
377
379
|
);
|
|
378
380
|
const emit = defineEmits<{
|
|
379
381
|
back: [];
|
|
@@ -398,6 +400,7 @@ const {
|
|
|
398
400
|
} = useRobots();
|
|
399
401
|
|
|
400
402
|
const record = ref<Record<string, any> | null>(null);
|
|
403
|
+
const cmdOk = computed(() => props.canCommand && (props.superAdmin || record.value?.brand !== "fleet-core"));
|
|
401
404
|
const topEl = ref<HTMLElement | null>(null);
|
|
402
405
|
const brands = ref<Array<Record<string, any>>>([]);
|
|
403
406
|
const error = ref("");
|
|
@@ -478,7 +481,7 @@ const makerApp = computed(() => (live.value?.ok ? live.value.profile?.makerApp :
|
|
|
478
481
|
|
|
479
482
|
const online = computed(() => live.value?.ok === true && live.value.online === true);
|
|
480
483
|
const commands = computed(() =>
|
|
481
|
-
visibleCommands({ capabilities: caps.value, canCommand:
|
|
484
|
+
visibleCommands({ capabilities: caps.value, canCommand: cmdOk.value, online: online.value, mode: statusMode.value, type: robotType.value, resumable: resumable.value }),
|
|
482
485
|
);
|
|
483
486
|
|
|
484
487
|
/* ---- the plan ---- */
|
|
@@ -506,7 +509,7 @@ watch(
|
|
|
506
509
|
const readout = computed(() => `${positionText(live.value, caps.value, now.value)} · ${speedText(prevPose.value, nextPose.value, statusMode.value)}`);
|
|
507
510
|
|
|
508
511
|
const spot = ref<Array<{ x: number; y: number }>>([]);
|
|
509
|
-
const spotOn = computed(() => showSpotClean({ canCommand:
|
|
512
|
+
const spotOn = computed(() => showSpotClean({ canCommand: cmdOk.value, capabilities: caps.value, online: online.value, type: robotType.value }));
|
|
510
513
|
|
|
511
514
|
// The route drawn on the plan: the shown map's first route, else the first with placed checkpoints (the rest are listed by name).
|
|
512
515
|
const route = computed<TPatrolRouteView | null>(() =>
|
|
@@ -839,9 +842,9 @@ function onPick(p: TSavedPoint) {
|
|
|
839
842
|
const livePanel = ref<{ show(): void } | null>(null);
|
|
840
843
|
const hasCamera = computed(() => caps.value?.liveVideo === true || canDrive.value);
|
|
841
844
|
const driveOpen = ref(false);
|
|
842
|
-
const canDrive = computed(() =>
|
|
845
|
+
const canDrive = computed(() => cmdOk.value && caps.value?.manualDrive === true && online.value);
|
|
843
846
|
const canResolve = computed(
|
|
844
|
-
() =>
|
|
847
|
+
() => cmdOk.value && caps.value?.resolveAlerts === true && faultBadge(fault.value) === "ALARM" && typeof fault.value?.code === "string",
|
|
845
848
|
);
|
|
846
849
|
const resolving = ref(false);
|
|
847
850
|
async function resolveFault() {
|
|
@@ -26,9 +26,10 @@
|
|
|
26
26
|
:key="robotId"
|
|
27
27
|
:robot-id="robotId"
|
|
28
28
|
:back-label="siteName || names.siteName ? `${siteName || names.siteName} fleet` : ''"
|
|
29
|
-
:can-edit="canEdit"
|
|
29
|
+
:can-edit="canEdit && superAdmin"
|
|
30
30
|
:can-command="canCommand"
|
|
31
|
-
:
|
|
31
|
+
:super-admin="superAdmin"
|
|
32
|
+
:can-delete="canDelete && superAdmin"
|
|
32
33
|
:types="types"
|
|
33
34
|
@back="emit('back')"
|
|
34
35
|
@removed="onRemoved"
|
|
@@ -42,7 +43,7 @@
|
|
|
42
43
|
:site-name="siteName"
|
|
43
44
|
:client-name="clientName"
|
|
44
45
|
:type="type"
|
|
45
|
-
:can-edit-map="canSetup && canEdit"
|
|
46
|
+
:can-edit-map="canSetup && canEdit && superAdmin"
|
|
46
47
|
:back-to-network="backToNetwork"
|
|
47
48
|
@open="(id) => emit('open', id)"
|
|
48
49
|
@network="emit('network')"
|
|
@@ -57,7 +58,7 @@
|
|
|
57
58
|
:client-name="clientName"
|
|
58
59
|
:type="type"
|
|
59
60
|
:can-command="canCommand"
|
|
60
|
-
:can-edit-map="canSetup && canEdit"
|
|
61
|
+
:can-edit-map="canSetup && canEdit && superAdmin"
|
|
61
62
|
:back-to-network="backToNetwork"
|
|
62
63
|
@open="(id) => emit('open', id)"
|
|
63
64
|
@network="emit('network')"
|
|
@@ -90,7 +91,7 @@
|
|
|
90
91
|
:site-name="siteName"
|
|
91
92
|
:client-name="clientName"
|
|
92
93
|
:type="type"
|
|
93
|
-
:can-setup="canSetup"
|
|
94
|
+
:can-setup="canSetup && superAdmin"
|
|
94
95
|
:back-to-network="backToNetwork"
|
|
95
96
|
:reload-key="reloadKey"
|
|
96
97
|
@open="(r) => emit('open', String(r._id))"
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { shouldPrompt, type TermsGateReply } from "../utils/terms-gate";
|
|
2
|
+
|
|
3
|
+
const TIMEOUT_MS = 5000;
|
|
4
|
+
const FAIL_RETRY_MS = 60_000;
|
|
5
|
+
|
|
6
|
+
type GateState = { sid: string; mustAccept: boolean; reply: TermsGateReply | null; failedAt: number };
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The post-login "Accept Terms and Privacy Policy" gate.
|
|
10
|
+
*
|
|
11
|
+
* Acceptance is stored per ACCOUNT on the server, so an account that has
|
|
12
|
+
* accepted the live version is never asked again (any device, any app); only a
|
|
13
|
+
* newly published version asks again. FAILS OPEN: any error, timeout or empty
|
|
14
|
+
* answer lets the user in and asks again later - this is a consent screen, not
|
|
15
|
+
* an API lock.
|
|
16
|
+
*/
|
|
17
|
+
export function useTermsGate() {
|
|
18
|
+
const { cookieConfig } = useRuntimeConfig().public;
|
|
19
|
+
const state = useState<GateState | null>("termsGate", () => null);
|
|
20
|
+
|
|
21
|
+
async function load(): Promise<TermsGateReply | null> {
|
|
22
|
+
try {
|
|
23
|
+
return await useNuxtApp().$api<TermsGateReply>("/api/users/terms", { timeout: TIMEOUT_MS, retry: 0 });
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** True when this account must accept before continuing. */
|
|
30
|
+
async function check(force = false): Promise<boolean> {
|
|
31
|
+
const sid = String(useCookie("sid", cookieConfig).value ?? "");
|
|
32
|
+
if (!sid) return false;
|
|
33
|
+
if (import.meta.client && navigator.onLine === false) return false;
|
|
34
|
+
|
|
35
|
+
const s = state.value;
|
|
36
|
+
if (!force && s && s.sid === sid) {
|
|
37
|
+
if (!s.failedAt || Date.now() - s.failedAt < FAIL_RETRY_MS) return s.mustAccept;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const reply = await load();
|
|
41
|
+
state.value = {
|
|
42
|
+
sid,
|
|
43
|
+
reply,
|
|
44
|
+
mustAccept: shouldPrompt(reply),
|
|
45
|
+
failedAt: reply ? 0 : Date.now(),
|
|
46
|
+
};
|
|
47
|
+
return state.value.mustAccept;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function accept(): Promise<void> {
|
|
51
|
+
const id = state.value?.reply?.latest?._id;
|
|
52
|
+
if (!id) throw new Error("No terms to accept.");
|
|
53
|
+
await useNuxtApp().$api("/api/users/terms/accept", { method: "POST", body: { terms: id } });
|
|
54
|
+
const sid = String(useCookie("sid", cookieConfig).value ?? "");
|
|
55
|
+
state.value = { sid, reply: state.value?.reply ?? null, mustAccept: false, failedAt: 0 };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** "Not now" after the server could not record it: let them in, ask again soon. */
|
|
59
|
+
function deferAfterFailure() {
|
|
60
|
+
const sid = String(useCookie("sid", cookieConfig).value ?? "");
|
|
61
|
+
state.value = { sid, reply: state.value?.reply ?? null, mustAccept: false, failedAt: Date.now() };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { state, check, accept, deferAfterFailure };
|
|
65
|
+
}
|
package/middleware/01.auth.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { termsGateSkips } from "../utils/terms-gate";
|
|
2
|
+
|
|
1
3
|
export default defineNuxtRouteMiddleware(async (route) => {
|
|
2
4
|
const { cookieConfig } = useRuntimeConfig().public;
|
|
3
5
|
|
|
@@ -12,4 +14,13 @@ export default defineNuxtRouteMiddleware(async (route) => {
|
|
|
12
14
|
// Redirect to login page if no access token
|
|
13
15
|
return navigateTo({ name: "index" });
|
|
14
16
|
}
|
|
17
|
+
|
|
18
|
+
// Terms and Privacy Policy: ask once per account per published version.
|
|
19
|
+
// Fails open; print/PDF/preview/kiosk/public (plain layout) pages are skipped.
|
|
20
|
+
if (import.meta.client && !termsGateSkips(route, navigator.userAgent)) {
|
|
21
|
+
const { check } = useTermsGate();
|
|
22
|
+
if (await check()) {
|
|
23
|
+
return navigateTo({ path: "/accept-terms", query: { redirect: route.fullPath } });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
15
26
|
});
|
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.
|
|
5
|
+
"version": "4.118.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,126 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-container class="py-8" style="max-width: 860px">
|
|
3
|
+
<v-card class="pa-4 pa-sm-6" variant="flat" border>
|
|
4
|
+
<h1 class="text-h5 font-weight-bold mb-1">Terms & Privacy Policy</h1>
|
|
5
|
+
<p class="text-body-2 text-medium-emphasis mb-4">
|
|
6
|
+
Please read and accept our Terms & Conditions and Privacy Policy to continue.
|
|
7
|
+
<template v-if="version">(Version {{ version }})</template>
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
<div v-if="loading" class="d-flex justify-center py-8">
|
|
11
|
+
<v-progress-circular indeterminate />
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<template v-else>
|
|
15
|
+
<div class="terms-scroll mb-4">
|
|
16
|
+
<h2 v-if="termsHtml" class="text-subtitle-1 font-weight-bold mb-2">Terms & Conditions</h2>
|
|
17
|
+
<!-- Published by Seven365 in Super Admin -> Terms & Privacy Policy. -->
|
|
18
|
+
<div v-if="termsHtml" class="platform-terms mb-6" v-html="termsHtml" />
|
|
19
|
+
<h2 v-if="policiesHtml" class="text-subtitle-1 font-weight-bold mb-2">Privacy Policy</h2>
|
|
20
|
+
<div v-if="policiesHtml" class="platform-terms" v-html="policiesHtml" />
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<v-checkbox
|
|
24
|
+
v-model="agreed"
|
|
25
|
+
hide-details
|
|
26
|
+
density="comfortable"
|
|
27
|
+
label="I have read and accept the Terms & Conditions and Privacy Policy"
|
|
28
|
+
/>
|
|
29
|
+
|
|
30
|
+
<v-alert v-if="errorText" type="error" variant="tonal" class="mt-3" density="compact">
|
|
31
|
+
{{ errorText }}
|
|
32
|
+
<div v-if="failed" class="mt-2">
|
|
33
|
+
<a href="#" @click.prevent="notNow">Continue for now (we will ask again next time)</a>
|
|
34
|
+
</div>
|
|
35
|
+
</v-alert>
|
|
36
|
+
|
|
37
|
+
<div class="d-flex flex-wrap ga-3 mt-4">
|
|
38
|
+
<v-btn color="primary" :disabled="!agreed" :loading="saving" @click="onAccept">I accept</v-btn>
|
|
39
|
+
<v-btn variant="text" :disabled="saving" @click="onSignOut">Sign out</v-btn>
|
|
40
|
+
</div>
|
|
41
|
+
</template>
|
|
42
|
+
</v-card>
|
|
43
|
+
</v-container>
|
|
44
|
+
</template>
|
|
45
|
+
|
|
46
|
+
<script setup lang="ts">
|
|
47
|
+
import { safeRedirect } from "../utils/terms-gate";
|
|
48
|
+
|
|
49
|
+
// No 01-auth here (no redirect loop); signed-out visitors are sent to sign-in.
|
|
50
|
+
definePageMeta({ layout: "plain", skipTermsGate: true });
|
|
51
|
+
useHead({ title: "Terms & Privacy Policy" });
|
|
52
|
+
|
|
53
|
+
const route = useRoute();
|
|
54
|
+
const { cookieConfig } = useRuntimeConfig().public;
|
|
55
|
+
const { state, check, accept, deferAfterFailure } = useTermsGate();
|
|
56
|
+
const { logout } = useLocalAuth();
|
|
57
|
+
|
|
58
|
+
const loading = ref(true);
|
|
59
|
+
const saving = ref(false);
|
|
60
|
+
const agreed = ref(false);
|
|
61
|
+
const failed = ref(false);
|
|
62
|
+
const errorText = ref("");
|
|
63
|
+
|
|
64
|
+
const latest = computed(() => state.value?.reply?.latest ?? null);
|
|
65
|
+
const version = computed(() => latest.value?.version);
|
|
66
|
+
const termsHtml = computed(() => sanitizeHtml(latest.value?.terms));
|
|
67
|
+
const policiesHtml = computed(() => sanitizeHtml(latest.value?.policies));
|
|
68
|
+
|
|
69
|
+
function goBack() {
|
|
70
|
+
return navigateTo(safeRedirect(route.query.redirect), { replace: true });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
onMounted(async () => {
|
|
74
|
+
if (!useCookie("sid", cookieConfig).value) {
|
|
75
|
+
await navigateTo({ name: "index" });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
// Re-ask the server: already accepted (another tab/device/app), nothing
|
|
79
|
+
// published, or the server is down -> straight through.
|
|
80
|
+
if (!(await check(true))) {
|
|
81
|
+
await goBack();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
loading.value = false;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
async function onAccept() {
|
|
88
|
+
saving.value = true;
|
|
89
|
+
errorText.value = "";
|
|
90
|
+
failed.value = false;
|
|
91
|
+
try {
|
|
92
|
+
await accept();
|
|
93
|
+
await goBack();
|
|
94
|
+
} catch (e: any) {
|
|
95
|
+
failed.value = true;
|
|
96
|
+
errorText.value =
|
|
97
|
+
e?.statusCode === 400
|
|
98
|
+
? "A newer version was just published. Please reload this page."
|
|
99
|
+
: "We could not save your acceptance. Please try again.";
|
|
100
|
+
} finally {
|
|
101
|
+
saving.value = false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function notNow() {
|
|
106
|
+
deferAfterFailure();
|
|
107
|
+
await goBack();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function onSignOut() {
|
|
111
|
+
await logout();
|
|
112
|
+
await navigateTo({ name: "index" });
|
|
113
|
+
}
|
|
114
|
+
</script>
|
|
115
|
+
|
|
116
|
+
<style scoped>
|
|
117
|
+
.terms-scroll {
|
|
118
|
+
max-height: 55vh;
|
|
119
|
+
overflow-y: auto;
|
|
120
|
+
padding-right: 8px;
|
|
121
|
+
}
|
|
122
|
+
.platform-terms {
|
|
123
|
+
line-height: 1.6;
|
|
124
|
+
overflow-wrap: anywhere;
|
|
125
|
+
}
|
|
126
|
+
</style>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure rules for the post-login "Accept Terms and Privacy Policy" gate, kept
|
|
3
|
+
* apart from Nuxt so they can be unit-tested. The server decides who must
|
|
4
|
+
* accept (GET /api/users/terms -> mustAccept, per ACCOUNT); an account that has
|
|
5
|
+
* accepted the live version is never asked again on any device or app.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type TermsGateReply = {
|
|
9
|
+
latest?: { _id?: string; version?: number; terms?: string; policies?: string } | null;
|
|
10
|
+
mustAccept?: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Gate only when the server positively says so AND sent text to accept. */
|
|
14
|
+
export function shouldPrompt(reply: TermsGateReply | null | undefined): boolean {
|
|
15
|
+
if (!reply || reply.mustAccept !== true) return false;
|
|
16
|
+
const latest = reply.latest;
|
|
17
|
+
return Boolean(latest?._id && (latest.terms || latest.policies));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const NEVER_GATED_LAYOUTS = ["plain", "plain-dark"];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Pages that must never be interrupted: print/PDF/preview/kiosk/public pages
|
|
24
|
+
* (they use the plain layouts), the accept page itself, an explicit opt-out,
|
|
25
|
+
* and a headless browser (the PDF service).
|
|
26
|
+
*/
|
|
27
|
+
export function termsGateSkips(
|
|
28
|
+
to: { path?: string; meta?: Record<string, unknown> },
|
|
29
|
+
userAgent = "",
|
|
30
|
+
): boolean {
|
|
31
|
+
if (to.path === "/accept-terms") return true;
|
|
32
|
+
const meta = to.meta ?? {};
|
|
33
|
+
if (meta.skipTermsGate) return true;
|
|
34
|
+
if (typeof meta.layout === "string" && NEVER_GATED_LAYOUTS.includes(meta.layout)) return true;
|
|
35
|
+
return /HeadlessChrome|Puppeteer/i.test(userAgent);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Only ever go back to a path on this site. */
|
|
39
|
+
export function safeRedirect(value: unknown): string {
|
|
40
|
+
const path = Array.isArray(value) ? value[0] : value;
|
|
41
|
+
if (typeof path !== "string" || !path.startsWith("/") || path.startsWith("//") || path.includes("\\")) {
|
|
42
|
+
return "/";
|
|
43
|
+
}
|
|
44
|
+
return path;
|
|
45
|
+
}
|