16 Commits

Author SHA1 Message Date
Emily
7658dbe85c fix members 2025-03-26 16:15:46 +01:00
Emily
1f9ef5d18c add payment service 2025-03-26 15:30:22 +01:00
Emily
94a28b31d3 update shields 2025-03-24 18:54:15 +01:00
Emily
87c9aca5c4 shields update 2025-03-20 16:04:00 +01:00
Emily
afda29997d Fix 2025-03-14 16:40:50 +01:00
Emily
d1b3e997c1 fix bug on settings page 2025-03-11 15:14:37 +01:00
Emily
be82f7046f fix projection UI on chart 2025-03-11 13:24:53 +01:00
Emily
45e9a9c6a7 update snapthots + admin panel users 2025-03-10 15:54:00 +01:00
Emily
942d074f99 refactoring 2025-03-06 10:55:46 +01:00
Emily
63fa3995c5 refactoring 2025-03-03 19:31:35 +01:00
Emily
76e5e07f79 Merge pull request #29 from wpgaurav/wpgaurav-patch-2
Fallback fonts
2025-02-17 14:20:04 +01:00
Emily
b8f9e598a7 Merge pull request #30 from wpgaurav/wpgaurav-patch-1
Switch from Google Fonts to Bunny Fonts and other privacy first alternatives
2025-02-17 14:19:46 +01:00
Emily
0ee4895e1a Merge pull request #31 from wpgaurav/main
Compress file sizes
2025-02-17 14:19:25 +01:00
Gaurav Tiwari
72d6b97383 Compress file sizes
40% size reduction
2025-02-15 22:40:14 +05:30
Gaurav Tiwari
3f22c655a5 Fallback fonts 2025-02-15 22:34:27 +05:30
Gaurav Tiwari
4fea549a5a Switch from Google Fonts to Bunny Fonts and other privacy first alternatives 2025-02-15 22:29:02 +05:30
148 changed files with 6786 additions and 553 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@@ -112,7 +112,7 @@ async function process_visit(data: Record<string, string>, sessionHash: string)
async function process_keep_alive(data: Record<string, string>, sessionHash: string) {
const { pid, instant, flowHash, timestamp } = data;
const { pid, instant, flowHash, timestamp, website } = data;
const existingSession = await SessionModel.findOne({ project_id: pid, session: sessionHash }, { _id: 1 });
if (!existingSession) {
@@ -123,13 +123,15 @@ async function process_keep_alive(data: Record<string, string>, sessionHash: str
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
$inc: { duration: 0 },
flowHash,
updated_at: Date.now()
website,
updated_at: new Date(parseInt(timestamp))
}, { upsert: true });
} else {
await SessionModel.updateOne({ project_id: pid, session: sessionHash, }, {
$inc: { duration: 1 },
flowHash,
updated_at: Date.now()
website,
updated_at: new Date(parseInt(timestamp))
}, { upsert: true });
}
@@ -137,7 +139,7 @@ async function process_keep_alive(data: Record<string, string>, sessionHash: str
async function process_event(data: Record<string, string>, sessionHash: string) {
const { name, metadata, pid, flowHash, timestamp } = data;
const { name, metadata, pid, flowHash, timestamp, website } = data;
let metadataObject;
try {
@@ -149,6 +151,7 @@ async function process_event(data: Record<string, string>, sessionHash: string)
await Promise.all([
EventModel.create({
project_id: pid, name, flowHash, metadata: metadataObject, session: sessionHash,
website,
created_at: new Date(parseInt(timestamp))
}),
ProjectCountModel.updateOne({ project_id: pid }, { $inc: { 'events': 1 } }, { upsert: true }),

9
dashboard/app.config.ts Normal file
View File

@@ -0,0 +1,9 @@
export default defineAppConfig({
ui: {
notifications: {
position: 'top-0 bottom-[unset]'
}
}
})

View File

@@ -69,6 +69,7 @@ const { drawerVisible, hideDrawer, drawerClasses } = useDrawer();
<UModals />
<UNotifications />
<LazyOnboarding> </LazyOnboarding>

View File

@@ -1,13 +1,14 @@
@import './font-awesome/css/all.css';
/* Are these many fonts required? For the time being switching to privacy friendly bunny.net for Google fonts. NOTE: No variable font support in bunnet.net yet. */
@import url('https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;0,1000;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900;1,1000&display=swap');
@import url('https://fonts.bunny.net/css?family=nunito:300,300i,400,400i,500,500i,600,600i,700,700i,800,800i,900,900i');
@import url('https://fonts.cdnfonts.com/css/brockmann');
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap');
@import url('https://fonts.bunny.net/css?family=inter:300,300i,400,400i,500,500i,600,600i,700,700i,800,800i,900,900i');
@import url('https://fonts.cdnfonts.com/css/geometric-sans-serif-v1');
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');
@import url('https://fonts.bunny.net/css?family=manrope:300,400,500,600,700,800');
@import url('https://fonts.bunny.net/css?family=lato:300,300i,400,400i,500,500i,600,600i,700,700i,800,800i,900,900i');
@import url('https://fonts.bunny.net/css?family=poppins:300,300i,400,400i,500,500i,600,600i,700,700i,800,800i,900,900i');
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0');
@import url('https://cdn.jsdelivr.net/npm/material-symbols@0.28.2/index.css');

View File

@@ -1,12 +1,16 @@
@use './utilities.scss';
@use './colors.scss';
:root{
--font-sans: "SF Pro Text","SF Pro Icons", "SF Pro Display", -apple-system, BlinkMacSystemFont, "Segoe UI", "Google Sans", "Helvetica Neue", Helvetica, "Apple Color Emoji", Arial, sans-serif, "Segoe UI Emoji", "Segoe UI Symbol"
}
@font-face {
font-family: "Geist";
src: url("../fonts/GeistVF.ttf");
}
.actionable-visits-color-checkbox {
color: #5655d7;
}
@@ -19,7 +23,7 @@
}
.geist {
font-family: "Geist";
font-family: "Geist", var(--font-sans);
}
@@ -34,38 +38,38 @@
}
.brockmann {
font-family: "Brockmann" !important;
font-family: "Brockmann", var(--font-sans)!important;
}
.nunito {
font-family: "Nunito" !important;
font-family: "Nunito",var(--font-sans)!important;
}
.inter {
font-family: "Inter" !important;
font-family: "Inter", var(--font-sans)!important;
}
.geometric {
font-family: 'Geometric Sans Serif v1' !important;
font-family: "Geometric Sans Serif v1", var(--font-sans)!important;
}
.manrope {
font-family: 'Manrope' !important;
font-family: "Manrope", var(--font-sans)!important;
}
.lato {
font-family: 'Lato' !important;
font-family: "Lato", var(--font-sans)!important;
}
.poppins {
font-family: 'Poppins' !important;
font-family: "Poppins", var(--font-sans)!important;
}
.poppins-childs {
font-family: 'Poppins' !important;
font-family: "Poppins", var(--font-sans)!important;
* {
font-family: 'Poppins' !important;
font-family: "Poppins", var(--font-sans)!important;
}
}
@@ -105,5 +109,5 @@ body {
}
* {
font-family: 'Nunito';
font-family: 'Nunito', var(--font-sans);
}

View File

@@ -54,7 +54,7 @@ onMounted(() => {
<div class="flex overflow-x-auto hide-scrollbars">
<div class="flex">
<div v-for="(tab, index) of items" @click="onChangeTab(index)"
class="px-6 pb-3 poppins font-medium text-lyx-lightmode-text dark:text-lyx-text-darker border-b-[1px] border-lyx-text-darker"
class="px-6 whitespace-nowrap pb-3 poppins font-medium text-lyx-lightmode-text dark:text-lyx-text-darker border-b-[1px] border-lyx-text-darker"
:class="{
'dark:!border-[#FFFFFF] dark:!text-[#FFFFFF] !border-lyx-primary !text-lyx-primary': activeTabIndex === index,
'hover:border-lyx-lightmode-text-dark hover:text-lyx-lightmode-text-dark/60 dark:hover:border-lyx-text-dark dark:hover:text-lyx-text-dark cursor-pointer': activeTabIndex !== index

View File

@@ -79,13 +79,13 @@ function reloadPage() {
</div>
<div class="flex items-center justify-center mt-10">
<div class="flex items-center justify-center mt-10 w-full px-10">
<div class="flex flex-col gap-6">
<div class="flex gap-6 xl:flex-row flex-col">
<div class="h-full w-full">
<CardTitled class="h-full w-full xl:min-w-[500px] xl:h-[35rem]" title="Quick setup tutorial"
<CardTitled class="h-full w-full xl:min-w-[400px] xl:h-[35rem]" title="Quick setup tutorial"
sub="Quickly Set Up Litlyx in 30 Seconds!">
<div class="flex items-center justify-center h-full w-full">
@@ -133,6 +133,28 @@ function reloadPage() {
</div>
</div>
<div>
<div>
<CardTitled class="w-full h-full" title="Wordpress + Elementor"
sub="Our WordPress plugin is coming soon!.">
<template #header>
<LyxUiButton @click="Lit.event('no_visit_goto_docs')" type="secondary"
to="https://docs.litlyx.com">
Visit documentation
</LyxUiButton>
</template>
<div class="flex flex-col items-end">
<div class="justify-center w-full hidden xl:flex gap-3">
<a href="#">
<img class="cursor-pointer" :src="'tech-icons/wpel.png'" alt="Litlyx-Wordpress-Elementor">
</a>
</div>
</div>
</CardTitled>
</div>
</div>
<div>
<div>
<CardTitled class="w-full h-full" title="Modules"

View File

@@ -11,5 +11,5 @@ const widgetStyle = computed(() => {
</script>
<template>
<div :style="widgetStyle" class="bg-lyx-widget-light"></div>
<div :style="widgetStyle" class="dark:bg-lyx-widget-light bg-lyx-lightmode-widget"></div>
</template>

View File

@@ -9,7 +9,23 @@ const avgDuration = computed(() => {
return (backendData.value.durations.durations.reduce((a: any, e: any) => a + parseInt(e[1]), 0) / backendData.value.durations.durations.length);
})
const labels = new Array(650).fill('-');
const labels = computed(() => {
if (!backendData?.value?.durations) return [];
const sizes = new Map<string, number>();
for (const e of backendData.value.durations.durations) {
if (!sizes.has(e[0])) {
sizes.set(e[0], 0);
} else {
const data = sizes.get(e[0]) ?? 0;
sizes.set(e[0], data + 1);
}
}
const max = Array.from(sizes.values()).reduce((a, e) => a > e ? a : e, 0);
return new Array(max).fill('-');
});
const durationsDatasets = computed(() => {
if (!backendData?.value?.durations) return [];
@@ -45,7 +61,7 @@ const durationsDatasets = computed(() => {
<div class="cursor-default flex justify-center w-full">
<div v-if="backendData" class="flex flex-col mt-8 gap-6 px-20 items-center w-full">
<div v-if="backendData && !backendPending" class="flex flex-col mt-8 gap-6 px-20 items-center w-full">
<div class="flex gap-8">
<div> Queue size: {{ backendData.queue?.size || 'ERROR' }} </div>

View File

@@ -88,12 +88,15 @@ onMounted(() => {
const filter = ref<string>('{}');
const { data: projectsInfo, pending: pendingProjects } = await useFetch<{ count: number, projects: TAdminProject[] }>(
const { data: projectsInfo, pending: pendingProjects } = useFetch<{ count: number, projects: TAdminProject[] }>(
() => `/api/admin/projects?page=${page.value - 1}&limit=${limit.value}&sortQuery=${order.value}&filterQuery=${filter.value}&filterFrom=${selected.value.start.toISOString()}&filterTo=${selected.value.end.toISOString()}`,
signHeaders()
);
const { data: metrics, pending: pendingMetrics } = useFetch(
() => `/api/admin/metrics?filterFrom=${selected.value.start.toISOString()}&filterTo=${selected.value.end.toISOString()}`,
signHeaders()
);
const { uiMenu } = useSelectMenuStyle();
@@ -165,6 +168,23 @@ const { uiMenu } = useSelectMenuStyle();
</UPopover>
</div>
<div class="w-[80%]">
<div v-if="pendingMetrics"> Loading... </div>
<div class="flex gap-10 flex-wrap" v-if="!pendingMetrics && metrics">
<div> Projects: {{ metrics.totalProjects }} ({{ metrics.premiumProjects }} premium) </div>
<div>
Total visits: {{ formatNumberK(metrics.totalVisits) }}
</div>
<div>
Active: {{ metrics.totalProjects - metrics.deadProjects }} |
Dead: {{ metrics.deadProjects }}
</div>
<div>
Total events: {{ formatNumberK(metrics.totalEvents) }}
</div>
</div>
</div>
</div>

View File

@@ -2,14 +2,34 @@
import { useSelectMenuStyle } from '~/composables/ui/useSelectMenuStyle';
import type { TAdminUser } from '~/server/api/admin/users';
import { sub, format, isSameDay, type Duration, startOfDay, endOfDay } from 'date-fns'
const filterText = ref<string>('');
watch(filterText,()=>{
watch(filterText, () => {
page.value = 1;
})
function isRangeSelected(duration: Duration) {
return isSameDay(selected.value.start, sub(new Date(), duration)) && isSameDay(selected.value.end, new Date())
}
function selectRange(duration: Duration) {
selected.value = { start: sub(new Date(), duration), end: new Date() }
}
const ranges = [
{ label: 'Last 7 days', duration: { days: 7 } },
{ label: 'Last 14 days', duration: { days: 14 } },
{ label: 'Last 30 days', duration: { days: 30 } },
{ label: 'Last 3 months', duration: { months: 3 } },
{ label: 'Last 6 months', duration: { months: 6 } },
{ label: 'Last year', duration: { years: 1 } }
]
const selected = ref<{ start: Date, end: Date }>({ start: sub(new Date(), { days: 14 }), end: new Date() })
const filter = computed(() => {
return JSON.stringify({
$or: [
@@ -39,7 +59,7 @@ const limitList = [
const limit = ref<number>(20);
const { data: usersInfo, pending: pendingUsers } = await useFetch<{ count: number, users: TAdminUser[] }>(
() => `/api/admin/users?page=${page.value - 1}&limit=${limit.value}&sortQuery=${order.value}&filterQuery=${filter.value}`,
() => `/api/admin/users?page=${page.value - 1}&limit=${limit.value}&sortQuery=${order.value}&filterQuery=${filter.value}&filterFrom=${selected.value.start.toISOString()}&filterTo=${selected.value.end.toISOString()}`,
signHeaders()
);
@@ -51,8 +71,9 @@ const { uiMenu } = useSelectMenuStyle();
<div class="mt-6 h-full">
<div class="flex items-center gap-10 px-10">
<div class="flex flex-col items-center gap-6">
<div class="flex items-center gap-10 px-10">
<div class="flex gap-2 items-center">
<div>Order:</div>
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Order" :options="ordersList"
@@ -71,7 +92,9 @@ const { uiMenu } = useSelectMenuStyle();
<div class="flex gap-2 items-center">
<LyxUiInput placeholder="Search user" class="px-2 py-1" v-model="filterText"></LyxUiInput>
</div>
</div>
<div class="flex items-centet gap-10">
<div class="flex gap-2 items-center">
<div>Page {{ page }} </div>
<div>
@@ -85,7 +108,31 @@ const { uiMenu } = useSelectMenuStyle();
<UPagination v-model="page" :page-count="limit" :total="usersInfo?.count || 0" />
</div>
<UPopover class="w-[20rem]" :popper="{ placement: 'bottom' }">
<UButton class="w-full" color="primary" variant="solid">
<div class="flex items-center justify-center w-full gap-2">
<i class="i-heroicons-calendar-days-20-solid"></i>
{{ selected.start.toLocaleDateString() }} - {{ selected.end.toLocaleDateString() }}
</div>
</UButton>
<template #panel="{ close }">
<div class="flex items-center sm:divide-x divide-gray-200 dark:divide-gray-800">
<div class="hidden sm:flex flex-col py-4">
<UButton v-for="(range, index) in ranges" :key="index" :label="range.label" color="gray"
variant="ghost" class="rounded-none px-6"
:class="[isRangeSelected(range.duration) ? 'bg-gray-100 dark:bg-gray-800' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50']"
truncate @click="selectRange(range.duration)" />
</div>
<DatePicker v-model="selected" @close="close" />
</div>
</template>
</UPopover>
</div>
</div>

View File

@@ -135,7 +135,7 @@ const chartData = ref<ChartData<'line' | 'bar' | 'bubble'>>({
type: 'bubble',
stack: 'combined',
borderColor: ["#fbbf24"]
},
}
],
});
@@ -198,11 +198,16 @@ const selectLabelsAvailable = computed<{ label: string, value: Slice, disabled:
})
const selectedSlice = computed<Slice>(() => {
console.log({ available: selectLabelsAvailable.value })
const targetValue = selectLabelsAvailable.value[selectedLabelIndex.value];
if (!targetValue) return 'day';
if (targetValue.disabled) {
selectedLabelIndex.value = selectLabelsAvailable.value.findIndex(e => !e.disabled);
}
if (selectedLabelIndex.value === -1) {
console.error('ERROR CANNOT FIND CORRECT SLICE')
return 'day';
}
return selectLabelsAvailable.value[selectedLabelIndex.value].value
});
@@ -215,9 +220,14 @@ function transformResponse(input: { _id: string, count: number }[]) {
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), selectedSlice.value));
if (input.length > 0) allDatesFull.value = input.map(e => e._id.toString());
const todayIndex = input.findIndex(e => new Date(e._id).getTime() > (Date.now() - new Date().getTimezoneOffset() * 1000 * 60));
const current = (Date.now());
//console.log(input.map(e => e._id));
//console.log(new Date(current));
return { data, labels, todayIndex }
const todayIndex = input.findIndex(e => new Date(e._id).getTime() >= current);
//console.log({ todayIndex })
return { data, labels, todayIndex: todayIndex + 1 }
}
function onResponseError(e: any) {
@@ -276,7 +286,6 @@ function onDataReady() {
chartData.value.datasets[1].backgroundColor = [createGradient('#4abde8')];
chartData.value.datasets[2].backgroundColor = [createGradient('#fbbf24')];
(chartData.value.datasets[1] as any).borderSkipped = sessionsData.data.value.data.map((e, i) => {
const todayIndex = eventsData.data.value?.todayIndex || 0;
if (i == todayIndex - 1) return true;
@@ -362,6 +371,12 @@ const legendClasses = ref<string[]>([
{{ (currentTooltipData as any)[tooltipNameIndex[index]] }}
</div>
</div>
<div class="mt-3 font-normal flex flex-col text-[.9rem] dark:text-lyx-text-dark text-lyx-lightmode-text-dark"
v-if="(currentTooltipData as any).sessions > (currentTooltipData as any).visits">
<div> Unique visitors are higher than total visits </div>
<div> which often means bots (automated scripts or crawlers)</div>
<div> are inflating the numbers.</div>
</div>
<!-- <div class="bg-lyx-background-lighter h-[2px] w-full my-2"> </div> -->
</LyxUiCard>
</div>

View File

@@ -8,7 +8,7 @@ const { snapshot, safeSnapshotDates, snapshotDuration } = useSnapshot()
const chartSlice = computed(() => {
if (snapshotDuration.value <= 3) return 'hour' as Slice;
if (snapshotDuration.value <= 32) return 'day' as Slice;
if (snapshotDuration.value <= 31 * 3) return 'day' as Slice;
return 'month' as Slice;
});
@@ -68,27 +68,53 @@ const avgBouncingRate = computed(() => {
return avg.toFixed(2) + ' %';
})
function weightedAverage(data: number[]): number {
if (data.length === 0) return 0;
// Compute median
const sortedData = [...data].sort((a, b) => a - b);
const middle = Math.floor(sortedData.length / 2);
const median = sortedData.length % 2 === 0
? (sortedData[middle - 1] + sortedData[middle]) / 2
: sortedData[middle];
// Define a threshold (e.g., 3 times the median) to filter out extreme values
const threshold = median * 3;
const filteredData = data.filter(num => num <= threshold);
if (filteredData.length === 0) return median; // Fallback to median if all are removed
// Compute weights based on inverse absolute deviation from median
const weights = filteredData.map(num => 1 / (1 + Math.abs(num - median)));
// Compute weighted sum and sum of weights
const weightedSum = filteredData.reduce((sum, num, i) => sum + num * weights[i], 0);
const sumOfWeights = weights.reduce((sum, weight) => sum + weight, 0);
return weightedSum / sumOfWeights;
}
const avgSessionDuration = computed(() => {
if (!sessionsDurationData.data.value) return '0.00 %'
const counts = sessionsDurationData.data.value.data
.filter(e => e > 0)
// .filter(e => e > 0)
.reduce((a, e) => e + a, 0);
const avg = counts / (Math.max(sessionsDurationData.data.value.data.filter(e => e > 0).length, 1)) / 5;
const avg = weightedAverage(sessionsDurationData.data.value.data);
// counts / (Math.max(sessionsDurationData.data.value.data.length, 1));
let hours = 0;
let minutes = 0;
let seconds = 0;
seconds += avg * 60;
while (seconds > 60) { seconds -= 60; minutes += 1; }
while (minutes > 60) { minutes -= 60; hours += 1; }
while (seconds >= 60) { seconds -= 60; minutes += 1; }
while (minutes >= 60) { minutes -= 60; hours += 1; }
return `${hours > 0 ? hours + 'h ' : ''}${minutes}m ${seconds.toFixed()}s`
});
const todayIndex = computed(() => {
if (!visitsData.data.value) return -1;
return visitsData.data.value.input.findIndex(e => new Date(e._id).getTime() > (Date.now() - new Date().getTimezoneOffset() * 1000 * 60));
return visitsData.data.value.input.findIndex(e => new Date(e._id).getTime() > (Date.now()));
})

View File

@@ -0,0 +1,38 @@
<script lang="ts" setup>
const emit = defineEmits(['success', 'cancel'])
</script>
<template>
<UModal :ui="{
strategy: 'override',
overlay: {
background: 'bg-lyx-background/85'
},
background: 'dark:bg-lyx-widget bg-lyx-lightmode-widget-light',
ring: 'border-solid border-[1px] border-[#262626]'
}">
<div class="h-full flex flex-col gap-2 p-4">
<div class="flex flex-col gap-3">
<div class="font-medium">
Are you sure to logout ?
</div>
<div class="flex justify-end gap-2">
<LyxUiButton type="secondary" @click="emit('cancel')">
Cancel
</LyxUiButton>
<LyxUiButton @click="emit('success')" type="danger">
Confirm
</LyxUiButton>
</div>
</div>
</div>
</UModal>
</template>

View File

@@ -107,7 +107,7 @@ async function confirmSnapshot() {
Cancel
</LyxUiButton>
<LyxUiButton @click="confirmSnapshot()" type="primary" class="w-full text-center"
:disabled="snapshotName.length == 0">
:disabled="snapshotName.trim().length == 0">
Confirm
</LyxUiButton>
</div>

View File

@@ -0,0 +1,86 @@
<script lang="ts" setup>
import Accept_invite from '~/pages/accept_invite.vue';
const { createAlert } = useAlert();
const { close } = useModal()
const emit = defineEmits(['success', 'cancel'])
const props = defineProps<{
invites: {
project_name: string, project_id: string
}[]
}>();
async function acceptInvite(project_id: string) {
try {
await $fetch('/api/project/members/accept', {
method: 'POST',
body: JSON.stringify({ project_id }),
headers: useComputedHeaders({
custom: {
'Content-Type': 'application/json'
}
}).value
});
emit('success');
} catch (ex) {
console.error(ex);
alert('Error accepting invite');
emit('cancel');
}
}
async function declineInvite(project_id: string) {
try {
await $fetch('/api/project/members/decline', {
method: 'POST',
body: JSON.stringify({ project_id }),
headers: useComputedHeaders({
custom: {
'Content-Type': 'application/json'
}
}).value
});
emit('success');
} catch (ex) {
console.error(ex);
alert('Error accepting invite');
emit('cancel');
}
}
</script>
<template>
<UModal :ui="{
strategy: 'override',
overlay: {
background: 'bg-lyx-background/85'
},
background: 'dark:bg-lyx-widget bg-lyx-lightmode-widget-light',
ring: 'border-solid border-[1px] border-[#262626]'
}">
<div class="h-full flex flex-col gap-8 p-6">
<div class="flex flex-col gap-6" v-for="invite of invites">
<div class="dark:text-lyx-text text-lyx-lightmode-text">
You are invited to join
<span class="font-semibold">{{ invite.project_name }}</span>.
Do you accept?
</div>
<div class="flex gap-4 w-full justify-end">
<LyxUiButton @click="declineInvite(invite.project_id)" type="secondary"> Decline </LyxUiButton>
<LyxUiButton @click="acceptInvite(invite.project_id)" type="primary"> Accept </LyxUiButton>
</div>
</div>
</div>
</UModal>
</template>

View File

@@ -0,0 +1,118 @@
<script lang="ts" setup>
import { useSelectMenuStyle } from '~/composables/ui/useSelectMenuStyle';
import type { TTeamMember } from '~/shared/schema/TeamMemberSchema';
const emit = defineEmits(['success', 'cancel'])
const props = defineProps<{ member_id: string }>();
const { domainList, domain, setActiveDomain, refreshDomains, refreshingDomains } = useDomain();
const { data: member } = useFetch<TTeamMember>(`/api/project/members/get?member_id=${props.member_id}`, {
headers: useComputedHeaders({})
})
const { createAlert } = useAlert()
async function save(member_id: string) {
if (!member.value) return;
const res = await $fetch('/api/project/members/edit', {
method: 'POST',
headers: useComputedHeaders({ custom: { 'Content-Type': 'application/json' } }).value,
body: JSON.stringify({
member_id,
webAnalytics: member.value.permission.webAnalytics,
events: member.value.permission.events,
ai: member.value.permission.ai,
domains: member.value.permission.domains
})
});
createAlert('Saved', 'Permission saved successfully', 'fas fa-check', 2500);
emit('success')
}
</script>
<template>
<UModal :ui="{
strategy: 'override',
overlay: {
background: 'bg-lyx-background/85'
},
background: 'bg-lyx-lightmode-widget dark:bg-lyx-widget',
ring: 'border-solid border-[1px] border-[#262626]'
}">
<div class="p-8">
<div v-if="member" class="manage flex flex-col gap-4">
<div class="flex flex-col gap-1">
<div class="poppins text-[1.1rem]"> Manage permissions </div>
<div class="poppins text-[.9rem] dark:text-lyx-text-dark"> Choose what this member can do on this project. </div>
</div>
<LyxUiSeparator></LyxUiSeparator>
<div class="flex flex-col gap-1">
<div>
<div class="mb-1"> Select what domain is allowed to see: </div>
<div class="mb-1">
<USelectMenu v-model="member.permission.domains" :options="domainList" multiple
value-attribute="_id">
<template #option="{ option, active, selected }">
<div class="flex items-center gap-2">
<div>
<img class="h-5 bg-black rounded-full" :src="'/logo_32.png'"
alt="Litlyx logo">
</div>
<div> {{ option._id }} </div>
</div>
</template>
<template #label="e">
<div class="flex items-center gap-2">
<div>
<img class="h-5 bg-black rounded-full" :src="'/logo_32.png'"
alt="Litlyx logo">
</div>
<div>
{{
member.permission.domains.length > 2 ?
`${member.permission.domains.length} domains` :
(member.permission.domains.map(e => e).join(' & ') || 'No domains')
}}
</div>
</div>
</template>
</USelectMenu>
</div>
</div>
<div class="flex items-center gap-2">
<UCheckbox v-model="member.permission.webAnalytics"></UCheckbox>
<div> Allow web analytics page </div>
</div>
<div class="flex items-center gap-2">
<UCheckbox v-model="member.permission.events"></UCheckbox>
<div> Allow events page </div>
</div>
<div class="flex items-center gap-2">
<UCheckbox v-model="member.permission.ai"></UCheckbox>
<div> Allow to use AI data analyst </div>
</div>
</div>
</div>
<div class="flex gap-2 justify-end mt-8">
<LyxUiButton class="!w-[6rem] text-center" type="secondary" @click="emit('cancel')"> Cancel </LyxUiButton>
<LyxUiButton class="!w-[6rem] text-center" v-if="member?.permission" @click="save(member._id.toString())" type="primary">
Save
</LyxUiButton>
</div>
</div>
</UModal>
</template>

View File

@@ -0,0 +1,80 @@
<script lang="ts" setup>
const emit = defineEmits(['success', 'cancel']);
const address = ref<string>('');
const description = ref<string>('');
const { data: currentIP } = useFetch<any>('https://api.ipify.org/?format=json');
const canAddAddress = computed(() => {
return address.value.trim().length > 0;
})
async function addAddress() {
if (!canAddAddress.value) return;
try {
const res = await $fetch('/api/shields/ip/add', {
method: 'POST',
headers: useComputedHeaders({}).value,
body: JSON.stringify({ address: address.value, description: description.value })
});
address.value = '';
emit('success');
} catch (ex: any) {
alert(ex.message);
emit('cancel');
}
}
</script>
<template>
<UModal :ui="{
strategy: 'override',
overlay: {
background: 'bg-lyx-background/85'
},
background: 'dark:bg-lyx-widget bg-lyx-lightmode-widget-light',
ring: 'border-solid border-[1px] border-[#262626]'
}">
<div class="h-full flex flex-col gap-2 p-4">
<div class="flex flex-col gap-3">
<div class="font-semibold text-[1.1rem]"> Add IP to Block List </div>
<div class="flex flex-col gap-2 dark:text-lyx-text-dark text-lyx-lightmode-text-dark">
<div> Your current IP address is: {{ currentIP?.ip || '...' }} </div>
<div> Copy and Paste your IP address in the box below or enter a custom address </div>
</div>
<div class="flex flex-col gap-2">
<div class="font-medium"> IP Address </div>
<LyxUiInput class="px-2 py-1" v-model="address" placeholder="127.0.0.1"></LyxUiInput>
</div>
<div class="flex flex-col gap-2">
<div class="font-medium"> Description (optional) </div>
<LyxUiInput class="px-2 py-1" v-model="description" placeholder="e.g. localhost or office">
</LyxUiInput>
</div>
<div class="flex flex-col gap-2 dark:text-lyx-text-dark text-lyx-lightmode-text-dark">
<div> Once added, we will start rejecting traffic from this IP within a few minutes.</div>
</div>
<div class="flex">
<LyxUiButton class="w-full text-center" :disabled="!canAddAddress" @click="addAddress()"
type="primary">
Add IP Address
</LyxUiButton>
</div>
</div>
</div>
</UModal>
</template>

View File

@@ -0,0 +1,67 @@
<script lang="ts" setup>
const emit = defineEmits(['success', 'cancel']);
const domain = ref<string>('');
const canAddDomain = computed(() => {
return domain.value.trim().length > 0;
})
async function addDomain() {
if (!canAddDomain.value) return;
try {
const res = await $fetch('/api/shields/domains/add', {
method: 'POST',
headers: useComputedHeaders({}).value,
body: JSON.stringify({ domain: domain.value })
});
domain.value = '';
emit('success');
} catch (ex: any) {
alert(ex.message);
emit('cancel');
}
}
</script>
<template>
<UModal :ui="{
strategy: 'override',
overlay: {
background: 'bg-lyx-background/85'
},
background: 'dark:bg-lyx-widget bg-lyx-lightmode-widget-light',
ring: 'border-solid border-[1px] border-[#262626]'
}">
<div class="h-full flex flex-col gap-2 p-4">
<div class="flex flex-col gap-3">
<div class="font-semibold text-[1.1rem]"> Add Domain to Allow List </div>
<LyxUiInput class="px-2 py-1" v-model="domain"></LyxUiInput>
<div class="flex flex-col gap-2 dark:text-lyx-text-dark text-lyx-lightmode-text-dark">
<div>
<div> You can use a wildcard (*) to match multiple hostnames. </div>
<div> For example, *.domain.com will only record traffic on the main domain and all the
subdomains.
</div>
</div>
<div> NB: Once added, we will start allowing traffic only from matching hostnames within a few
minutes.</div>
</div>
<div class="flex">
<LyxUiButton class="w-full text-center" :disabled="!canAddDomain" @click="addDomain()" type="primary">
Add domain
</LyxUiButton>
</div>
</div>
</div>
</UModal>
</template>

View File

@@ -0,0 +1,56 @@
<script lang="ts" setup>
const emit = defineEmits(['success', 'cancel']);
const props = defineProps<{ address: string }>();
async function deleteAddress() {
if (!props.address) return;
try {
const res = await $fetch('/api/shields/ip/delete', {
method: 'DELETE',
headers: useComputedHeaders({}).value,
body: JSON.stringify({ address: props.address })
});
emit('success');
} catch (ex: any) {
alert(ex.message);
emit('cancel');
}
}
</script>
<template>
<UModal :ui="{
strategy: 'override',
overlay: {
background: 'bg-lyx-background/85'
},
background: 'dark:bg-lyx-widget bg-lyx-lightmode-widget-light',
ring: 'border-solid border-[1px] border-[#262626]'
}">
<div class="h-full flex flex-col gap-2 p-4">
<div class="flex flex-col gap-3">
<div class="font-semibold text-[1.1rem]"> IP Address delete </div>
<div> Are you sure to delete the blacklisted IP Address
<span class="font-semibold">{{ props.address }}</span>
</div>
<div class="flex justify-end gap-2">
<LyxUiButton type="secondary" @click="emit('cancel')">
Cancel
</LyxUiButton>
<LyxUiButton @click="deleteAddress()" type="danger">
Delete
</LyxUiButton>
</div>
</div>
</div>
</UModal>
</template>

View File

@@ -0,0 +1,56 @@
<script lang="ts" setup>
const emit = defineEmits(['success', 'cancel']);
const props = defineProps<{ domain: string }>();
async function deleteDomain() {
if (!props.domain) return;
try {
const res = await $fetch('/api/shields/domains/delete', {
method: 'DELETE',
headers: useComputedHeaders({}).value,
body: JSON.stringify({ domain: props.domain })
});
emit('success');
} catch (ex: any) {
alert(ex.message);
emit('cancel');
}
}
</script>
<template>
<UModal :ui="{
strategy: 'override',
overlay: {
background: 'bg-lyx-background/85'
},
background: 'dark:bg-lyx-widget bg-lyx-lightmode-widget-light',
ring: 'border-solid border-[1px] border-[#262626]'
}">
<div class="h-full flex flex-col gap-2 p-4">
<div class="flex flex-col gap-3">
<div class="font-semibold text-[1.1rem]"> Domain delete </div>
<div> Are you sure to delete the whitelisted domain
<span class="font-semibold">{{ props.domain }}</span>
</div>
<div class="flex justify-end gap-2">
<LyxUiButton type="secondary" @click="emit('cancel')">
Cancel
</LyxUiButton>
<LyxUiButton @click="deleteDomain()" type="danger">
Delete
</LyxUiButton>
</div>
</div>
</div>
</UModal>
</template>

View File

@@ -5,6 +5,7 @@ import { DialogFeedback, DialogHelp } from '#components';
const modal = useModal();
const selfhosted = useSelfhosted();
const { domain } = useDomain();
const colorMode = useColorMode()
const isDark = computed({
@@ -16,23 +17,24 @@ const isDark = computed({
}
})
const {safeSnapshotDates} = useSnapshot();
const { safeSnapshotDates } = useSnapshot();
</script>
<template>
<div
class="w-full overflow-y-auto hide-scrollbars h-[4rem] border-solid border-[#D9D9E0] dark:border-[#202020] border-b-[1px] bg-lyx-lightmode-background dark:bg-lyx-background flex dark:shadow-[1px_0_10px_#000000]">
class="w-full hide-scrollbars relative h-[4rem] border-solid border-[#D9D9E0] dark:border-[#202020] border-b-[1px] bg-lyx-lightmode-background dark:bg-lyx-background dark:shadow-[1px_0_10px_#000000]">
<div class="absolute flex h-full w-full">
<div class="flex items-center px-6">
<SelectorDomainSelector></SelectorDomainSelector>
</div>
<div class="hidden lg:flex pl-[12rem] items-center popping text-[.9rem] dark:text-lyx-text-dark">
<div class="hidden lg:flex items-center popping text-[.9rem] dark:text-lyx-text-dark">
Timeframe:
{{new Date(safeSnapshotDates.from).toLocaleDateString()}}
{{ new Date(safeSnapshotDates.from).toLocaleDateString() }}
to
{{new Date(safeSnapshotDates.to).toLocaleDateString()}}
{{ new Date(safeSnapshotDates.to).toLocaleDateString() }}
</div>
@@ -62,4 +64,6 @@ const {safeSnapshotDates} = useSnapshot();
</div>
</div>
</div>
</template>

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup>
import { DialogConfirmLogout, DialogInviteManager } from '#components';
import CreateSnapshot from '../dialog/CreateSnapshot.vue';
export type Entry = {
@@ -27,6 +28,10 @@ type Props = {
const route = useRoute();
const props = defineProps<Props>();
const { data: pendingInvites, refresh: refreshInvites } = useFetch('/api/project/members/pending', {
headers: useComputedHeaders({})
});
const { userRoles, setLoggedUser } = useLoggedUser();
const { projectList } = useProject();
@@ -89,12 +94,25 @@ async function generatePDF() {
const { setToken } = useAccessToken();
const router = useRouter();
const { actions } = useProject();
const modal = useModal();
function onLogout() {
console.log('LOGOUT')
modal.open(DialogConfirmLogout, {
onSuccess() {
modal.close();
console.log('LOGOUT');
setToken('');
setLoggedUser(undefined);
router.push('/login');
},
onCancel() {
modal.close();
}
})
}
const { data: maxProjects } = useFetch("/api/user/max_projects", {
@@ -106,6 +124,28 @@ const { data: maxProjects } = useFetch("/api/user/max_projects", {
});
function openPendingInvites() {
if (!pendingInvites.value) return;
if (pendingInvites.value.length == 0) return;
console.log(pendingInvites);
modal.open(DialogInviteManager, {
invites: pendingInvites.value.map(e => {
return { project_id: e.project_id, project_name: e.project_name }
}),
onSuccess: () => {
modal.close();
actions.refreshProjectsList();
refreshInvites();
},
onCancel: () => {
modal.close();
actions.refreshProjectsList();
refreshInvites();
},
});
}
</script>
<template>
@@ -249,7 +289,6 @@ const { data: maxProjects } = useFetch("/api/user/max_projects", {
<div class="bg-lyx-lightmode-widget dark:bg-[#202020] h-[1px] w-full"></div>
<div class="flex flex-col h-full">
<div v-for="section of sections" class="flex flex-col gap-1 h-full pb-6">
<div v-for="entry of section.entries" :class="{ 'grow flex items-end': entry.grow }">
@@ -283,6 +322,18 @@ const { data: maxProjects } = useFetch("/api/user/max_projects", {
<div class="grow"></div>
<div v-if="pendingInvites && pendingInvites.length > 0" @click="openPendingInvites()"
class="w-full bg-[#fbbf2422] p-4 rounded-lg text-[.9rem] flex flex-col justify-center cursor-pointer">
<div class="poppins font-medium dark:text-lyx-text text-lyx-lightmode-text">
Pending invitation
</div>
<div class="poppins dark:text-lyx-text-dark text-lyx-lightmode-text-dark">
You have {{ pendingInvites.length }}
pending invitation{{ pendingInvites.length != 1 ? 's' : '' }}
awaiting your response
</div>
</div>
<div class="bg-lyx-lightmode-widget dark:bg-[#202020] h-[1px] w-full px-4 mb-3"></div>
<div class="flex justify-end px-2">
@@ -296,7 +347,8 @@ const { data: maxProjects } = useFetch("/api/user/max_projects", {
</div>
<UTooltip text="Logout" :popper="{ arrow: true, placement: 'top' }">
<div @click="onLogout()" class="cursor-pointer hover:text-lyx-lightmode-text text-lyx-lightmode-text-dark dark:hover:text-lyx-text dark:text-lyx-text-dark">
<div @click="onLogout()"
class="cursor-pointer hover:text-lyx-lightmode-text text-lyx-lightmode-text-dark dark:hover:text-lyx-text dark:text-lyx-text-dark">
<i class="far fa-arrow-right-from-bracket scale-x-[-100%]"></i>
</div>
</UTooltip>

View File

@@ -8,7 +8,7 @@ function onChange(e: string) {
</script>
<template>
<div class="flex gap-2 absolute">
<div class="flex gap-2">
<USelectMenu :uiMenu="{
select: 'bg-lyx-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget w-max',
@@ -18,7 +18,7 @@ function onChange(e: string) {
},
input: 'z-[999] !bg-lyx-lightmode-widget dark:!bg-lyx-widget-light'
}" class="w-full" searchable searchable-placeholder="Search domain..." v-if="domainList" @change="onChange"
:value="domain" value-attribute="_id" :options="domainList">
:value="domain" :loading="refreshingDomains" value-attribute="_id" :options="domainList">
<template #option="{ option, active, selected }">
<div class="flex items-center gap-2">
@@ -35,7 +35,7 @@ function onChange(e: string) {
<img class="h-5 bg-black rounded-full" :src="'/logo_32.png'" alt="Litlyx logo">
</div>
<div>
{{ domain || '-' }}
{{ refreshingDomains ? 'Loading...' : (domain || '-') }}
</div>
</div>
</template>

View File

@@ -16,7 +16,6 @@ function isProjectMine(owner?: string) {
function onChange(e: TProject) {
actions.setActiveProject(e._id.toString());
setActiveDomain('All domains');
}
</script>

View File

@@ -4,6 +4,8 @@ import type { SettingsTemplateEntry } from './Template.vue';
const { project, actions, projectList, isGuest, projectId } = useProject();
const { createErrorAlert, createAlert } = useAlert();
const entries: SettingsTemplateEntry[] = [
{ id: 'pname', title: 'Name', text: 'Project name' },
{ id: 'api', title: 'ApiKeys', text: 'Manage your authorization token' },
@@ -37,7 +39,7 @@ async function createApiKey() {
apiKeys.value.push(res);
newApiKeyName.value = '';
} catch (ex: any) {
alert(ex.message);
createErrorAlert('Error', ex.message, 10000);
}
}
@@ -53,7 +55,7 @@ async function deleteApiKey(api_id: string) {
newApiKeyName.value = '';
await updateApiKeys();
} catch (ex: any) {
alert(ex.message);
createErrorAlert('Error', ex.message, 10000);
}
}
@@ -116,14 +118,12 @@ async function deleteProject() {
} catch (ex: any) {
alert(ex.message);
createErrorAlert('Error', ex.message);
}
}
const { createAlert } = useAlert()
function copyScript() {
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
@@ -172,7 +172,7 @@ function copyProjectId() {
<LyxUiInput class="grow px-4 py-2" :disabled="isGuest" placeholder="ApiKeyName"
v-model="newApiKeyName">
</LyxUiInput>
<LyxUiButton v-if="!isGuest" @click="createApiKey()" :disabled="newApiKeyName.length < 3"
<LyxUiButton v-if="!isGuest" @click="createApiKey()" :disabled="newApiKeyName.trim().length < 3"
type="primary">
<i class="far fa-plus"></i>
</LyxUiButton>

View File

@@ -116,7 +116,7 @@ const { showDrawer } = useDrawer();
</script>
<template>
<div class="relative">
<div class="relative pb-[6rem]">
<div v-if="invoicesPending || planPending"
class="backdrop-blur-[1px] z-[20] mt-20 w-full h-full flex items-center justify-center font-bold">

View File

@@ -1,123 +0,0 @@
<script setup lang="ts">
import type { SettingsTemplateEntry } from './Template.vue';
const { projectId, isGuest } = useProject();
definePageMeta({ layout: 'dashboard' });
const columns = [
{ key: 'me', label: '' },
{ key: 'email', label: 'Email' },
{ key: 'name', label: 'Name' },
{ key: 'role', label: 'Role' },
{ key: 'action', label: 'Actions' },
// { key: 'pending', label: 'Pending' },
]
const { data: members, refresh: refreshMembers } = useFetch('/api/project/members/list', {
headers: useComputedHeaders({ useSnapshotDates: false })
});
const showAddMember = ref<boolean>(false);
const addMemberEmail = ref<string>("");
async function kickMember(email: string) {
const sure = confirm('Are you sure to kick ' + email + ' ?');
if (!sure) return;
try {
await $fetch('/api/project/members/kick', {
method: 'POST',
...signHeaders({
'Content-Type': 'application/json',
'x-pid': projectId.value ?? ''
}),
body: JSON.stringify({ email }),
onResponseError({ request, response, options }) {
alert(response.statusText);
}
});
refreshMembers();
} catch (ex: any) { }
}
async function addMember() {
if (addMemberEmail.value.length === 0) return;
try {
showAddMember.value = false;
await $fetch('/api/project/members/add', {
method: 'POST',
...signHeaders({
'Content-Type': 'application/json',
'x-pid': projectId.value ?? ''
}),
body: JSON.stringify({ email: addMemberEmail.value }),
onResponseError({ request, response, options }) {
alert(response.statusText);
}
});
addMemberEmail.value = '';
refreshMembers();
} catch (ex: any) { }
}
const entries: SettingsTemplateEntry[] = [
{ id: 'add', title: 'Add member', text: 'Add new member to project' },
{ id: 'members', title: 'Members', text: 'Manage members of current project' },
]
</script>
<template>
<SettingsTemplate :entries="entries">
<template #add>
<div v-if="!isGuest" class="flex flex-col">
<div class="flex gap-4 items-center">
<LyxUiInput class="px-4 py-1 w-full" placeholder="User email" v-model="addMemberEmail"></LyxUiInput>
<LyxUiButton @click="addMember" type="secondary"> Add </LyxUiButton>
</div>
<div class="poppins text-[.8rem] mt-2 text-lyx-text-darker">
User should have been registered to Litlyx
</div>
</div>
<div v-if="isGuest" class="text-lyx-text-darker"> Guests cannot add members</div>
</template>
<template #members>
<UTable :rows="members || []" :columns="columns">
<template #me-data="e">
<i v-if="e.row.me" class="far fa-user"></i>
<i v-if="!e.row.me"></i>
</template>
<template #action-data="e" v-if="!isGuest">
<div @click="kickMember(e.row.email)" v-if="e.row.role != 'OWNER'"
class="text-red-500 hover:bg-black/20 cursor-pointer outline outline-[1px] outline-red-500 px-3 py-1 rounded-lg text-center">
Kick
</div>
</template>
</UTable>
</template>
</SettingsTemplate>
</template>

View File

@@ -0,0 +1,101 @@
<script lang="ts" setup>
import { DialogShieldsDeleteAddress, DialogShieldsAddAddress } from '#components';
definePageMeta({ layout: 'dashboard' });
const { data: blackAddresses, refresh: refreshAddresses, pending: pendingAddresses } = useFetch('/api/shields/ip/list', {
headers: useComputedHeaders({})
});
const toast = useToast()
const modal = useModal();
function showAddAddressModal() {
modal.open(DialogShieldsAddAddress, {
onSuccess: () => {
refreshAddresses();
modal.close();
toast.add({
id: 'shield_address_add_success',
title: 'Success',
description: 'Blacklist updated with the new address',
timeout: 5000
});
},
onCancel: () => {
modal.close();
}
})
}
function showDeleteAddressModal(address: string) {
modal.open(DialogShieldsDeleteAddress, {
address,
onSuccess: () => {
refreshAddresses();
modal.close();
toast.add({
id: 'shield_address_remove_success',
title: 'Deleted',
description: 'Blacklist address deleted successfully',
timeout: 5000
});
},
onCancel: () => {
modal.close();
}
})
}
</script>
<template>
<div class="py-4 flex">
<LyxUiCard class="w-full mx-2">
<div>
<div class="text-[1.2rem] font-semibold"> IP Block List </div>
<div class="dark:text-lyx-text-dark text-lyx-lightmode-text-dark">
Reject incoming traffic from specific IP addresses
</div>
</div>
<LyxUiSeparator class="my-3"></LyxUiSeparator>
<div class="flex justify-end pb-3">
<LyxUiButton type="primary" @click="showAddAddressModal()"> Add IP Address </LyxUiButton>
</div>
<div class="flex justify-center pb-8 text-[1.2rem]" v-if="pendingAddresses">
<i class="fas fa-loader animate-spin"></i>
</div>
<div v-if="!pendingAddresses && blackAddresses && blackAddresses.length == 0"
class="flex flex-col items-center pb-8">
<div>
No domain rules configured for this project.
</div>
<div class="font-semibold">
Traffic from all domains is currently accepted.
</div>
</div>
<div v-if="!pendingAddresses && blackAddresses && blackAddresses.length > 0"
class="grid grid-cols-[auto_auto_auto_auto] px-10">
<div> Domain </div>
<div class="col-span-2"> Description </div>
<div> Actions </div>
<LyxUiSeparator class="col-span-4 my-3"></LyxUiSeparator>
<template v-for="entry of blackAddresses">
<div class="mb-2"> {{ entry.address }} </div>
<div class="col-span-2">{{ entry.description || 'No description' }}</div>
<div> <i @click="showDeleteAddressModal(entry.address)"
class="far fa-trash cursor-pointer hover:text-lyx-text-dark"></i> </div>
</template>
</div>
</LyxUiCard>
</div>
</template>

View File

@@ -0,0 +1,47 @@
<script lang="ts" setup>
definePageMeta({ layout: 'dashboard' });
const { data: botOptions, refresh: refreshBotOptions, pending: pendingBotOptions } = useFetch('/api/shields/bots/options', {
headers: useComputedHeaders({})
});
async function onChange(newValue: boolean) {
await $fetch('/api/shields/bots/update_options', {
method: 'POST',
headers: useComputedHeaders({ custom: { 'Content-Type': 'application/json' } }).value,
body: JSON.stringify({ block: newValue })
})
await refreshBotOptions();
}
</script>
<template>
<div class="py-4 flex">
<LyxUiCard class="w-full mx-2">
<div>
<div class="text-[1.2rem] font-semibold"> Block bot traffic </div>
<div class="dark:text-lyx-text-dark text-lyx-lightmode-text-dark">
Automatically block unwanted bot and crawler traffic to protect your site from spam, scrapers, and
unnecessary server load.
</div>
</div>
<LyxUiSeparator class="my-3"></LyxUiSeparator>
<div class="flex justify-center pb-8 text-[1.2rem]" v-if="pendingBotOptions">
<i class="fas fa-loader animate-spin"></i>
</div>
<div v-if="!pendingBotOptions && botOptions">
<div class="flex gap-2">
<UToggle :modelValue="botOptions.block" @change="onChange"></UToggle>
<div> Enable bot protection </div>
</div>
</div>
</LyxUiCard>
</div>
</template>

View File

@@ -0,0 +1,99 @@
<script lang="ts" setup>
import { DialogShieldsAddDomain, DialogShieldsDeleteDomain } from '#components';
definePageMeta({ layout: 'dashboard' });
const { data: allowedDomains, refresh: refreshDomains, pending: pendingDomains } = useFetch('/api/shields/domains/list', {
headers: useComputedHeaders({})
});
const toast = useToast()
const modal = useModal();
function showAddDomainModal() {
modal.open(DialogShieldsAddDomain, {
onSuccess: () => {
refreshDomains();
modal.close();
toast.add({
id: 'shield_domain_add_success',
title: 'Success',
description: 'Whitelist updated with the new domain',
timeout: 5000
});
},
onCancel: () => {
modal.close();
}
})
}
function showDeleteDomainModal(domain: string) {
modal.open(DialogShieldsDeleteDomain, {
domain,
onSuccess: () => {
refreshDomains();
modal.close();
toast.add({
id: 'shield_domain_remove_success',
title: 'Deleted',
description: 'Whitelist domain deleted successfully',
timeout: 5000
});
},
onCancel: () => {
modal.close();
}
})
}
</script>
<template>
<div class="py-4 flex">
<LyxUiCard class="w-full mx-2">
<div>
<div class="text-[1.2rem] font-semibold"> Domains allow list </div>
<div class="dark:text-lyx-text-dark text-lyx-lightmode-text-dark">
Accept incoming traffic only from familiar domains.
</div>
</div>
<LyxUiSeparator class="my-3"></LyxUiSeparator>
<div class="flex justify-end pb-3">
<LyxUiButton type="primary" @click="showAddDomainModal()"> Add Domain </LyxUiButton>
</div>
<div class="flex justify-center pb-8 text-[1.2rem]" v-if="pendingDomains">
<i class="fas fa-loader animate-spin"></i>
</div>
<div v-if="!pendingDomains && allowedDomains && allowedDomains.length == 0"
class="flex flex-col items-center pb-8">
<div>
No domain rules configured for this project.
</div>
<div class="font-semibold">
Traffic from all domains is currently accepted.
</div>
</div>
<div v-if="!pendingDomains && allowedDomains && allowedDomains.length > 0"
class="grid grid-cols-[auto_auto_auto_auto] px-10">
<div class="col-span-3">Domain</div>
<div>Actions</div>
<LyxUiSeparator class="col-span-4 my-3"></LyxUiSeparator>
<template v-for="domain of allowedDomains">
<div class="col-span-3 mb-3">{{ domain }}</div>
<div> <i @click="showDeleteDomainModal(domain)"
class="far fa-trash cursor-pointer hover:text-lyx-text-dark"></i> </div>
</template>
</div>
</LyxUiCard>
</div>
</template>

View File

@@ -71,7 +71,6 @@ export function getDefaultSnapshots(project_id: TProjectSnapshot['project_id'],
}
const allTime: DefaultSnapshot = {
project_id,
_id: '___allTime' as any,
@@ -83,8 +82,45 @@ export function getDefaultSnapshots(project_id: TProjectSnapshot['project_id'],
}
const last30Days: DefaultSnapshot = {
project_id,
_id: '___last30days' as any,
name: 'Last 30 days',
from: fns.startOfDay(fns.subDays(Date.now(), 30)),
to: fns.endOfDay(fns.subDays(Date.now(), 0)),
color: '#606c38',
default: true
}
const snapshotList = [lastDay, today, lastMonth, currentMonth, lastWeek, currentWeek, allTime]
const last60Days: DefaultSnapshot = {
project_id,
_id: '___last60days' as any,
name: 'Last 60 days',
from: fns.startOfDay(fns.subDays(Date.now(), 60)),
to: fns.endOfDay(fns.subDays(Date.now(), 0)),
color: '#bc6c25',
default: true
}
const last90Days: DefaultSnapshot = {
project_id,
_id: '___last90days' as any,
name: 'Last 90 days',
from: fns.startOfDay(fns.subDays(Date.now(), 90)),
to: fns.endOfDay(fns.subDays(Date.now(), 0)),
color: '#fefae0',
default: true
}
const snapshotList = [
allTime,
lastDay, today,
lastWeek, currentWeek,
lastMonth, currentMonth,
last30Days,
last60Days, last90Days,
]
return snapshotList;

View File

@@ -34,10 +34,19 @@ function createAlert(title: string, text: string, icon: string, ms: number) {
}, 250)
}
function createSuccessAlert(title: string, text: string, ms?: number) {
return createAlert(title, text, 'far fa-circle-check', ms ?? 5000);
}
function createErrorAlert(title: string, text: string, ms?: number) {
return createAlert(title, text, 'far fa-triangle-exclamation', ms ?? 5000);
}
function closeAlert(id: number) {
alerts.value = alerts.value.filter(e => e.id != id);
}
export function useAlert() {
return { alerts, createAlert, closeAlert }
return { alerts, createAlert, closeAlert, createSuccessAlert, createErrorAlert }
}

View File

@@ -16,26 +16,22 @@ function refreshDomains() {
domainsRequest.refresh();
}
watch(domainsRequest.data, () => {
if (!domainsRequest.data.value) return;
setActiveDomain(domainList.value[0]._id);
});
const refreshingDomains = computed(() => domainsRequest.pending.value);
const domainList = computed(() => {
return [
{
_id: 'All domains', visits: domainsRequest.data.value?.reduce((a, e) => a + e.visits, 0)
},
...(domainsRequest.data.value?.sort((a, b) => b.visits - a.visits) || [])
]
return (domainsRequest.data.value?.sort((a, b) => b.visits - a.visits) || []);
})
const activeDomain = ref<string>();
const domain = computed(() => {
if (activeDomain.value) return activeDomain.value;
if (!domainList.value) return;
if (domainList.value.length == 0) return;
setActiveDomain(domainList.value[0]._id);
return domainList.value[0]._id;
return activeDomain.value;
})
function setActiveDomain(domain: string) {

View File

@@ -0,0 +1,14 @@
const { data: permission } = useFetch('/api/project/members/me', {
headers: useComputedHeaders({})
});
const canSeeWeb = computed(() => permission.value?.webAnalytics || false);
const canSeeEvents = computed(() => permission.value?.events || false);
const canSeeAi = computed(() => permission.value?.ai || false);
export function usePermission() {
return { permission, canSeeWeb, canSeeEvents, canSeeAi };
}

View File

@@ -33,7 +33,10 @@ const guestProjectList = computed(() => {
return guestProjectsRequest.data.value;
})
const refreshProjectsList = () => projectsRequest.refresh();
const refreshProjectsList = async () => {
await projectsRequest.refresh();
await guestProjectsRequest.refresh();
}
const activeProjectId = ref<string | undefined>();

View File

@@ -15,7 +15,7 @@ const remoteSnapshots = useFetch<TProjectSnapshot[]>('/api/project/snapshots', {
watch(project, async () => {
await remoteSnapshots.refresh();
snapshot.value = isLiveDemo.value ? snapshots.value[3] : snapshots.value[3];
snapshot.value = isLiveDemo.value ? snapshots.value[7] : snapshots.value[7];
});
const snapshots = computed<GenericSnapshot[]>(() => {

View File

@@ -18,6 +18,8 @@ const sections: Section[] = [
entries: [
{ label: 'Web Analytics', to: '/', icon: 'fal fa-table-layout' },
{ label: 'Custom Events', to: '/events', icon: 'fal fa-square-bolt' },
{ label: 'Members', to: '/members', icon: 'fal fa-users' },
{ label: 'Shields', to: '/shields', icon: 'fal fa-shield' },
{ label: 'Ask AI', to: '/analyst', icon: 'fal fa-sparkles' },
// { label: 'Security', to: '/security', icon: 'fal fa-shield', disabled: selfhosted },

View File

@@ -0,0 +1,34 @@
<script lang="ts" setup>
const router = useRouter();
const route = useRoute();
onMounted(async () => {
try {
const project_id = route.query.project_id;
if (!project_id) throw Error('project_id is required');
const res = await $fetch('/api/project/members/accept', {
headers: useComputedHeaders({
custom: {
'Content-Type': 'application/json'
}
}).value,
method: 'POST',
body: JSON.stringify({ project_id })
});
router.push('/');
} catch (ex) {
console.error('ERROR');
console.error(ex);
alert('An error occurred');
}
});
</script>
<template>
<div>
You will be redirected soon.
</div>
</template>

View File

@@ -5,165 +5,8 @@ import type { CItem } from '~/components/CustomTab.vue';
definePageMeta({ layout: 'dashboard' });
const filterPremium = ref<boolean>(false);
const filterAppsumo = ref<boolean>(false);
const timeRange = ref<number>(9);
function setTimeRange(n: number) {
timeRange.value = n;
}
const timeRangeTimestamp = computed(() => {
if (timeRange.value == 1) return Date.now() - 1000 * 60 * 60 * 24;
if (timeRange.value == 2) return Date.now() - 1000 * 60 * 60 * 24 * 7;
if (timeRange.value == 3) return Date.now() - 1000 * 60 * 60 * 24 * 30;
return 0;
})
// const { data: projectsAggregatedResponseData } = await useFetch<AdminProjectsList[]>('/api/admin/projects', signHeaders());
// const { data: counts } = await useFetch(() => `/api/admin/counts?from=${timeRangeTimestamp.value}`, signHeaders());
// function onHideClicked() {
// isAdminHidden.value = true;
// }
// function isAppsumoType(type: number) {
// return type > 6000 && type < 6004
// }
// const projectsAggregated = computed(() => {
// let pool = projectsAggregatedResponseData.value ? [...projectsAggregatedResponseData.value] : [];
// let shownPool: AdminProjectsList[] = [];
// for (const element of pool) {
// shownPool.push({ ...element, projects: [...element.projects] });
// if (filterAppsumo.value === true) {
// shownPool.forEach(e => {
// e.projects = e.projects.filter(project => {
// return isAppsumoType(project.premium_type)
// })
// })
// shownPool = shownPool.filter(e => {
// return e.projects.length > 0;
// })
// } else if (filterPremium.value === true) {
// shownPool.forEach(e => {
// e.projects = e.projects.filter(project => {
// return project.premium === true;
// })
// })
// shownPool = shownPool.filter(e => {
// return e.projects.length > 0;
// })
// } else {
// console.log('NO DATA')
// }
// }
// return shownPool.sort((a, b) => {
// const sumVisitsA = a.projects.reduce((pa, pe) => pa + (pe.counts?.visits || 0) + (pe.counts?.events || 0), 0);
// const sumVisitsB = b.projects.reduce((pa, pe) => pa + (pe.counts?.visits || 0) + (pe.counts?.events || 0), 0);
// return sumVisitsB - sumVisitsA;
// }).filter(e => {
// return new Date(e.created_at).getTime() >= timeRangeTimestamp.value
// });
// })
// const premiumCount = computed(() => {
// let premiums = 0;
// projectsAggregated.value?.forEach(e => {
// e.projects.forEach(p => {
// if (p.premium) premiums++;
// });
// })
// return premiums;
// })
// const activeProjects = computed(() => {
// let actives = 0;
// projectsAggregated.value?.forEach(e => {
// e.projects.forEach(p => {
// if (!p.counts) return;
// if (!p.counts.updated_at) return;
// const updated_at = new Date(p.counts.updated_at).getTime();
// if (updated_at < Date.now() - 1000 * 60 * 60 * 24) return;
// actives++;
// });
// })
// return actives;
// });
// const totalVisits = computed(() => {
// return projectsAggregated.value?.reduce((a, e) => {
// return a + e.projects.reduce((pa, pe) => pa + (pe.counts?.visits || 0), 0);
// }, 0) || 0;
// });
// const totalEvents = computed(() => {
// return projectsAggregated.value?.reduce((a, e) => {
// return a + e.projects.reduce((pa, pe) => pa + (pe.counts?.events || 0), 0);
// }, 0) || 0;
// });
const details = ref<any>();
const showDetails = ref<boolean>(false);
async function getProjectDetails(project_id: string) {
details.value = await $fetch(`/api/admin/details?project_id=${project_id}`, signHeaders());
showDetails.value = true;
}
async function resetCount(project_id: string) {
await $fetch(`/api/admin/reset_count?project_id=${project_id}`, signHeaders());
}
function dateDiffDays(a: string) {
return (Date.now() - new Date(a).getTime()) / (1000 * 60 * 60 * 24)
}
function getLogBg(last_logged_at?: string) {
const day = 1000 * 60 * 60 * 24;
const week = 1000 * 60 * 60 * 24 * 7;
const lastLoggedAtDate = new Date(last_logged_at || 0);
if (lastLoggedAtDate.getTime() > Date.now() - day) {
return 'bg-green-500'
} else if (lastLoggedAtDate.getTime() > Date.now() - week) {
return 'bg-yellow-500'
} else {
return 'bg-red-500'
}
}
const tabs: CItem[] = [
{ label: 'Overview', slot: 'overview' },

View File

@@ -6,6 +6,8 @@ definePageMeta({ layout: 'dashboard' });
const selfhosted = useSelfhosted();
const { permission, canSeeAi } = usePermission();
const debugModeAi = ref<boolean>(false);
const { userRoles } = useLoggedUser();
@@ -253,7 +255,12 @@ async function clearAllChats() {
</script>
<template>
<div class="w-full h-full overflow-y-hidden">
<div v-if="!canSeeAi" class="h-full w-full flex mt-[20vh] justify-center">
<div> You need AI permission to view this page </div>
</div>
<div v-if="canSeeAi" class="w-full h-full overflow-y-hidden">
<div class="flex flex-row h-full overflow-y-hidden">

View File

@@ -4,6 +4,7 @@ import DateService, { type Slice } from '@services/DateService';
definePageMeta({ layout: 'dashboard' });
const { permission, canSeeEvents } = usePermission();
const { snapshotDuration } = useSnapshot();
@@ -30,7 +31,12 @@ const eventsData = await useFetch(`/api/data/count`, {
<template>
<div class="w-full h-full overflow-y-auto pb-20 p-6 gap-6 flex flex-col">
<div v-if="!canSeeEvents" class="h-full w-full flex mt-[20vh] justify-center">
<div> You need events permission to view this page </div>
</div>
<div v-if="canSeeEvents" class="w-full h-full overflow-y-auto pb-20 p-6 gap-6 flex flex-col">
<LyxUiCard class="w-full flex justify-between items-center lg:flex-row flex-col gap-6 lg:gap-0">

View File

@@ -11,6 +11,9 @@ const jwtLogin = computed(() => route.query.jwt_login as string);
const { token, setToken } = useAccessToken();
const { refreshingDomains } = useDomain();
const { permission, canSeeWeb, canSeeEvents } = usePermission();
onMounted(async () => {
if (jwtLogin.value) {
@@ -36,13 +39,22 @@ const selfhosted = useSelfhosted();
<template>
<div class="dashboard w-full h-full overflow-y-auto overflow-x-hidden pb-[7rem] md:pt-4 lg:pt-0">
<div v-if="!canSeeWeb" class="h-full w-full flex mt-[20vh] justify-center">
<div> You need webAnalytics permission to view this page </div>
</div>
<div v-if="canSeeWeb && refreshingDomains">
<div class="w-full flex justify-center items-center mt-[20vh]">
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
</div>
</div>
<div v-if="canSeeWeb && !refreshingDomains" class="dashboard w-full h-full overflow-y-auto overflow-x-hidden pb-[7rem] md:pt-4 lg:pt-0">
<div v-if="showDashboard">
<div class="w-full px-4 py-2 gap-2 flex flex-col">
<BannerLimitsInfo v-if="!selfhosted" :key="refreshKey"></BannerLimitsInfo>
<!-- <BannerOffer v-if="!selfhosted" :key="refreshKey"></BannerOffer> -->
</div>
<div>
@@ -52,7 +64,10 @@ const selfhosted = useSelfhosted();
<div class="mt-6 px-6 flex gap-6 flex-col 2xl:flex-row w-full">
<DashboardActionableChart :key="refreshKey"></DashboardActionableChart>
<DashboardActionableChart v-if="canSeeWeb && canSeeEvents" :key="refreshKey"></DashboardActionableChart>
<LyxUiCard v-else class="flex justify-center w-full py-4">
You need events permission to view this widget
</LyxUiCard>
</div>
@@ -92,7 +107,6 @@ const selfhosted = useSelfhosted();
</div>
<FirstInteraction v-if="!justLogged" :refresh-interaction="firstInteraction.refresh"
:first-interaction="(firstInteraction.data.value || false)"></FirstInteraction>

View File

@@ -19,6 +19,8 @@ const router = useRouter();
const { token, setToken } = useAccessToken();
const { createErrorAlert } = useAlert();
async function handleOnSuccess(response: any) {
try {
@@ -97,7 +99,7 @@ function goBackToEmailLogin() {
async function signInSelfhosted() {
try {
const result = await $fetch(`/api/auth/no_auth`, {
const result: any = await $fetch(`/api/auth/no_auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.value, password: password.value })
@@ -124,7 +126,7 @@ async function signInSelfhosted() {
}
} catch (ex: any) {
alert('Error during login.' + ex.message);
createErrorAlert('Error', 'Error during login.' + ex.message);
}
}
@@ -137,7 +139,7 @@ async function signInWithCredentials() {
body: JSON.stringify({ email: email.value, password: password.value })
})
if (result.error) return alert(result.message);
if (result.error) return createErrorAlert('Error', result.message);
setToken(result.access_token);
@@ -156,8 +158,8 @@ async function signInWithCredentials() {
}
} catch (ex) {
alert('Something went wrong.');
} catch (ex: any) {
createErrorAlert('Error', 'Something went wrong.' + ex.message);
}
}
@@ -258,7 +260,7 @@ async function signInWithCredentials() {
<div v-if="isNoAuth" @click="loginWithoutAuth"
<div v-if="isNoAuth"
class="flex text-[1.3rem] flex-col gap-4 items-center px-8 py-3 relative z-[2]">
<div class="flex flex-col gap-4 z-[100] w-[20vw] min-w-[20rem]">
<LyxUiInput class="px-3 py-2" placeholder="Email" v-model="email"></LyxUiInput>

212
dashboard/pages/members.vue Normal file
View File

@@ -0,0 +1,212 @@
<script setup lang="ts">
import { DialogPermissionManager } from '#components';
import type { TPermission } from '~/shared/schema/TeamMemberSchema';
const { projectId, isGuest } = useProject();
definePageMeta({ layout: 'dashboard' });
const columns = [
{ key: 'me', label: '' },
{ key: 'email', label: 'Email' },
{ key: 'permission', label: 'Permission' },
{ key: 'pending', label: 'Status' },
{ key: 'action', label: 'Actions' },
]
const { data: members, refresh: refreshMembers } = useFetch('/api/project/members/list', {
headers: useComputedHeaders({ useSnapshotDates: false })
});
const showAddMember = ref<boolean>(false);
const addMemberEmail = ref<string>("");
const { createErrorAlert } = useAlert();
async function kickMember(email: string) {
const sure = confirm('Are you sure to kick ' + email + ' ?');
if (!sure) return;
try {
await $fetch('/api/project/members/kick', {
method: 'POST',
...signHeaders({
'Content-Type': 'application/json',
'x-pid': projectId.value ?? ''
}),
body: JSON.stringify({ email }),
onResponseError({ request, response, options }) {
createErrorAlert('Error', response.statusText);
}
});
refreshMembers();
} catch (ex: any) { }
}
async function addMember() {
if (addMemberEmail.value.length === 0) return;
try {
showAddMember.value = false;
await $fetch('/api/project/members/add', {
method: 'POST',
...signHeaders({
'Content-Type': 'application/json',
'x-pid': projectId.value ?? ''
}),
body: JSON.stringify({ email: addMemberEmail.value }),
onResponseError({ request, response, options }) {
createErrorAlert('Error', response.statusText);
}
});
addMemberEmail.value = '';
refreshMembers();
} catch (ex: any) { }
}
const modal = useModal();
function openPermissionManagerDialog(member_id: string) {
modal.open(DialogPermissionManager, {
preventClose: true,
member_id,
onSuccess: () => {
modal.close();
refreshMembers();
},
onCancel: () => {
modal.close();
refreshMembers();
},
});
}
function permissionToString(permission: TPermission) {
const result: string[] = [];
if (permission.webAnalytics) result.push('w');
if (permission.events) result.push('e');
if (permission.ai) result.push('a');
if (permission.domains.includes('All domains')) {
result.push('+');
} else {
result.push(permission.domains.length.toString());
}
return result.join('');
}
async function leaveProject() {
try {
await $fetch('/api/project/members/leave', {
headers: useComputedHeaders({}).value
});
location.reload();
} catch (ex: any) {
alert(ex.message);
}
}
</script>
<template>
<div class="p-6 pt-10">
<div v-if="!isGuest" class="flex flex-col gap-8">
<div class="flex flex-col">
<div class="flex gap-4 items-center">
<LyxUiInput class="px-4 py-1 w-full" placeholder="Add a new member" v-model="addMemberEmail">
</LyxUiInput>
<LyxUiButton @click="addMember" type="secondary"> Add </LyxUiButton>
</div>
<div class="poppins text-[.8rem] mt-2 dark:text-lyx-text-dark">
We will send an invitation email to the user you wish to add to this project.
</div>
</div>
<div>
<UTable :rows="members || []" :columns="columns">
<template #me-data="e">
<i v-if="e.row.me" class="far fa-user text-lyx-lightmode-text dark:text-lyx-text"></i>
<i v-if="!e.row.me"></i>
</template>
<template #email-data="e">
<div class="text-lyx-lightmode-text dark:text-lyx-text">
{{ e.row.email }}
</div>
</template>
<template #pending-data="e">
<div class="text-lyx-lightmode-text dark:text-lyx-text">
{{ e.row.pending ? 'Pending' : 'Accepted' }}
</div>
</template>
<template #permission-data="e">
<div class="text-lyx-lightmode-text dark:text-lyx-text flex gap-2">
<div v-if="e.row.role !== 'OWNER' && !isGuest">
<LyxUiButton class="!px-2" type="secondary"
@click="openPermissionManagerDialog(e.row.id.toString())">
<UTooltip text="Manage permissions">
<i class="far fa-gear"></i>
</UTooltip>
</LyxUiButton>
</div>
<div class="flex gap-2 flex-wrap">
<UBadge variant="outline" size="sm" color="yellow"
v-if="!e.row.permission.webAnalytics && !e.row.permission.events && !e.row.permission.ai && e.row.permission.domains.length == 0">
No permission given
</UBadge>
<UBadge variant="outline" size="sm" v-if="e.row.permission.webAnalytics"
label="Analytics"> </UBadge>
<UBadge variant="outline" size="sm" v-if="e.row.permission.events" label="Events">
</UBadge>
<UBadge variant="outline" size="sm" v-if="e.row.permission.ai" label="AI"> </UBadge>
<UBadge variant="outline" color="blue" size="sm"
v-if="e.row.permission.domains.includes('All domains')" label="All domains">
</UBadge>
<UBadge variant="outline" size="sm" color="blue"
v-if="!e.row.permission.domains.includes('All domains')"
v-for="domain of e.row.permission.domains" :label="domain"> </UBadge>
</div>
</div>
</template>
<template #action-data="e" v-if="!isGuest">
<div @click="kickMember(e.row.email)" v-if="e.row.role != 'OWNER'"
class="text-red-500 hover:bg-black/20 cursor-pointer outline outline-[1px] outline-red-500 px-3 py-1 rounded-lg text-center">
Remove
</div>
</template>
</UTable>
</div>
</div>
<div v-if="isGuest" class="flex flex-col gap-8 mt-[10vh]">
<div class="flex flex-col gap-4 items-center">
<div class="text-[1.2rem]"> Leave this project </div>
<LyxUiButton @click="leaveProject()" type="primary"> Leave </LyxUiButton>
</div>
</div>
</div>
</template>

View File

@@ -23,7 +23,7 @@ onMounted(() => {
async function createProject() {
if (projectName.value.length < 2) return;
if (projectName.value.trim().length < 2) return;
Lit.event('create_project');
@@ -34,14 +34,16 @@ async function createProject() {
await $fetch('/api/project/create', {
method: 'POST',
...signHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ name: projectName.value })
body: JSON.stringify({ name: projectName.value.trim() })
});
await actions.refreshProjectsList();
const newActiveProjectId = projectList.value?.[projectList.value?.length - 1]._id.toString();
if (newActiveProjectId) {
await actions.setActiveProject(newActiveProjectId);
console.log('Set active project', newActiveProjectId);
}
setPageLayout('dashboard');
@@ -89,7 +91,7 @@ async function createProject() {
<div>
<LyxUiButton type="primary" @click="createProject()" :disabled="projectName.length < 2">
<LyxUiButton type="primary" @click="createProject()" :disabled="projectName.trim().length < 2">
Create
</LyxUiButton>

View File

@@ -7,7 +7,6 @@ const selfhosted = useSelfhosted();
const items = [
{ label: 'General', slot: 'general', tab: 'general' },
{ label: 'Domains', slot: 'domains', tab: 'domains' },
{ label: 'Members', slot: 'members', tab: 'members' },
{ label: 'Billing', slot: 'billing', tab: 'billing' },
{ label: 'Codes', slot: 'codes', tab: 'codes' },
{ label: 'Account', slot: 'account', tab: 'account' }
@@ -16,7 +15,7 @@ const items = [
</script>
<template>
<div class="lg:px-10 lg:py-8 h-dvh overflow-y-auto overflow-x-hidden hide-scrollbars !pb-[10rem]">
<div class="lg:px-10 h-full lg:py-8 overflow-hidden hide-scrollbars">
<div class="poppins font-semibold text-[1.3rem] lg:px-0 px-4 lg:py-0 py-4"> Settings </div>
@@ -27,9 +26,6 @@ const items = [
<template #domains>
<SettingsData :key="refreshKey"></SettingsData>
</template>
<template #members>
<SettingsMembers :key="refreshKey"></SettingsMembers>
</template>
<template #billing>
<SettingsBilling v-if="!selfhosted" :key="refreshKey"></SettingsBilling>
<div class="flex popping text-[1.2rem] font-semibold justify-center mt-[20vh] text-lyx-lightmode-text dark:text-lyx-text"

View File

@@ -0,0 +1,35 @@
<script lang="ts" setup>
definePageMeta({ layout: 'dashboard' });
const selfhosted = useSelfhosted();
const items = [
{ label: 'Domains', slot: 'domains', tab: 'domains' },
{ label: 'IP Addresses', slot: 'ipaddresses', tab: 'ipaddresses' },
{ label: 'Bot traffic', slot: 'bots', tab: 'bots' },
// { label: 'Countries', slot: 'countries', tab: 'countries' },
// { label: 'Pages', slot: 'pages', tab: 'pages' },
]
</script>
<template>
<div class="lg:px-10 h-full lg:py-8 overflow-hidden hide-scrollbars">
<div class="poppins font-semibold text-[1.3rem] lg:px-0 px-4 lg:py-0 py-4"> Shields </div>
<CustomTab :items="items" :route="true" class="mt-8">
<template #domains>
<ShieldsDomains></ShieldsDomains>
</template>
<template #ipaddresses>
<ShieldsAddresses></ShieldsAddresses>
</template>
<template #bots>
<ShieldsBots></ShieldsBots>
</template>
</CustomTab>
</div>
</template>

View File

@@ -0,0 +1,15 @@
<script lang="ts" setup>
const { data: links } = useFetch('/api/project/links/list', {
headers: useComputedHeaders()
});
</script>
<template>
<div>
<div v-for="link of links">
{{ link }}
</div>
</div>
</template>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
dashboard/public/yt.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -14,7 +14,7 @@ export async function getUserProjectFromId(project_id: string, user: AuthContext
const project = await ProjectModel.findById(project_id);
if (!project) return;
const [hasAccess, role] = await hasAccessToProject(user.id, project_id, project);
const [hasAccess, role] = await hasAccessToProject(user.id, project_id, user.user.email, project);
if (!hasAccess) return;
if (role === 'GUEST' && !allowGuest) return false;

View File

@@ -0,0 +1,36 @@
import { ProjectModel } from "@schema/project/ProjectSchema";
import { UserModel } from "@schema/UserSchema";
import { EventModel } from "@schema/metrics/EventSchema";
import { VisitModel } from "@schema/metrics/VisitSchema";
export default defineEventHandler(async event => {
const userData = getRequestUser(event);
if (!userData?.logged) return;
if (!userData.user.roles.includes('ADMIN')) return;
const { filterFrom, filterTo } = getQuery(event);
const matchQuery = {
created_at: {
$gte: new Date(filterFrom as string),
$lte: new Date(filterTo as string)
}
}
const totalProjects = await ProjectModel.countDocuments({ ...matchQuery });
const premiumProjects = await ProjectModel.countDocuments({ ...matchQuery, premium: true });
const deadProjects = await ProjectModel.countDocuments({ ...matchQuery });
const totalUsers = await UserModel.countDocuments({ ...matchQuery });
const totalVisits = 0;
const totalEvents = await EventModel.countDocuments({ ...matchQuery });
return { totalProjects, premiumProjects, deadProjects, totalUsers, totalVisits, totalEvents }
});

View File

@@ -8,16 +8,27 @@ export default defineEventHandler(async event => {
if (!userData?.logged) return;
if (!userData.user.roles.includes('ADMIN')) return;
const { page, limit, sortQuery, filterQuery } = getQuery(event);
const { page, limit, sortQuery, filterQuery, filterFrom, filterTo } = getQuery(event);
const pageNumber = parseInt(page as string);
const limitNumber = parseInt(limit as string);
const count = await UserModel.countDocuments(JSON.parse(filterQuery as string));
const matchQuery = {
...JSON.parse(filterQuery as string),
created_at: {
$gte: new Date(filterFrom as string),
$lte: new Date(filterTo as string)
}
}
const count = await UserModel.countDocuments(matchQuery);
const users = await UserModel.aggregate([
{
$match: JSON.parse(filterQuery as string)
$match: matchQuery
},
{
$lookup: {

View File

@@ -3,7 +3,7 @@ import { AiChatModel } from "@schema/ai/AiChatSchema";
export default defineEventHandler(async event => {
const data = await getRequestDataOld(event);
const data = await getRequestData(event, [], ['AI']);
if (!data) return;
const { project_id } = data;

View File

@@ -4,7 +4,7 @@ import type OpenAI from "openai";
import { getChartsInMessage } from "~/server/services/AiService";
export default defineEventHandler(async event => {
const data = await getRequestDataOld(event);
const data = await getRequestData(event, [], ['AI']);
if (!data) return;
const isAdmin = data.user.user.roles.includes('ADMIN');

View File

@@ -2,7 +2,7 @@
import { AiChatModel } from "@schema/ai/AiChatSchema";
export default defineEventHandler(async event => {
const data = await getRequestDataOld(event);
const data = await getRequestData(event, [], ['AI']);
if (!data) return;
const { project_id } = data;

View File

@@ -10,7 +10,7 @@ export async function getAiChatRemainings(project_id: string) {
}
export default defineEventHandler(async event => {
const data = await getRequestDataOld(event);
const data = await getRequestData(event, [], ['AI']);
if (!data) return;
const { pid } = data;

View File

@@ -3,7 +3,7 @@ import { AiChatModel } from "@schema/ai/AiChatSchema";
export default defineEventHandler(async event => {
const data = await getRequestDataOld(event);
const data = await getRequestData(event, [], ['AI']);
if (!data) return;
const { project_id } = data;

View File

@@ -5,7 +5,7 @@ import { ProjectLimitModel } from "@schema/project/ProjectsLimits";
export default defineEventHandler(async event => {
const data = await getRequestDataOld(event);
const data = await getRequestData(event, [], ['AI']);
if (!data) return;
const { pid } = data;

View File

@@ -59,6 +59,13 @@ export default defineEventHandler(async event => {
const savedUser = await newUser.save();
setImmediate(() => {
const emailData = EmailService.getEmailServerInfo('brevolist_add', { email: payload.email as string });
EmailServiceHelper.sendEmail(emailData);
});
setImmediate(() => {
console.log('SENDING WELCOME EMAIL TO', payload.email);
if (!payload.email) return;

View File

@@ -34,6 +34,11 @@ export default defineEventHandler(async event => {
await RegisterModel.create({ email, password: hashedPassword });
setImmediate(() => {
const emailData = EmailService.getEmailServerInfo('brevolist_add', { email });
EmailServiceHelper.sendEmail(emailData);
});
setImmediate(() => {
const emailData = EmailService.getEmailServerInfo('confirm', { target: email, link: `https://dashboard.litlyx.com/api/auth/confirm_email?register_code=${jwt}` });
EmailServiceHelper.sendEmail(emailData);

View File

@@ -4,7 +4,7 @@ import { Redis } from "~/server/services/CacheService";
export default defineEventHandler(async event => {
const data = await getRequestData(event, ['GUEST', 'RANGE', 'GUEST', 'DOMAIN']);
const data = await getRequestData(event, ['RANGE', 'DOMAIN'], ['WEB']);
if (!data) return;
const { pid, from, to, project_id, limit, domain } = data;

View File

@@ -4,7 +4,7 @@ import { Redis } from "~/server/services/CacheService";
export default defineEventHandler(async event => {
const data = await getRequestData(event, ['GUEST', 'DOMAIN', 'RANGE', 'SCHEMA']);
const data = await getRequestData(event, ['DOMAIN', 'RANGE', 'SCHEMA'], ['WEB']);
if (!data) return;
const { schemaName, pid, from, to, model, project_id, domain } = data;

View File

@@ -4,7 +4,7 @@ import { Redis } from "~/server/services/CacheService";
export default defineEventHandler(async event => {
const data = await getRequestData(event, ['GUEST', 'RANGE', 'GUEST', 'DOMAIN']);
const data = await getRequestData(event, ['RANGE', 'DOMAIN'], ['WEB']);
if (!data) return;
const { pid, from, to, project_id, limit, domain } = data;

View File

@@ -4,7 +4,7 @@ import { Redis } from "~/server/services/CacheService";
export default defineEventHandler(async event => {
const data = await getRequestData(event, ['GUEST', 'RANGE', 'GUEST', 'DOMAIN']);
const data = await getRequestData(event, ['RANGE', 'DOMAIN'], ['WEB']);
if (!data) return;
const { pid, from, to, project_id, limit, domain } = data;

View File

@@ -4,7 +4,7 @@ import { Redis } from "~/server/services/CacheService";
export default defineEventHandler(async event => {
const data = await getRequestData(event, ['GUEST', 'DOMAIN', 'RANGE']);
const data = await getRequestData(event, ['DOMAIN', 'RANGE'], ['EVENTS']);
if (!data) return;
const { pid, from, to, project_id, limit, domain } = data;

View File

@@ -4,7 +4,7 @@ import { Redis } from "~/server/services/CacheService";
export default defineEventHandler(async event => {
const data = await getRequestData(event, ['GUEST', 'RANGE', 'GUEST', 'DOMAIN']);
const data = await getRequestData(event, ['RANGE', 'DOMAIN'], ['WEB']);
if (!data) return;
const { pid, from, to, project_id, limit, domain } = data;

View File

@@ -4,7 +4,7 @@ import { Redis } from "~/server/services/CacheService";
export default defineEventHandler(async event => {
const data = await getRequestData(event, ['GUEST', 'RANGE', 'GUEST', 'DOMAIN']);
const data = await getRequestData(event, ['RANGE', 'DOMAIN'], ['WEB']);
if (!data) return;
const { pid, from, to, project_id, limit, domain } = data;

View File

@@ -4,7 +4,7 @@ import { Redis } from "~/server/services/CacheService";
export default defineEventHandler(async event => {
const data = await getRequestData(event, ['OFFSET', 'RANGE', 'GUEST', 'DOMAIN']);
const data = await getRequestData(event, ['OFFSET', 'RANGE', 'DOMAIN'], ['WEB']);
if (!data) return;
const { pid, from, to, project_id, limit, domain } = data;

View File

@@ -1,18 +1,45 @@
import { VisitModel } from "@schema/metrics/VisitSchema";
import { TeamMemberModel } from "~/shared/schema/TeamMemberSchema";
export default defineEventHandler(async event => {
const data = await getRequestData(event, ['GUEST']);
const data = await getRequestData(event, []);
if (!data) return;
const { project_id } = data;
const { project_id, project, user } = data;
const result = await VisitModel.aggregate([
const result: { _id: string, visits: number }[] = await VisitModel.aggregate([
{ $match: { project_id, } },
{ $group: { _id: "$website", visits: { $sum: 1 } } },
]);
return result as { _id: string, visits: number }[];
const isOwner = user.id === project.owner.toString();
if (isOwner) return [
{
_id: 'All domains',
visits: result.reduce((a, e) => a + e.visits, 0)
},
...result
]
const member = await TeamMemberModel.findOne({ project_id, $or: [{ user_id: user.id }, { email: user.user.email }], pending: false });
if (!member) return setResponseStatus(event, 400, 'Not a member');
if (!member.permission) return setResponseStatus(event, 400, 'No permission');
if (member.permission.domains.includes('All domains')) {
return [
{
_id: 'All domains',
visits: result.reduce((a, e) => a + e.visits, 0)
},
...result
]
}
return result.filter(e => {
return member.permission.domains.includes(e._id);
});
});

View File

@@ -13,23 +13,28 @@ export default defineEventHandler(async event => {
const body = await readBody(event);
if (body.name.length == 0) return setResponseStatus(event, 400, 'name is required');
if (body.name.length < 3) return setResponseStatus(event, 400, 'name too short');
if (body.name.length > 32) return setResponseStatus(event, 400, 'name too long');
const data = await getRequestDataOld(event, { allowGuests: false, allowLitlyx: false, });
const data = await getRequestData(event, [], ['OWNER']);
if (!data) return;
if (!body.name) return setResponseStatus(event, 400, 'body is required');
if (body.name.trim().length == 0) return setResponseStatus(event, 400, 'name is required');
if (body.name.trim().length < 3) return setResponseStatus(event, 400, 'name too short');
if (body.name.trim().length > 32) return setResponseStatus(event, 400, 'name too long');
const { project_id } = data;
const sameName = await ApiSettingsModel.exists({ project_id, apiName: body.name.trim() });
if (sameName) return setResponseStatus(event, 400, 'An api key with the same name exists');
const key = generateApiKey();
const keyNumbers = await ApiSettingsModel.countDocuments({ project_id });
if (keyNumbers >= 5) return setResponseStatus(event, 400, 'Api key limit reached');
const newApiSettings = await ApiSettingsModel.create({ project_id, apiKey: key, apiName: body.name, created_at: Date.now(), usage: 0 });
const newApiSettings = await ApiSettingsModel.create({ project_id, apiKey: key, apiName: body.name.trim(), created_at: Date.now(), usage: 0 });
return newApiSettings.toJSON();

View File

@@ -11,7 +11,7 @@ export default defineEventHandler(async event => {
const project = await ProjectModel.findOne({ _id: project_id });
if (!project) return;
const [hasAccess] = await hasAccessToProject(user.id, project_id, project)
const [hasAccess] = await hasAccessToProject(user.id, project_id, user.user.email, project)
if (!hasAccess) return;
const query = getQuery(event);

View File

@@ -1,16 +1,18 @@
export default defineEventHandler(async event => {
const data = await getRequestDataOld(event, { requireSchema: false, allowGuests: false, allowLitlyx: false });
const data = await getRequestData(event, [], ['OWNER']);
if (!data) return;
const { project } = data;
const { name } = await readBody(event);
if (name.length == 0) return setResponseStatus(event, 400, 'name is required');
if (name.trim().length == 0) return setResponseStatus(event, 400, 'name is required');
if (name.trim().length < 2) return setResponseStatus(event, 400, 'name too short');
if (name.trim().length > 32) return setResponseStatus(event, 400, 'name too long');
project.name = name;
project.name = name.trim();
await project.save();
return { ok: true };

View File

@@ -8,7 +8,7 @@ export default defineEventHandler(async event => {
const body = await readBody(event);
const newProjectName = body.name;
const newProjectName = body.name.trim();
if (!newProjectName) return setResponseStatus(event, 400, 'ProjectName too short');
if (newProjectName.length < 2) return setResponseStatus(event, 400, 'ProjectName too short');

View File

@@ -0,0 +1,18 @@
import { UserModel } from "@schema/UserSchema";
import { ProjectLinkModel } from "~/shared/schema/project/ProjectLinkSchema";
export default defineEventHandler(async event => {
const data = await getRequestData(event, [], ['OWNER']);
if (!data) return;
const { project_id, project } = data;
const owner = await UserModel.findById(project.owner);
if (!owner) return setResponseStatus(event, 400, 'No owner');
const links = await ProjectLinkModel.find({ project_id });
return links;
});

View File

@@ -8,7 +8,11 @@ export default defineEventHandler(async event => {
const members = await TeamMemberModel.find({
user_id: userData.id
$or: [
{ user_id: userData.id },
{ email: userData.user.email }
],
pending: false
});
const projects: TProject[] = [];

View File

@@ -0,0 +1,28 @@
import { TeamMemberModel } from "~/shared/schema/TeamMemberSchema";
export default defineEventHandler(async event => {
const data = await getRequestData(event, [], []);
if (!data) return [];
const body = await readBody(event);
const { project_id } = body;
if (!project_id) return setResponseStatus(event, 400, 'project_id is required');
console.log({ project_id, user_id: data.user.id });
const member = await TeamMemberModel.findOne({
project_id, $or: [
{ user_id: data.user.id },
{ email: data.user.user.email }
]
});
if (!member) return setResponseStatus(event, 400, 'member not found');
member.pending = false;
await member.save();
return { ok: true };
});

View File

@@ -1,20 +1,54 @@
import { TeamMemberModel } from "@schema/TeamMemberSchema";
import { UserModel } from "@schema/UserSchema";
import { EmailServiceHelper } from "~/server/services/EmailServiceHelper";
import { EmailService } from "~/shared/services/EmailService";
export default defineEventHandler(async event => {
const data = await getRequestDataOld(event, { requireSchema: false, allowGuests: false, allowLitlyx: false });
const data = await getRequestData(event, [], ['OWNER']);
if (!data) return;
const { project_id } = data;
const { project_id, project, user } = data;
const { email } = await readBody(event);
const targetUser = await UserModel.findOne({ email });
if (!targetUser) return setResponseStatus(event, 400, 'No user with this email');
if (targetUser && targetUser._id.toString() === project.owner.toString()) {
return setResponseStatus(event, 400, 'You cannot invite yourself');
}
const link = `https://dashboard.litlyx.com/accept_invite?project_id=${project_id.toString()}`;
if (!targetUser) {
const exist = await TeamMemberModel.exists({ project_id, email });
if (exist) return setResponseStatus(event, 400, 'Member already invited');
await TeamMemberModel.create({
project_id,
email,
pending: true,
role: 'GUEST'
});
setImmediate(() => {
const emailData = EmailService.getEmailServerInfo('invite_project_noaccount', {
target: email,
projectName: project.name,
link
});
EmailServiceHelper.sendEmail(emailData);
});
return { ok: true };
} else {
const exist = await TeamMemberModel.exists({ project_id, user_id: targetUser.id });
if (exist) return setResponseStatus(event, 400, 'Member already invited');
await TeamMemberModel.create({
project_id,
@@ -23,6 +57,20 @@ export default defineEventHandler(async event => {
role: 'GUEST'
});
setImmediate(() => {
const emailData = EmailService.getEmailServerInfo('invite_project', {
target: email,
projectName: project.name,
link
});
EmailServiceHelper.sendEmail(emailData);
});
return { ok: true };
}
});

View File

@@ -0,0 +1,18 @@
import { TeamMemberModel } from "~/shared/schema/TeamMemberSchema";
export default defineEventHandler(async event => {
const data = await getRequestData(event, [], []);
if (!data) return [];
const body = await readBody(event);
const { project_id } = body;
if (!project_id) return setResponseStatus(event, 400, 'project_id is required');
const member = await TeamMemberModel.deleteOne({ project_id, user_id: data.user.id });
if (!member) return setResponseStatus(event, 400, 'member not found');
return { ok: true };
});

View File

@@ -0,0 +1,25 @@
import { TeamMemberModel } from "~/shared/schema/TeamMemberSchema";
export default defineEventHandler(async event => {
const data = await getRequestData(event, [], ['OWNER']);
if (!data) return [];
const body = await readBody(event);
const { member_id, webAnalytics, events, ai, domains } = body;
if (!member_id) return setResponseStatus(event, 400, 'permission_id is required');
const edited = await TeamMemberModel.updateOne({ _id: member_id }, {
permission: {
webAnalytics,
events,
ai,
domains
}
});
return { ok: edited.modifiedCount == 1 }
});

View File

@@ -0,0 +1,23 @@
import { TeamMemberModel, TPermission, TTeamMember } from "@schema/TeamMemberSchema";
export default defineEventHandler(async event => {
const data = await getRequestData(event, []);
if (!data) return;
const { member_id } = getQuery(event);
const member = await TeamMemberModel.findById(member_id);
if (!member) return setResponseStatus(event, 400, 'Cannot get member');
const resultPermission: TPermission = {
ai: false,
domains: [],
events: false,
webAnalytics: false
}
return {
permission: resultPermission,
...member.toJSON() as any
} as TTeamMember
});

View File

@@ -5,7 +5,7 @@ import { UserModel } from "@schema/UserSchema";
export default defineEventHandler(async event => {
const data = await getRequestDataOld(event, { requireSchema: false, allowGuests: false, allowLitlyx: false });
const data = await getRequestData(event, [], ['OWNER']);
if (!data) return;
const { project_id } = data;
@@ -16,6 +16,7 @@ export default defineEventHandler(async event => {
if (!user) return setResponseStatus(event, 400, 'Email not found');
await TeamMemberModel.deleteOne({ project_id, user_id: user.id });
await TeamMemberModel.deleteOne({ project_id, email: email });
return { ok: true }

View File

@@ -4,7 +4,7 @@ import { TeamMemberModel } from "@schema/TeamMemberSchema";
export default defineEventHandler(async event => {
const data = await getRequestDataOld(event, { requireSchema: false, allowGuests: false, allowLitlyx: false });
const data = await getRequestData(event, [], []);
if (!data) return;
const { project_id, user } = data;

View File

@@ -1,11 +1,20 @@
import { ProjectModel } from "@schema/project/ProjectSchema";
import { TeamMemberModel } from "@schema/TeamMemberSchema";
import { TeamMemberModel, TeamMemberRole, TPermission, TTeamMember } from "@schema/TeamMemberSchema";
import { UserModel } from "@schema/UserSchema";
export type MemberWithPermissions = {
id: string | null,
email: string,
name: string,
role: TeamMemberRole,
pending: boolean,
me: boolean,
permission: TPermission
}
export default defineEventHandler(async event => {
const data = await getRequestDataOld(event, { requireSchema: false });
const data = await getRequestData(event, [], ['OWNER']);
if (!data) return;
const { project_id, project, user } = data;
@@ -15,25 +24,49 @@ export default defineEventHandler(async event => {
const members = await TeamMemberModel.find({ project_id });
const result: { email: string, name: string, role: string, pending: boolean, me: boolean }[] = [];
const result: MemberWithPermissions[] = [];
result.push({
id: null,
email: owner.email,
name: owner.name,
role: 'OWNER',
pending: false,
me: user.id === owner.id
me: user.id === owner.id,
permission: {
webAnalytics: true,
events: true,
ai: true,
domains: ['All domains']
}
})
for (const member of members) {
const userMember = await UserModel.findById(member.user_id);
if (!userMember) continue;
let userMember;
if (member.user_id) {
userMember = await UserModel.findById(member.user_id);
} else {
userMember = await UserModel.findOne({ email: member.email });
}
const permission: TPermission = {
webAnalytics: member.permission?.webAnalytics || false,
events: member.permission?.events || false,
ai: member.permission?.ai || false,
domains: member.permission?.domains || []
}
result.push({
email: userMember.email,
name: userMember.name,
id: member.id,
email: userMember?.email || member.email || 'NO_EMAIL',
name: userMember?.name || 'NO_NAME',
role: member.role,
pending: member.pending,
me: user.id === userMember.id
me: user.id === (userMember?.id || member.user_id || 'NO_ID'),
permission
})
}

View File

@@ -0,0 +1,44 @@
import { ProjectModel } from "@schema/project/ProjectSchema";
import { TeamMemberModel, TeamMemberRole, TPermission, TTeamMember } from "@schema/TeamMemberSchema";
import { UserModel } from "@schema/UserSchema";
export default defineEventHandler(async event => {
const data = await getRequestData(event, []);
if (!data) return;
const { project_id, project, user } = data;
const owner = await UserModel.findById(project.owner, { _id: 1 });
if (owner && owner._id.toString() === user.id) return {
ai: true,
domains: ['All domains'],
events: true,
webAnalytics: true
}
const member = await TeamMemberModel.findOne({
project_id,
$or: [
{ user_id: user.id }, { email: user.user.email }
]
});
if (!member) return {
ai: false,
domains: [],
events: false,
webAnalytics: false
}
return {
ai: false,
domains: [],
events: false,
webAnalytics: false,
...(member.permission as any),
} as TPermission
});

View File

@@ -0,0 +1,44 @@
import { TeamMemberModel } from "@schema/TeamMemberSchema";
import { Types } from "mongoose";
export default defineEventHandler(async event => {
const data = await getRequestData(event);
if (!data) return;
const members = await TeamMemberModel.aggregate([
{
$match:
{
$or: [
{ user_id: new Types.ObjectId(data.user.id) },
{ email: data.user.user.email }
],
pending: true
}
},
{
$lookup: {
from: 'projects',
as: 'project',
foreignField: '_id',
localField: 'project_id',
}
},
{
$addFields: {
project_name: { $arrayElemAt: ["$project.name", 0] }
}
},
{
$project: {
project: 0
}
}
]);
return members;
});

View File

@@ -0,0 +1,9 @@
import { BotTrafficOptionModel } from "~/shared/schema/shields/BotTrafficOptionSchema";
export default defineEventHandler(async event => {
const data = await getRequestData(event, [], ['OWNER']);
if (!data) return;
const result = await BotTrafficOptionModel.findOne({ project_id: data.project_id });
if (!result) return { block: false };
return { block: result.block }
});

View File

@@ -0,0 +1,14 @@
import { BotTrafficOptionModel } from "~/shared/schema/shields/BotTrafficOptionSchema";
export default defineEventHandler(async event => {
const data = await getRequestData(event, [], ['OWNER']);
if (!data) return;
const body = await readBody(event);
const { block } = body;
if (block != true && block != false)
return setResponseStatus(event, 400, 'block is required and must be true or false');
const result = await BotTrafficOptionModel.updateOne({ project_id: data.project_id }, { block }, { upsert: true });
return { ok: result.acknowledged };
});

View File

@@ -0,0 +1,11 @@
import { CountryBlacklistModel } from "~/shared/schema/shields/CountryBlacklistSchema";
export default defineEventHandler(async event => {
const data = await getRequestData(event, [], ['OWNER']);
if (!data) return;
const body = await readBody(event);
const { country, description } = body;
if (country.trim().length == 0) return setResponseStatus(event, 400, 'Country is required');
const result = await CountryBlacklistModel.updateOne({ project_id: data.project_id, country }, { description }, { upsert: true });
return { ok: result.acknowledged };
});

View File

@@ -0,0 +1,14 @@
import { CountryBlacklistModel } from "~/shared/schema/shields/CountryBlacklistSchema";
export default defineEventHandler(async event => {
const data = await getRequestData(event, [], ['OWNER']);
if (!data) return;
const body = await readBody(event);
const { country } = body;
const removal = await CountryBlacklistModel.deleteOne({ project_id: data.project_id, country });
return { ok: removal.deletedCount == 1 };
});

View File

@@ -0,0 +1,8 @@
import { CountryBlacklistModel } from "~/shared/schema/shields/CountryBlacklistSchema";
export default defineEventHandler(async event => {
const data = await getRequestData(event, [], ['OWNER']);
if (!data) return;
const blacklist = await CountryBlacklistModel.find({ project_id: data.project_id });
return blacklist.map(e => e.toJSON());
});

View File

@@ -0,0 +1,21 @@
import { DomainWhitelistModel } from "~/shared/schema/shields/DomainWhitelistSchema";
export default defineEventHandler(async event => {
const data = await getRequestData(event, [], ['OWNER']);
if (!data) return;
const body = await readBody(event);
const { domain } = body;
if (domain.trim().length == 0) return setResponseStatus(event, 400, 'Domain is required');
const whitelist = await DomainWhitelistModel.updateOne({
project_id: data.project_id
},
{ $push: { domains: domain } },
{ upsert: true }
);
return { ok: true };
});

View File

@@ -0,0 +1,18 @@
import { DomainWhitelistModel } from "~/shared/schema/shields/DomainWhitelistSchema";
export default defineEventHandler(async event => {
const data = await getRequestData(event, [], ['OWNER']);
if (!data) return;
const body = await readBody(event);
const { domain } = body;
const removal = await DomainWhitelistModel.updateOne({
project_id: data.project_id
},
{ $pull: { domains: domain } },
);
return { ok: removal.modifiedCount == 1 };
});

View File

@@ -0,0 +1,10 @@
import { DomainWhitelistModel } from "~/shared/schema/shields/DomainWhitelistSchema";
export default defineEventHandler(async event => {
const data = await getRequestData(event, [], ['OWNER']);
if (!data) return;
const whitelist = await DomainWhitelistModel.findOne({ project_id: data.project_id });
if (!whitelist) return [];
const domains = whitelist.domains;
return domains;
});

View File

@@ -0,0 +1,11 @@
import { AddressBlacklistModel } from "~/shared/schema/shields/AddressBlacklistSchema";
export default defineEventHandler(async event => {
const data = await getRequestData(event, [], ['OWNER']);
if (!data) return;
const body = await readBody(event);
const { address, description } = body;
if (address.trim().length == 0) return setResponseStatus(event, 400, 'Address is required');
const result = await AddressBlacklistModel.updateOne({ project_id: data.project_id, address }, { description }, { upsert: true });
return { ok: result.acknowledged };
});

View File

@@ -0,0 +1,14 @@
import { AddressBlacklistModel } from "~/shared/schema/shields/AddressBlacklistSchema";
export default defineEventHandler(async event => {
const data = await getRequestData(event, [], ['OWNER']);
if (!data) return;
const body = await readBody(event);
const { address } = body;
const removal = await AddressBlacklistModel.deleteOne({ project_id: data.project_id, address });
return { ok: removal.deletedCount == 1 };
});

View File

@@ -0,0 +1,8 @@
import { AddressBlacklistModel } from "~/shared/schema/shields/AddressBlacklistSchema";
export default defineEventHandler(async event => {
const data = await getRequestData(event, [], ['OWNER']);
if (!data) return;
const blacklist = await AddressBlacklistModel.find({ project_id: data.project_id });
return blacklist.map(e => e.toJSON());
});

Some files were not shown because too many files have changed in this diff Show More