initial public commit
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { slide } from "svelte/transition";
|
||||
import { Admin } from "pocketbase";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import { setErrors } from "@/stores/errors";
|
||||
import { confirm } from "@/stores/confirmation";
|
||||
import { addSuccessToast } from "@/stores/toasts";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
import Toggler from "@/components/base/Toggler.svelte";
|
||||
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const formId = "admin_" + CommonHelper.randomString(5);
|
||||
|
||||
let panel;
|
||||
let admin = new Admin();
|
||||
let isSaving = false;
|
||||
let confirmClose = false; // prevent close recursion
|
||||
let avatar = 0;
|
||||
let email = "";
|
||||
let password = "";
|
||||
let passwordConfirm = "";
|
||||
let changePasswordToggle = false;
|
||||
|
||||
$: hasChanges =
|
||||
(admin.isNew && email != "") ||
|
||||
changePasswordToggle ||
|
||||
email !== admin.email ||
|
||||
avatar !== admin.avatar;
|
||||
|
||||
export function show(model) {
|
||||
load(model);
|
||||
|
||||
confirmClose = true;
|
||||
|
||||
return panel?.show();
|
||||
}
|
||||
|
||||
export function hide() {
|
||||
return panel?.hide();
|
||||
}
|
||||
|
||||
function load(model) {
|
||||
setErrors({}); // reset errors
|
||||
admin = model?.clone ? model.clone() : new Admin();
|
||||
reset(); // reset form
|
||||
}
|
||||
|
||||
function reset() {
|
||||
changePasswordToggle = false;
|
||||
email = admin?.email || "";
|
||||
avatar = admin?.avatar || 0;
|
||||
password = "";
|
||||
passwordConfirm = "";
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (isSaving || !hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
|
||||
const data = { email, avatar };
|
||||
if (admin.isNew || changePasswordToggle) {
|
||||
data["password"] = password;
|
||||
data["passwordConfirm"] = passwordConfirm;
|
||||
}
|
||||
|
||||
let request;
|
||||
if (admin.isNew) {
|
||||
request = ApiClient.Admins.create(data);
|
||||
} else {
|
||||
request = ApiClient.Admins.update(admin.id, data);
|
||||
}
|
||||
|
||||
request
|
||||
.then(async (result) => {
|
||||
confirmClose = false;
|
||||
hide();
|
||||
addSuccessToast(admin.isNew ? "Successfully created admin." : "Successfully updated admin.");
|
||||
dispatch("save", result);
|
||||
|
||||
if (ApiClient.AuthStore.model?.id === result.id) {
|
||||
ApiClient.AuthStore.save(ApiClient.AuthStore.token, result);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
})
|
||||
.finally(() => {
|
||||
isSaving = false;
|
||||
});
|
||||
}
|
||||
|
||||
function deleteConfirm() {
|
||||
if (!admin?.id) {
|
||||
return; // nothing to delete
|
||||
}
|
||||
|
||||
confirm(`Do you really want to delete the selected admin?`, () => {
|
||||
return ApiClient.Admins.delete(admin.id)
|
||||
.then(() => {
|
||||
confirmClose = false;
|
||||
hide();
|
||||
addSuccessToast("Successfully deleted admin.");
|
||||
dispatch("delete", admin);
|
||||
})
|
||||
.catch((err) => {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<OverlayPanel
|
||||
bind:this={panel}
|
||||
popup
|
||||
class="admin-panel"
|
||||
beforeHide={() => {
|
||||
if (hasChanges && confirmClose) {
|
||||
confirm("You have unsaved changes. Do you really want to close the panel?", () => {
|
||||
confirmClose = false;
|
||||
hide();
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
on:hide
|
||||
on:show
|
||||
>
|
||||
<svelte:fragment slot="header">
|
||||
<h4>
|
||||
{admin.isNew ? "New admin" : "Edit admin"}
|
||||
</h4>
|
||||
</svelte:fragment>
|
||||
|
||||
<form id={formId} class="grid" autocomplete="off" on:submit|preventDefault={save}>
|
||||
{#if !admin.isNew}
|
||||
<Field class="form-field disabled" name="id" let:uniqueId>
|
||||
<label for={uniqueId}>
|
||||
<i class={CommonHelper.getFieldTypeIcon("primary")} />
|
||||
<span class="txt">ID</span>
|
||||
</label>
|
||||
<input type="text" id={uniqueId} value={admin.id} disabled />
|
||||
</Field>
|
||||
{/if}
|
||||
|
||||
<div class="content">
|
||||
<p class="section-title">Avatar</p>
|
||||
<div class="flex flex-gap-xs flex-wrap">
|
||||
{#each [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] as index}
|
||||
<figure
|
||||
tabindex="0"
|
||||
class="link-fade thumb thumb-circle {index == avatar ? 'thumb-active' : 'thumb-sm'}"
|
||||
on:click={() => (avatar = index)}
|
||||
on:keydown={(e) => {
|
||||
if (e.code === "Enter" || e.code === "Space") {
|
||||
e.preventDefault();
|
||||
avatar = index;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="{import.meta.env.BASE_URL}images/avatars/avatar{index}.svg"
|
||||
alt="Avatar {index}"
|
||||
/>
|
||||
</figure>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field class="form-field required" name="email" let:uniqueId>
|
||||
<label for={uniqueId}>
|
||||
<i class={CommonHelper.getFieldTypeIcon("email")} />
|
||||
<span class="txt">Email</span>
|
||||
</label>
|
||||
<input type="email" autocomplete="off" id={uniqueId} required bind:value={email} />
|
||||
</Field>
|
||||
|
||||
{#if !admin.isNew}
|
||||
<Field class="form-field form-field-toggle" let:uniqueId>
|
||||
<input type="checkbox" id={uniqueId} bind:checked={changePasswordToggle} />
|
||||
<label for={uniqueId}>Change password</label>
|
||||
</Field>
|
||||
{/if}
|
||||
|
||||
{#if admin.isNew || changePasswordToggle}
|
||||
<div class="col-12">
|
||||
<div class="grid" transition:slide={{ duration: 150 }}>
|
||||
<div class="col-sm-6">
|
||||
<Field class="form-field required" name="password" let:uniqueId>
|
||||
<label for={uniqueId}>
|
||||
<i class="ri-lock-line" />
|
||||
<span class="txt">Password</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
id={uniqueId}
|
||||
required
|
||||
bind:value={password}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<Field class="form-field required" name="passwordConfirm" let:uniqueId>
|
||||
<label for={uniqueId}>
|
||||
<i class="ri-lock-line" />
|
||||
<span class="txt">Password confirm</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
id={uniqueId}
|
||||
required
|
||||
bind:value={passwordConfirm}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
{#if !admin.isNew}
|
||||
<button type="button" class="btn btn-sm btn-circle btn-secondary">
|
||||
<!-- empty span for alignment -->
|
||||
<span />
|
||||
<i class="ri-more-line" />
|
||||
<Toggler class="dropdown dropdown-upside dropdown-left dropdown-nowrap">
|
||||
<button type="button" class="dropdown-item" on:click={() => deleteConfirm()}>
|
||||
<i class="ri-delete-bin-7-line" />
|
||||
<span class="txt">Delete</span>
|
||||
</button>
|
||||
</Toggler>
|
||||
</button>
|
||||
<div class="flex-fill" />
|
||||
{/if}
|
||||
|
||||
<button type="button" class="btn btn-secondary" disabled={isSaving} on:click={() => hide()}>
|
||||
<span class="txt">Cancel</span>
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form={formId}
|
||||
class="btn btn-expanded"
|
||||
class:btn-loading={isSaving}
|
||||
disabled={!hasChanges || isSaving}
|
||||
>
|
||||
<span class="txt">{admin.isNew ? "Create" : "Save changes"}</span>
|
||||
</button>
|
||||
</svelte:fragment>
|
||||
</OverlayPanel>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script>
|
||||
import { link, replace } from "svelte-spa-router";
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import { addSuccessToast } from "@/stores/toasts";
|
||||
import FullPage from "@/components/base/FullPage.svelte";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
|
||||
export let params;
|
||||
|
||||
let newPassword = "";
|
||||
let newPasswordConfirm = "";
|
||||
let isLoading = false;
|
||||
|
||||
$: email = CommonHelper.getJWTPayload(params?.token).email || "";
|
||||
|
||||
async function submit() {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
await ApiClient.Admins.confirmPasswordReset(params?.token, newPassword, newPasswordConfirm);
|
||||
addSuccessToast("Successfully set a new admin password.");
|
||||
replace("/");
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<FullPage>
|
||||
<form class="m-b-base" on:submit|preventDefault={submit}>
|
||||
<div class="content txt-center m-b-sm">
|
||||
<h4 class="m-b-xs">
|
||||
Reset your admin password
|
||||
{#if email}
|
||||
for <strong class="txt-nowrap">{email}</strong>
|
||||
{/if}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<Field class="form-field required" name="password" let:uniqueId>
|
||||
<label for={uniqueId}>New password</label>
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input type="password" id={uniqueId} required autofocus bind:value={newPassword} />
|
||||
</Field>
|
||||
|
||||
<Field class="form-field required" name="passwordConfirm" let:uniqueId>
|
||||
<label for={uniqueId}>New password confirm</label>
|
||||
<input type="password" id={uniqueId} required bind:value={newPasswordConfirm} />
|
||||
</Field>
|
||||
|
||||
<button type="submit" class="btn btn-lg btn-block" class:btn-loading={isLoading} disabled={isLoading}>
|
||||
<span class="txt">Set new password</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="content txt-center">
|
||||
<a href="/login" class="link-hint" use:link>Back to login</a>
|
||||
</div>
|
||||
</FullPage>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script>
|
||||
import { link } from "svelte-spa-router";
|
||||
import { replace } from "svelte-spa-router";
|
||||
import FullPage from "@/components/base/FullPage.svelte";
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
import { addErrorToast } from "@/stores/toasts";
|
||||
|
||||
const queryParams = CommonHelper.getQueryParams(window.location?.href);
|
||||
|
||||
let email = queryParams.demoEmail || "";
|
||||
let password = queryParams.demoPassword || "";
|
||||
let isLoading = false;
|
||||
|
||||
function login() {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
|
||||
return ApiClient.Admins.authViaEmail(email, password)
|
||||
.then(() => {
|
||||
replace("/");
|
||||
})
|
||||
.catch(() => {
|
||||
addErrorToast("Invalid login credentials.");
|
||||
})
|
||||
.finally(() => {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<FullPage>
|
||||
<form class="block" on:submit|preventDefault={login}>
|
||||
<div class="content txt-center m-b-base">
|
||||
<h4>Admin sign in</h4>
|
||||
</div>
|
||||
|
||||
<Field class="form-field required" name="email" let:uniqueId>
|
||||
<label for={uniqueId}>Email</label>
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input type="email" id={uniqueId} bind:value={email} required autofocus />
|
||||
</Field>
|
||||
|
||||
<Field class="form-field required" name="password" let:uniqueId>
|
||||
<label for={uniqueId}>Password</label>
|
||||
<input type="password" id={uniqueId} bind:value={password} required />
|
||||
<div class="help-block">
|
||||
<a href="/request-password-reset" class="link-hint" use:link>Forgotten password?</a>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-lg btn-block btn-next"
|
||||
class:btn-disabled={isLoading}
|
||||
class:btn-loading={isLoading}
|
||||
>
|
||||
<span class="txt">Login</span>
|
||||
<i class="ri-arrow-right-line" />
|
||||
</button>
|
||||
</form>
|
||||
</FullPage>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script>
|
||||
import { link } from "svelte-spa-router";
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import FullPage from "@/components/base/FullPage.svelte";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
|
||||
let email = "";
|
||||
let isLoading = false;
|
||||
let success = false;
|
||||
|
||||
async function submit() {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
await ApiClient.Admins.requestPasswordReset(email);
|
||||
success = true;
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<FullPage>
|
||||
{#if success}
|
||||
<div class="alert alert-success">
|
||||
<div class="icon"><i class="ri-checkbox-circle-line" /></div>
|
||||
<div class="content">
|
||||
<p>Check <strong class="txt-nowrap">{email}</strong> for the recovery link.</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<form class="m-b-base" on:submit|preventDefault={submit}>
|
||||
<div class="content txt-center m-b-sm">
|
||||
<h4 class="m-b-xs">Forgotten admin password</h4>
|
||||
<p>Enter the email associated with your account and we’ll send you a recovery link:</p>
|
||||
</div>
|
||||
|
||||
<Field class="form-field required" name="email" let:uniqueId>
|
||||
<label for={uniqueId}>Email</label>
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input type="email" id={uniqueId} required autofocus bind:value={email} />
|
||||
</Field>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-lg btn-block"
|
||||
class:btn-loading={isLoading}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<i class="ri-mail-send-line" />
|
||||
<span class="txt">Send recovery link</span>
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<div class="content txt-center">
|
||||
<a href="/login" class="link-hint" use:link>Back to login</a>
|
||||
</div>
|
||||
</FullPage>
|
||||
@@ -0,0 +1,198 @@
|
||||
<script>
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import { admin as loggedAdmin } from "@/stores/admin";
|
||||
import Searchbar from "@/components/base/Searchbar.svelte";
|
||||
import SortHeader from "@/components/base/SortHeader.svelte";
|
||||
import IdLabel from "@/components/base/IdLabel.svelte";
|
||||
import FormattedDate from "@/components/base/FormattedDate.svelte";
|
||||
import SettingsSidebar from "@/components/settings/SettingsSidebar.svelte";
|
||||
import AdminUpsertPanel from "@/components/admins/AdminUpsertPanel.svelte";
|
||||
|
||||
const queryParams = CommonHelper.getQueryParams(window.location?.href);
|
||||
|
||||
let adminUpsertPanel;
|
||||
let admins = [];
|
||||
let isLoading = false;
|
||||
let filter = queryParams.filter || "";
|
||||
let sort = queryParams.sort || "-created";
|
||||
|
||||
$: if (sort !== -1 && filter !== -1) {
|
||||
// keep listing params in sync
|
||||
CommonHelper.replaceClientQueryParams({
|
||||
filter: filter,
|
||||
sort: sort,
|
||||
});
|
||||
|
||||
loadAdmins();
|
||||
}
|
||||
|
||||
CommonHelper.setDocumentTitle("Admins");
|
||||
|
||||
export function loadAdmins() {
|
||||
isLoading = true;
|
||||
admins = []; // reset
|
||||
|
||||
return ApiClient.Admins.getFullList(100, {
|
||||
sort: sort || "-created",
|
||||
filter: filter,
|
||||
})
|
||||
.then((result) => {
|
||||
admins = result;
|
||||
isLoading = false;
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err !== null) {
|
||||
isLoading = false;
|
||||
console.warn(err);
|
||||
clearList();
|
||||
ApiClient.errorResponseHandler(err, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function clearList() {
|
||||
admins = [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<SettingsSidebar />
|
||||
|
||||
<main class="page-wrapper">
|
||||
<header class="page-header">
|
||||
<nav class="breadcrumbs">
|
||||
<div class="breadcrumb-item">Settings</div>
|
||||
<div class="breadcrumb-item">Admins</div>
|
||||
</nav>
|
||||
|
||||
<div class="flex-fill" />
|
||||
|
||||
<button type="button" class="btn btn-expanded" on:click={() => adminUpsertPanel?.show()}>
|
||||
<i class="ri-add-line" />
|
||||
<span class="txt">New admin</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<Searchbar
|
||||
value={filter}
|
||||
placeholder={"Search filter, eg. email='test@example.com'"}
|
||||
extraAutocompleteKeys={["email"]}
|
||||
on:submit={(e) => (filter = e.detail)}
|
||||
/>
|
||||
|
||||
<div class="table-wrapper">
|
||||
<table class="table" class:table-loading={isLoading}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="min-width" />
|
||||
|
||||
<SortHeader class="col-type-text" name="id" bind:sort>
|
||||
<div class="col-header-content">
|
||||
<i class={CommonHelper.getFieldTypeIcon("primary")} />
|
||||
<span class="txt">id</span>
|
||||
</div>
|
||||
</SortHeader>
|
||||
|
||||
<SortHeader class="col-type-email col-field-email" name="email" bind:sort>
|
||||
<div class="col-header-content">
|
||||
<i class={CommonHelper.getFieldTypeIcon("email")} />
|
||||
<span class="txt">email</span>
|
||||
</div>
|
||||
</SortHeader>
|
||||
|
||||
<SortHeader class="col-type-date col-field-created" name="created" bind:sort>
|
||||
<div class="col-header-content">
|
||||
<i class={CommonHelper.getFieldTypeIcon("date")} />
|
||||
<span class="txt">created</span>
|
||||
</div>
|
||||
</SortHeader>
|
||||
|
||||
<SortHeader class="col-type-date col-field-updated" name="updated" bind:sort>
|
||||
<div class="col-header-content">
|
||||
<i class={CommonHelper.getFieldTypeIcon("date")} />
|
||||
<span class="txt">updated</span>
|
||||
</div>
|
||||
</SortHeader>
|
||||
|
||||
<th class="col-type-action min-width" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each admins as admin (admin.id)}
|
||||
<tr
|
||||
tabindex="0"
|
||||
class="row-handle"
|
||||
on:click={() => adminUpsertPanel?.show(admin)}
|
||||
on:keydown={(e) => {
|
||||
if (e.code === "Enter" || e.code === "Space") {
|
||||
e.preventDefault();
|
||||
adminUpsertPanel?.show(admin);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td class="min-width">
|
||||
<figure class="thumb thumb-sm thumb-circle">
|
||||
<img
|
||||
src="{import.meta.env.BASE_URL}images/avatars/avatar{admin.avatar ||
|
||||
0}.svg"
|
||||
alt="Admin avatar"
|
||||
/>
|
||||
</figure>
|
||||
</td>
|
||||
<td class="col-type-text col-field-id">
|
||||
<IdLabel id={admin.id} />
|
||||
{#if admin.id === $loggedAdmin.id}
|
||||
<span class="label label-warning m-l-5">You</span>
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
<td class="col-type-email col-field-email">
|
||||
<span class="txt txt-ellipsis" title={admin.email}>
|
||||
{admin.email}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td class="col-type-date col-field-created">
|
||||
<FormattedDate date={admin.created} />
|
||||
</td>
|
||||
<td class="col-type-date col-field-updated">
|
||||
<FormattedDate date={admin.updated} />
|
||||
</td>
|
||||
<td class="col-type-action min-width">
|
||||
<i class="ri-arrow-right-line" />
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#if isLoading}
|
||||
<tr>
|
||||
<td colspan="99" class="p-xs">
|
||||
<span class="skeleton-loader" />
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan="99" class="txt-center txt-hint p-xs">
|
||||
<h6>No admins found.</h6>
|
||||
{#if filter?.length}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-hint btn-expanded m-t-sm"
|
||||
on:click={() => (filter = "")}
|
||||
>
|
||||
<span class="txt">Clear filters</span>
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{#if admins.length}
|
||||
<small class="block txt-hint txt-right m-t-sm">Showing {admins.length} of {admins.length}</small>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<AdminUpsertPanel bind:this={adminUpsertPanel} on:save={() => loadAdmins()} on:delete={() => loadAdmins()} />
|
||||
Reference in New Issue
Block a user