login is functional now
This commit is contained in:
parent
05b9ddd5a1
commit
d0937f626d
61
main.js
61
main.js
|
@ -3,6 +3,7 @@ const { app, BrowserWindow, Menu, ipcMain } = require("electron");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const serve = require("electron-serve");
|
const serve = require("electron-serve");
|
||||||
const loadURL = serve({ directory: "public" });
|
const loadURL = serve({ directory: "public" });
|
||||||
|
const config = require("./src/config/config");
|
||||||
const { setupTitlebar, attachTitlebarToWindow } = require(
|
const { setupTitlebar, attachTitlebarToWindow } = require(
|
||||||
"custom-electron-titlebar/main",
|
"custom-electron-titlebar/main",
|
||||||
);
|
);
|
||||||
|
@ -19,7 +20,10 @@ function registerIPCPipes() {
|
||||||
ipcMain.handle("ezpplauncher:login", async (e, args) => {
|
ipcMain.handle("ezpplauncher:login", async (e, args) => {
|
||||||
const fetchResult = await fetch("https://ez-pp.farm/login/check", {
|
const fetchResult = await fetch("https://ez-pp.farm/login/check", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ username: args.username, password: args.password }),
|
body: JSON.stringify({
|
||||||
|
username: args.username,
|
||||||
|
password: args.password,
|
||||||
|
}),
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
|
@ -27,12 +31,61 @@ function registerIPCPipes() {
|
||||||
|
|
||||||
if (fetchResult.ok) {
|
if (fetchResult.ok) {
|
||||||
const result = await fetchResult.json();
|
const result = await fetchResult.json();
|
||||||
if (result.code == 200) return result;
|
if ("user" in result) {
|
||||||
|
if (args.saveCredentials) {
|
||||||
|
config.set("username", args.username);
|
||||||
|
config.set("password", args.password);
|
||||||
|
}
|
||||||
|
config.remove("guest");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
code: 403,
|
code: 500,
|
||||||
message: "Invalid username or password.",
|
message: "Something went wrong while logging you in.",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("ezpplauncher:autologin", async (e) => {
|
||||||
|
const username = config.get("username");
|
||||||
|
const password = config.get("password");
|
||||||
|
const guest = config.get("guest");
|
||||||
|
if (guest) return { code: 200, message: "Login as guest", guest: true };
|
||||||
|
if (username == undefined || password == undefined) {
|
||||||
|
return { code: 200, message: "No autologin" };
|
||||||
}
|
}
|
||||||
|
const fetchResult = await fetch("https://ez-pp.farm/login/check", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fetchResult.ok) {
|
||||||
|
const result = await fetchResult.json();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
code: 500,
|
||||||
|
message: "Something went wrong while logging you in.",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("ezpplauncher:guestlogin", (e) => {
|
||||||
|
config.remove("username");
|
||||||
|
config.remove("password");
|
||||||
|
config.set("guest", "1");
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("ezpplauncher:logout", (e) => {
|
||||||
|
config.remove("username");
|
||||||
|
config.remove("password");
|
||||||
|
config.remove("guest");
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
1331
package-lock.json
generated
1331
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
@ -24,6 +24,7 @@
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "rollup -c --bundleConfigAsCjs",
|
"build": "rollup -c --bundleConfigAsCjs",
|
||||||
|
"rebuild": "electron-rebuild",
|
||||||
"dev": "rollup -c -w --bundleConfigAsCjs",
|
"dev": "rollup -c -w --bundleConfigAsCjs",
|
||||||
"start": "sirv public --no-clear",
|
"start": "sirv public --no-clear",
|
||||||
"electron": "wait-on http://localhost:8080 && electron .",
|
"electron": "wait-on http://localhost:8080 && electron .",
|
||||||
|
@ -33,7 +34,10 @@
|
||||||
"check": "svelte-check --tsconfig ./tsconfig.json"
|
"check": "svelte-check --tsconfig ./tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@electron/rebuild": "^3.5.0",
|
||||||
|
"@types/better-sqlite3": "^7.6.8",
|
||||||
"axios": "^1.6.5",
|
"axios": "^1.6.5",
|
||||||
|
"better-sqlite3": "^9.2.2",
|
||||||
"custom-electron-titlebar": "^4.2.7",
|
"custom-electron-titlebar": "^4.2.7",
|
||||||
"electron-serve": "^1.1.0",
|
"electron-serve": "^1.1.0",
|
||||||
"svelte-french-toast": "^1.2.0"
|
"svelte-french-toast": "^1.2.0"
|
||||||
|
|
22
preload.js
22
preload.js
|
@ -15,6 +15,24 @@ window.addEventListener("login-attempt", async (e) => {
|
||||||
const loginResult = await ipcRenderer.invoke("ezpplauncher:login", {
|
const loginResult = await ipcRenderer.invoke("ezpplauncher:login", {
|
||||||
username: e.detail.username,
|
username: e.detail.username,
|
||||||
password: e.detail.password,
|
password: e.detail.password,
|
||||||
|
saveCredentials: e.detail.saveCredentials,
|
||||||
|
});
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("login-result", { detail: loginResult }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("autologin-attempt", async (e) => {
|
||||||
|
const loginResult = await ipcRenderer.invoke("ezpplauncher:autologin");
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("login-result", { detail: loginResult }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("logout", async (e) => {
|
||||||
|
await ipcRenderer.invoke("ezpplauncher:logout");
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("guest-login", async (e) => {
|
||||||
|
await ipcRenderer.invoke("ezpplauncher:guestlogin");
|
||||||
});
|
});
|
||||||
window.dispatchEvent(new CustomEvent("login-result", { detail: loginResult }));
|
|
||||||
})
|
|
|
@ -31,6 +31,12 @@
|
||||||
loggedIn = newUser != undefined;
|
loggedIn = newUser != undefined;
|
||||||
user = newUser;
|
user = newUser;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
window.dispatchEvent(new CustomEvent("logout"));
|
||||||
|
currentUser.set(undefined);
|
||||||
|
currentPage.set(Page.Login);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Toaster></Toaster>
|
<Toaster></Toaster>
|
||||||
|
@ -52,7 +58,7 @@
|
||||||
id="avatar-menu"
|
id="avatar-menu"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Dropdown placement="bottom" triggeredBy="#avatar-menu">
|
<Dropdown placement="bottom-start" triggeredBy="#avatar-menu">
|
||||||
<DropdownHeader>
|
<DropdownHeader>
|
||||||
<span class="block text-sm">{loggedIn ? user?.name : "Guest"}</span>
|
<span class="block text-sm">{loggedIn ? user?.name : "Guest"}</span>
|
||||||
<span
|
<span
|
||||||
|
@ -79,10 +85,7 @@
|
||||||
{#if loggedIn}
|
{#if loggedIn}
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
class="flex flex-row gap-2 border-0 dark:!bg-gray-700 dark:active:!bg-gray-900 dark:hover:!bg-gray-800 transition-colors"
|
class="flex flex-row gap-2 border-0 dark:!bg-gray-700 dark:active:!bg-gray-900 dark:hover:!bg-gray-800 transition-colors"
|
||||||
on:click={() => {
|
on:click={logout}
|
||||||
currentUser.set(undefined);
|
|
||||||
currentPage.set(Page.Login);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<ArrowRightFromBracketSolid
|
<ArrowRightFromBracketSolid
|
||||||
class="select-none outline-none border-none"
|
class="select-none outline-none border-none"
|
||||||
|
|
45
src/config/config.js
Normal file
45
src/config/config.js
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
const sqlite = require("better-sqlite3");
|
||||||
|
const path = require("path");
|
||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
const configFolder = path.join(
|
||||||
|
process.platform == "win32"
|
||||||
|
? process.env["LOCALAPPDATA"]
|
||||||
|
: process.env["HOME"],
|
||||||
|
"EZPPLauncher",
|
||||||
|
);
|
||||||
|
if (!fs.existsSync(configFolder)) fs.mkdirSync(configFolder);
|
||||||
|
|
||||||
|
const dbFile = path.join(configFolder, "ezpplauncher.db");
|
||||||
|
|
||||||
|
const db = sqlite(dbFile);
|
||||||
|
db.pragma("journal_mode = WAL");
|
||||||
|
|
||||||
|
db.exec(
|
||||||
|
"CREATE TABLE IF NOT EXISTS config (configKey VARCHAR PRIMARY KEY, configValue VARCHAR);",
|
||||||
|
);
|
||||||
|
|
||||||
|
const set = (key, value) => {
|
||||||
|
db.prepare(
|
||||||
|
`INSERT OR REPLACE INTO config (configKey, configValue) VALUES (?, ?)`,
|
||||||
|
).run(key, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (key) => {
|
||||||
|
db.prepare(`DELETE FROM config WHERE configKey = ?`).run(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const get = (
|
||||||
|
key,
|
||||||
|
) => {
|
||||||
|
const result = db.prepare(
|
||||||
|
"SELECT configKey key, configValue val FROM config WHERE key = ?",
|
||||||
|
).get(key);
|
||||||
|
return result ? result.val ?? undefined : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
get,
|
||||||
|
set,
|
||||||
|
remove,
|
||||||
|
};
|
|
@ -1,24 +1,44 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from "flowbite-svelte";
|
import { Button, Checkbox } from "flowbite-svelte";
|
||||||
import Progressbar from "../lib/Progressbar.svelte";
|
import Progressbar from "../lib/Progressbar.svelte";
|
||||||
|
let progressbarFix = true;
|
||||||
|
let launching = false;
|
||||||
|
let patch = true;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
progressbarFix = false;
|
||||||
|
}, 1000);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="h-[265px] my-auto flex flex-col justify-center items-center p-5">
|
<main
|
||||||
|
class="h-[265px] my-auto flex flex-col justify-center items-center p-5 animate-fadeIn"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="container flex flex-col items-center justify-center gap-5 rounded-lg p-3"
|
class="container flex flex-col items-center justify-center gap-3 rounded-lg p-3"
|
||||||
>
|
>
|
||||||
<Button color="light" size="xl" class="dark:active:!bg-gray-900"
|
<Button
|
||||||
>Launch</Button
|
color="light"
|
||||||
|
size="xl"
|
||||||
|
class="dark:active:!bg-gray-900 {launching
|
||||||
|
? ''
|
||||||
|
: 'active:scale-95 '}transition-transform duration-75"
|
||||||
|
disabled={launching}
|
||||||
|
on:click={() => (launching = !launching)}>Launch</Button
|
||||||
|
>
|
||||||
|
<Checkbox disabled={launching} bind:checked={patch}>Patch</Checkbox>
|
||||||
|
<div
|
||||||
|
class="w-full flex flex-col justify-center items-center gap-2 mt-2 {launching
|
||||||
|
? 'animate-fadeIn '
|
||||||
|
: 'animate-fadeOut '}{progressbarFix ? '!opacity-0' : 'opacity-0'}"
|
||||||
>
|
>
|
||||||
<div class="w-full flex flex-col justify-center items-center gap-2">
|
|
||||||
<p class="m-0 p-0 dark:text-gray-100">Waiting</p>
|
|
||||||
<Progressbar
|
<Progressbar
|
||||||
animate={true}
|
animate={true}
|
||||||
progress={null}
|
progress={null}
|
||||||
labelInside={true}
|
labelInside={true}
|
||||||
size="h-6"
|
size="h-3"
|
||||||
labelInsideClass="bg-primary-600 drop-shadow-xl text-gray-100 text-base font-medium text-center p-1 leading-none rounded-full"
|
labelInsideClass="bg-primary-600 drop-shadow-xl text-gray-100 text-base font-medium text-center p-1 leading-none rounded-full"
|
||||||
/>
|
/>
|
||||||
|
<p class="m-0 p-0 dark:text-gray-400 font-light">Waiting...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
@ -1,15 +1,16 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Input, Button, Spinner } from "flowbite-svelte";
|
import { Input, Button, Spinner, Checkbox } from "flowbite-svelte";
|
||||||
import { performLogin } from "../util/loginUtil";
|
import { performLogin } from "../util/loginUtil";
|
||||||
import type { User } from "../types/user";
|
import type { User } from "../types/user";
|
||||||
import type { Error } from "../types/error";
|
import type { Error } from "../types/error";
|
||||||
import { currentPage, currentUser } from "../storage/localStore";
|
import { currentPage, currentUser, startup } from "../storage/localStore";
|
||||||
import toast from "svelte-french-toast";
|
import toast from "svelte-french-toast";
|
||||||
import { Page } from "../consts/pages";
|
import { Page } from "../consts/pages";
|
||||||
|
|
||||||
let loading = false;
|
let loading = false;
|
||||||
let username = "";
|
let username = "";
|
||||||
let password = "";
|
let password = "";
|
||||||
|
let saveCredentials = false;
|
||||||
|
|
||||||
const processLogin = async () => {
|
const processLogin = async () => {
|
||||||
loading = true;
|
loading = true;
|
||||||
|
@ -21,7 +22,8 @@
|
||||||
const wasSuccessful = "user" in resultData;
|
const wasSuccessful = "user" in resultData;
|
||||||
|
|
||||||
if (!wasSuccessful) {
|
if (!wasSuccessful) {
|
||||||
toast.error(resultData.message, {
|
const errorResult = resultData as Error;
|
||||||
|
toast.error(errorResult.message, {
|
||||||
position: "bottom-center",
|
position: "bottom-center",
|
||||||
className:
|
className:
|
||||||
"dark:!bg-gray-800 border-1 dark:!border-gray-700 dark:!text-gray-100",
|
"dark:!bg-gray-800 border-1 dark:!border-gray-700 dark:!text-gray-100",
|
||||||
|
@ -30,27 +32,87 @@
|
||||||
loading = false;
|
loading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log(resultData);
|
const userResult = resultData.user as User;
|
||||||
currentUser.set(resultData.user as User);
|
currentUser.set(userResult);
|
||||||
currentPage.set(Page.Launch);
|
currentPage.set(Page.Launch);
|
||||||
toast.success(`Welcome back ${resultData.user.name}!`, {
|
toast.success(`Welcome back, ${userResult.name}!`, {
|
||||||
position: "bottom-center",
|
position: "bottom-center",
|
||||||
className:
|
className:
|
||||||
"dark:!bg-gray-800 border-1 dark:!border-gray-700 dark:!text-gray-100",
|
"dark:!bg-gray-800 border-1 dark:!border-gray-700 dark:!text-gray-100",
|
||||||
duration: 5000,
|
duration: 3000,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{ once: true }
|
{ once: true }
|
||||||
);
|
);
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("login-attempt", { detail: { username, password } })
|
new CustomEvent("login-attempt", {
|
||||||
|
detail: { username, password, saveCredentials },
|
||||||
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const tryAutoLogin = async () => {
|
||||||
|
loading = true;
|
||||||
|
await new Promise((res) => setTimeout(res, 1500));
|
||||||
|
window.addEventListener(
|
||||||
|
"login-result",
|
||||||
|
(e) => {
|
||||||
|
const customEvent = e as CustomEvent;
|
||||||
|
const resultData = customEvent.detail;
|
||||||
|
const isGuest = "guest" in resultData;
|
||||||
|
const wasSuccessful = "user" in resultData;
|
||||||
|
if (isGuest) {
|
||||||
|
currentPage.set(Page.Launch);
|
||||||
|
toast.success(`Logged in as Guest`, {
|
||||||
|
position: "bottom-center",
|
||||||
|
className:
|
||||||
|
"dark:!bg-gray-800 border-1 dark:!border-gray-700 dark:!text-gray-100",
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!wasSuccessful) {
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userResult = resultData.user as User;
|
||||||
|
currentUser.set(userResult);
|
||||||
|
currentPage.set(Page.Launch);
|
||||||
|
toast.success(`Welcome back, ${userResult.name}!`, {
|
||||||
|
position: "bottom-center",
|
||||||
|
className:
|
||||||
|
"dark:!bg-gray-800 border-1 dark:!border-gray-700 dark:!text-gray-100",
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
loading = false;
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
window.dispatchEvent(new CustomEvent("autologin-attempt"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const proceedAsGuest = () => {
|
||||||
|
window.dispatchEvent(new CustomEvent("guest-login"));
|
||||||
|
currentPage.set(Page.Launch);
|
||||||
|
toast.success(`Logged in as Guest`, {
|
||||||
|
position: "bottom-center",
|
||||||
|
className:
|
||||||
|
"dark:!bg-gray-800 border-1 dark:!border-gray-700 dark:!text-gray-100",
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!$startup) {
|
||||||
|
startup.set(true);
|
||||||
|
tryAutoLogin();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="h-[265px] my-auto flex flex-col justify-center items-center p-5">
|
<main
|
||||||
|
class="h-[265px] my-auto flex flex-col justify-center items-center p-5 animate-fadeIn opacity-0"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="container flex flex-col items-center justify-center gap-5 rounded-lg p-3"
|
class="container flex flex-col items-center justify-center gap-3 rounded-lg p-3"
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
|
@ -66,9 +128,10 @@
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
bind:value={password}
|
bind:value={password}
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col justify-center items-center gap-5 mt-2">
|
<Checkbox bind:checked={saveCredentials}>Save credentials</Checkbox>
|
||||||
|
<div class="flex flex-col justify-center items-center gap-5 mt-1">
|
||||||
<Button
|
<Button
|
||||||
class="dark:active:!bg-gray-900"
|
class="dark:active:!bg-gray-900 active:scale-95 transition-transform duration-75"
|
||||||
color="light"
|
color="light"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
on:click={processLogin}
|
on:click={processLogin}
|
||||||
|
@ -80,11 +143,10 @@
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
class="!bg-transparent border-none dark:text-gray-700 hover:!bg-gray-700/15 ring-primary active:ring-2 focus:ring-2"
|
class="!bg-transparent font-light border-none dark:text-gray-700 hover:!bg-gray-700/15 ring-primary active:ring-2 focus:ring-2 active:scale-95 transition-transform duration-75"
|
||||||
color="none"
|
color="none"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
on:click={() => currentPage.set(Page.Launch)}
|
on:click={proceedAsGuest}>Continue without login</Button
|
||||||
>Continue without login</Button
|
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,5 +2,6 @@ import { type Writable, writable } from "svelte/store";
|
||||||
import { Page } from "../consts/pages";
|
import { Page } from "../consts/pages";
|
||||||
import type { User } from "../types/user";
|
import type { User } from "../types/user";
|
||||||
|
|
||||||
|
export const startup = writable(false);
|
||||||
export const currentUser: Writable<undefined | User> = writable(undefined);
|
export const currentUser: Writable<undefined | User> = writable(undefined);
|
||||||
export const currentPage = writable(Page.Login);
|
export const currentPage = writable(Page.Login);
|
||||||
|
|
|
@ -7,6 +7,20 @@ const config = {
|
||||||
darkMode: "media",
|
darkMode: "media",
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
keyframes: {
|
||||||
|
fadeIn: {
|
||||||
|
"0%": { opacity: "0", transform: "translateY(5px)" },
|
||||||
|
"100%": { opacity: "1" },
|
||||||
|
},
|
||||||
|
fadeOut: {
|
||||||
|
"100%": { opacity: "0", transform: "translateY(5px)" },
|
||||||
|
"0%": { opacity: "1" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
fadeIn: "fadeIn 1s ease forwards",
|
||||||
|
fadeOut: "fadeOut 1s ease forwards",
|
||||||
|
},
|
||||||
colors: {
|
colors: {
|
||||||
// flowbite-svelte
|
// flowbite-svelte
|
||||||
primary: {
|
primary: {
|
||||||
|
|
Loading…
Reference in New Issue
Block a user