mirror of
https://github.com/Litlyx/litlyx
synced 2025-12-10 15:58:38 +01:00
add api keys
This commit is contained in:
@@ -1,9 +1,11 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import type { TApiSettings } from '@schema/ApiSettingsSchema';
|
||||||
import type { SettingsTemplateEntry } from './Template.vue';
|
import type { SettingsTemplateEntry } from './Template.vue';
|
||||||
|
|
||||||
|
|
||||||
const entries: SettingsTemplateEntry[] = [
|
const entries: SettingsTemplateEntry[] = [
|
||||||
{ id: 'pname', title: 'Name', text: 'Project name' },
|
{ id: 'pname', title: 'Name', text: 'Project name' },
|
||||||
|
{ id: 'api', title: 'ApiKeys', text: 'Manage your authorization token' },
|
||||||
{ id: 'pid', title: 'Id', text: 'Project id' },
|
{ id: 'pid', title: 'Id', text: 'Project id' },
|
||||||
{ id: 'pscript', title: 'Script', text: 'Universal javascript integration' },
|
{ id: 'pscript', title: 'Script', text: 'Universal javascript integration' },
|
||||||
{ id: 'pdelete', title: 'Delete', text: 'Delete current project' },
|
{ id: 'pdelete', title: 'Delete', text: 'Delete current project' },
|
||||||
@@ -12,8 +14,54 @@ const entries: SettingsTemplateEntry[] = [
|
|||||||
const activeProject = useActiveProject();
|
const activeProject = useActiveProject();
|
||||||
const projectNameInputVal = ref<string>(activeProject.value?.name || '');
|
const projectNameInputVal = ref<string>(activeProject.value?.name || '');
|
||||||
|
|
||||||
|
|
||||||
|
const apiKeys = ref<TApiSettings[]>([]);
|
||||||
|
|
||||||
|
const newApiKeyName = ref<string>('');
|
||||||
|
|
||||||
|
async function updateApiKeys() {
|
||||||
|
newApiKeyName.value = '';
|
||||||
|
apiKeys.value = await $fetch<TApiSettings[]>('/api/keys/get_all', signHeaders());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createApiKey() {
|
||||||
|
try {
|
||||||
|
const res = await $fetch<TApiSettings>('/api/keys/create', {
|
||||||
|
method: 'POST', ...signHeaders({
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}),
|
||||||
|
body: JSON.stringify({ name: newApiKeyName.value })
|
||||||
|
});
|
||||||
|
apiKeys.value.push(res);
|
||||||
|
newApiKeyName.value = '';
|
||||||
|
} catch (ex: any) {
|
||||||
|
alert(ex.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteApiKey(api_id: string) {
|
||||||
|
try {
|
||||||
|
const res = await $fetch<TApiSettings>('/api/keys/delete', {
|
||||||
|
method: 'DELETE', ...signHeaders({
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}),
|
||||||
|
body: JSON.stringify({ api_id })
|
||||||
|
});
|
||||||
|
newApiKeyName.value = '';
|
||||||
|
await updateApiKeys();
|
||||||
|
} catch (ex: any) {
|
||||||
|
alert(ex.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateApiKeys();
|
||||||
|
})
|
||||||
|
|
||||||
watch(activeProject, () => {
|
watch(activeProject, () => {
|
||||||
projectNameInputVal.value = activeProject.value?.name || "";
|
projectNameInputVal.value = activeProject.value?.name || "";
|
||||||
|
updateApiKeys();
|
||||||
})
|
})
|
||||||
|
|
||||||
const canChange = computed(() => {
|
const canChange = computed(() => {
|
||||||
@@ -61,6 +109,32 @@ async function deleteProject() {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { createAlert } = useAlert()
|
||||||
|
|
||||||
|
function copyScript() {
|
||||||
|
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
||||||
|
|
||||||
|
|
||||||
|
const createScriptText = () => {
|
||||||
|
return [
|
||||||
|
'<script defer ',
|
||||||
|
`data-project="${activeProject.value?._id}" `,
|
||||||
|
'src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></',
|
||||||
|
'script>'
|
||||||
|
].join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(createScriptText());
|
||||||
|
createAlert('Success', 'Script copied successfully.', 'far fa-circle-check', 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function copyProjectId() {
|
||||||
|
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
|
||||||
|
navigator.clipboard.writeText(activeProject.value?._id?.toString() || '');
|
||||||
|
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
@@ -74,10 +148,31 @@ async function deleteProject() {
|
|||||||
<LyxUiButton @click="changeProjectName()" :disabled="!canChange" type="primary"> Change </LyxUiButton>
|
<LyxUiButton @click="changeProjectName()" :disabled="!canChange" type="primary"> Change </LyxUiButton>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<template #api>
|
||||||
|
<div class="flex items-center gap-4" v-if="apiKeys && apiKeys.length < 5">
|
||||||
|
<LyxUiInput class="grow px-4 py-2" placeholder="ApiKeyName" v-model="newApiKeyName"></LyxUiInput>
|
||||||
|
<LyxUiButton @click="createApiKey()" :disabled="newApiKeyName.length < 3" type="primary">
|
||||||
|
<i class="far fa-plus"></i>
|
||||||
|
</LyxUiButton>
|
||||||
|
</div>
|
||||||
|
<LyxUiCard v-if="apiKeys && apiKeys.length > 0" class="w-full flex flex-col gap-4 items-center mt-4">
|
||||||
|
<div v-for="apiKey of apiKeys" class="flex flex-col w-full">
|
||||||
|
|
||||||
|
<div class="flex gap-8 items-center">
|
||||||
|
<div class="grow">Name: {{ apiKey.apiName }}</div>
|
||||||
|
<div>{{ apiKey.apiKey }}</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<i class="far fa-trash cursor-pointer" @click="deleteApiKey(apiKey._id.toString())"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</LyxUiCard>
|
||||||
|
</template>
|
||||||
<template #pid>
|
<template #pid>
|
||||||
<LyxUiCard class="w-full flex items-center">
|
<LyxUiCard class="w-full flex items-center">
|
||||||
<div class="grow">{{ activeProject?._id.toString() }}</div>
|
<div class="grow">{{ activeProject?._id.toString() }}</div>
|
||||||
<div><i class="far fa-copy"></i></div>
|
<div><i class="far fa-copy" @click="copyProjectId()"></i></div>
|
||||||
</LyxUiCard>
|
</LyxUiCard>
|
||||||
</template>
|
</template>
|
||||||
<template #pscript>
|
<template #pscript>
|
||||||
@@ -87,7 +182,7 @@ async function deleteProject() {
|
|||||||
<script defer data-project="${activeProject?._id}"
|
<script defer data-project="${activeProject?._id}"
|
||||||
src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></script>` }}
|
src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></script>` }}
|
||||||
</div>
|
</div>
|
||||||
<div><i class="far fa-copy"></i></div>
|
<div><i class="far fa-copy" @click="copyScript()"></i></div>
|
||||||
</LyxUiCard>
|
</LyxUiCard>
|
||||||
</template>
|
</template>
|
||||||
<template #pdelete>
|
<template #pdelete>
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ const limitsInfo = ref<{
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (route.query.just_logged) return location.href = '/';
|
if (route.query.just_logged) return location.href = '/';
|
||||||
limitsInfo.value = await $fetch<any>("/api/project/limits_info", signHeaders());
|
limitsInfo.value = await $fetch<any>("/api/project/limits_info", signHeaders());
|
||||||
|
watch(activeProject, async () => {
|
||||||
|
limitsInfo.value = await $fetch<any>("/api/project/limits_info", signHeaders());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
47
dashboard/server/api/keys/create.post.ts
Normal file
47
dashboard/server/api/keys/create.post.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
|
||||||
|
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||||
|
import { ApiSettingsModel, TApiSettings } from "@schema/ApiSettingsSchema";
|
||||||
|
import { UserSettingsModel } from "@schema/UserSettings";
|
||||||
|
import { ProjectModel } from "@schema/ProjectSchema";
|
||||||
|
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
function generateApiKey() {
|
||||||
|
return 'lit_' + crypto.randomBytes(6).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 userData = getRequestUser(event);
|
||||||
|
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged');
|
||||||
|
|
||||||
|
const currentActiveProject = await UserSettingsModel.findOne({ user_id: userData.id });
|
||||||
|
if (!currentActiveProject) return setResponseStatus(event, 400, 'You need to select a project');
|
||||||
|
|
||||||
|
const project_id = currentActiveProject.active_project_id;
|
||||||
|
|
||||||
|
const project = await ProjectModel.findById(project_id);
|
||||||
|
if (!project) return setResponseStatus(event, 400, 'Project not found');
|
||||||
|
|
||||||
|
if (project.owner.toString() != userData.id) {
|
||||||
|
return setResponseStatus(event, 400, 'You are not the owner');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
|
||||||
|
return newApiSettings.toJSON();
|
||||||
|
|
||||||
|
});
|
||||||
28
dashboard/server/api/keys/delete.delete.ts
Normal file
28
dashboard/server/api/keys/delete.delete.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
|
||||||
|
import { ApiSettingsModel } from "@schema/ApiSettingsSchema";
|
||||||
|
import { UserSettingsModel } from "@schema/UserSettings";
|
||||||
|
import { ProjectModel } from "@schema/ProjectSchema";
|
||||||
|
|
||||||
|
export default defineEventHandler(async event => {
|
||||||
|
|
||||||
|
const body = await readBody(event);
|
||||||
|
|
||||||
|
const userData = getRequestUser(event);
|
||||||
|
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged');
|
||||||
|
|
||||||
|
const currentActiveProject = await UserSettingsModel.findOne({ user_id: userData.id });
|
||||||
|
if (!currentActiveProject) return setResponseStatus(event, 400, 'You need to select a project');
|
||||||
|
|
||||||
|
const project_id = currentActiveProject.active_project_id;
|
||||||
|
|
||||||
|
const project = await ProjectModel.findById(project_id);
|
||||||
|
if (!project) return setResponseStatus(event, 400, 'Project not found');
|
||||||
|
|
||||||
|
if (project.owner.toString() != userData.id) {
|
||||||
|
return setResponseStatus(event, 400, 'You are not the owner');
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletation = await ApiSettingsModel.deleteOne({ _id: body.api_id });
|
||||||
|
return { ok: deletation.acknowledged };
|
||||||
|
|
||||||
|
});
|
||||||
33
dashboard/server/api/keys/get_all.ts
Normal file
33
dashboard/server/api/keys/get_all.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
|
||||||
|
import { getUserProjectFromId } from "~/server/LIVE_DEMO_DATA";
|
||||||
|
import { ApiSettingsModel, TApiSettings } from "@schema/ApiSettingsSchema";
|
||||||
|
import { UserSettingsModel } from "@schema/UserSettings";
|
||||||
|
import { ProjectModel } from "@schema/ProjectSchema";
|
||||||
|
|
||||||
|
|
||||||
|
function cryptApiKeyName(apiSettings: TApiSettings): TApiSettings {
|
||||||
|
return { ...apiSettings, apiKey: apiSettings.apiKey.substring(0, 6) + '******' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async event => {
|
||||||
|
|
||||||
|
const userData = getRequestUser(event);
|
||||||
|
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged');
|
||||||
|
|
||||||
|
const currentActiveProject = await UserSettingsModel.findOne({ user_id: userData.id });
|
||||||
|
if (!currentActiveProject) return setResponseStatus(event, 400, 'You need to select a project');
|
||||||
|
|
||||||
|
const project_id = currentActiveProject.active_project_id;
|
||||||
|
|
||||||
|
const project = await ProjectModel.findById(project_id);
|
||||||
|
if (!project) return setResponseStatus(event, 400, 'Project not found');
|
||||||
|
|
||||||
|
if (project.owner.toString() != userData.id) {
|
||||||
|
return setResponseStatus(event, 400, 'You are not the owner');
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKeys = await ApiSettingsModel.find({ project_id }, { project_id: 0 })
|
||||||
|
|
||||||
|
return apiKeys.map(e => cryptApiKeyName(e.toJSON())) as TApiSettings[];
|
||||||
|
|
||||||
|
});
|
||||||
@@ -22,6 +22,8 @@ export default defineEventHandler(async event => {
|
|||||||
|
|
||||||
const { name } = await readBody(event);
|
const { name } = await readBody(event);
|
||||||
|
|
||||||
|
if (name.length == 0) return setResponseStatus(event, 400, 'name is required');
|
||||||
|
|
||||||
project.name = name;
|
project.name = name;
|
||||||
await project.save();
|
await project.save();
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ProjectModel, TProject } from "@schema/ProjectSchema";
|
|||||||
import { UserSettingsModel } from "@schema/UserSettings";
|
import { UserSettingsModel } from "@schema/UserSettings";
|
||||||
import { VisitModel } from '@schema/metrics/VisitSchema';
|
import { VisitModel } from '@schema/metrics/VisitSchema';
|
||||||
import { EventModel } from '@schema/metrics/EventSchema';
|
import { EventModel } from '@schema/metrics/EventSchema';
|
||||||
|
import { ProjectSnapshotModel } from '@schema/ProjectSnapshot';
|
||||||
|
|
||||||
|
|
||||||
type PDF_Data = {
|
type PDF_Data = {
|
||||||
@@ -114,8 +115,11 @@ export default defineEventHandler(async event => {
|
|||||||
const project = await ProjectModel.findById(project_id);
|
const project = await ProjectModel.findById(project_id);
|
||||||
if (!project) return setResponseStatus(event, 400, 'Project not found');
|
if (!project) return setResponseStatus(event, 400, 'Project not found');
|
||||||
|
|
||||||
|
const fromHeader = getHeader(event, 'x-from');
|
||||||
|
const toHeader = getHeader(event, 'x-from');
|
||||||
|
|
||||||
|
const from = fromHeader;
|
||||||
|
const to = toHeader;
|
||||||
|
|
||||||
const eventsCount = await EventModel.countDocuments({ project_id: project._id });
|
const eventsCount = await EventModel.countDocuments({ project_id: project._id });
|
||||||
const visitsCount = await VisitModel.countDocuments({ project_id: project._id });
|
const visitsCount = await VisitModel.countDocuments({ project_id: project._id });
|
||||||
|
|||||||
44
dashboard/server/api/v1/events.ts
Normal file
44
dashboard/server/api/v1/events.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
|
||||||
|
import { ApiSettingsModel } from '@schema/ApiSettingsSchema';
|
||||||
|
import { EventModel } from '@schema/metrics/EventSchema';
|
||||||
|
|
||||||
|
export default defineEventHandler(async event => {
|
||||||
|
|
||||||
|
const { row, from, to, limit } = getQuery(event);
|
||||||
|
|
||||||
|
const authorization = getHeader(event, 'Authorization');
|
||||||
|
if (!authorization) return setResponseStatus(event, 403, 'Authorization is required');
|
||||||
|
|
||||||
|
const [type, token] = authorization.split(' ');
|
||||||
|
if (type != 'Bearer') return setResponseStatus(event, 401, 'Malformed authorization');
|
||||||
|
|
||||||
|
const apiSettings = await ApiSettingsModel.findOne({ apiKey: token });
|
||||||
|
if (!apiSettings) return setResponseStatus(event, 401, 'ApiKey not valid');
|
||||||
|
|
||||||
|
if (!row) return setResponseStatus(event, 400, 'row is required');
|
||||||
|
|
||||||
|
|
||||||
|
const rows: string[] = Array.isArray(row) ? row as string[] : [row as string];
|
||||||
|
|
||||||
|
const projection: any = {};
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
projection[row] = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitNumber = parseInt((limit as string));
|
||||||
|
|
||||||
|
const limitValue = isNaN(limitNumber) ? 100 : limitNumber;
|
||||||
|
|
||||||
|
const visits = await EventModel.find({
|
||||||
|
project_id: apiSettings.project_id,
|
||||||
|
created_at: {
|
||||||
|
$gte: from || new Date(2023, 0),
|
||||||
|
$lte: to || new Date(3000, 0)
|
||||||
|
}
|
||||||
|
}, { _id: 0, ...projection }, { limit: limitValue });
|
||||||
|
|
||||||
|
return visits.map(e => e.toJSON());
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
44
dashboard/server/api/v1/visits.ts
Normal file
44
dashboard/server/api/v1/visits.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
|
||||||
|
import { ApiSettingsModel } from '@schema/ApiSettingsSchema';
|
||||||
|
import { VisitModel } from '@schema/metrics/VisitSchema';
|
||||||
|
|
||||||
|
export default defineEventHandler(async event => {
|
||||||
|
|
||||||
|
const { row, from, to, limit } = getQuery(event);
|
||||||
|
|
||||||
|
const authorization = getHeader(event, 'Authorization');
|
||||||
|
if (!authorization) return setResponseStatus(event, 403, 'Authorization is required');
|
||||||
|
|
||||||
|
const [type, token] = authorization.split(' ');
|
||||||
|
if (type != 'Bearer') return setResponseStatus(event, 401, 'Malformed authorization');
|
||||||
|
|
||||||
|
const apiSettings = await ApiSettingsModel.findOne({ apiKey: token });
|
||||||
|
if (!apiSettings) return setResponseStatus(event, 401, 'ApiKey not valid');
|
||||||
|
|
||||||
|
if (!row) return setResponseStatus(event, 400, 'row is required');
|
||||||
|
|
||||||
|
|
||||||
|
const rows: string[] = Array.isArray(row) ? row as string[] : [row as string];
|
||||||
|
|
||||||
|
const projection: any = {};
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
projection[row] = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitNumber = parseInt((limit as string));
|
||||||
|
|
||||||
|
const limitValue = isNaN(limitNumber) ? 100 : limitNumber;
|
||||||
|
|
||||||
|
const visits = await VisitModel.find({
|
||||||
|
project_id: apiSettings.project_id,
|
||||||
|
created_at: {
|
||||||
|
$gte: from || new Date(2023, 0),
|
||||||
|
$lte: to || new Date(3000, 0)
|
||||||
|
}
|
||||||
|
}, { _id: 0, ...projection }, { limit: limitValue });
|
||||||
|
|
||||||
|
return visits.map(e => e.toJSON());
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
@@ -127,7 +127,7 @@ export async function sendMessageOnChat(text: string, pid: string, initial_chat_
|
|||||||
messages.push({ tool_call_id: toolCall.id, role: "tool", content: JSON.stringify(functionResponse) });
|
messages.push({ tool_call_id: toolCall.id, role: "tool", content: JSON.stringify(functionResponse) });
|
||||||
await addMessageToChat({ tool_call_id: toolCall.id, role: "tool", content: JSON.stringify(functionResponse) }, chat_id);
|
await addMessageToChat({ tool_call_id: toolCall.id, role: "tool", content: JSON.stringify(functionResponse) }, chat_id);
|
||||||
}
|
}
|
||||||
response = await openai.chat.completions.create({ model: 'gpt-3.5-turbo', messages, n: 1, tools });
|
response = await openai.chat.completions.create({ model: 'gpt-4o', messages, n: 1, tools });
|
||||||
responseMessage = response.choices[0].message;
|
responseMessage = response.choices[0].message;
|
||||||
toolCalls = responseMessage.tool_calls;
|
toolCalls = responseMessage.tool_calls;
|
||||||
|
|
||||||
|
|||||||
20
shared/schema/ApiSettingsSchema.ts
Normal file
20
shared/schema/ApiSettingsSchema.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { model, Schema, Types } from 'mongoose';
|
||||||
|
|
||||||
|
export type TApiSettings = {
|
||||||
|
_id: Schema.Types.ObjectId,
|
||||||
|
project_id: Schema.Types.ObjectId,
|
||||||
|
apiKey: string,
|
||||||
|
apiName: string,
|
||||||
|
usage: number,
|
||||||
|
created_at: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
const ApiSettingsSchema = new Schema<TApiSettings>({
|
||||||
|
project_id: { type: Types.ObjectId, index: 1 },
|
||||||
|
apiKey: { type: String, required: true },
|
||||||
|
apiName: { type: String, required: true },
|
||||||
|
usage: { type: Number, default: 0, required: true, },
|
||||||
|
created_at: { type: Date, default: () => Date.now() },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ApiSettingsModel = model<TApiSettings>('api_settings', ApiSettingsSchema);
|
||||||
Reference in New Issue
Block a user