initial public commit

This commit is contained in:
Gani Georgiev
2022-07-07 00:19:05 +03:00
commit 3d07f0211d
484 changed files with 92412 additions and 0 deletions

View File

@@ -0,0 +1,73 @@
<script>
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import FullPage from "@/components/base/FullPage.svelte";
import Field from "@/components/base/Field.svelte";
export let params;
let password = "";
let isLoading = false;
let success = false;
$: newEmail = CommonHelper.getJWTPayload(params?.token).newEmail || "";
async function submit() {
if (isLoading) {
return;
}
isLoading = true;
try {
await ApiClient.Users.confirmEmailChange(params?.token, password);
success = true;
} catch (err) {
ApiClient.errorResponseHandler(err);
}
isLoading = false;
}
</script>
<FullPage nobranding>
{#if success}
<div class="alert alert-success">
<div class="icon"><i class="ri-checkbox-circle-line" /></div>
<div class="content txt-bold">
<p>Email address changed</p>
<p>You can now sign in with your new email address.</p>
</div>
</div>
<button type="button" class="btn btn-secondary btn-block" on:click={() => window.close()}>
Close
</button>
{:else}
<form on:submit|preventDefault={submit}>
<div class="content txt-center m-b-sm">
<h4 class="m-b-xs">
Type your password to confirm changing your email address
{#if newEmail}
to <strong class="txt-nowrap">{newEmail}</strong>
{/if}
</h4>
</div>
<Field class="form-field required" name="password" let:uniqueId>
<label for={uniqueId}>Password</label>
<!-- svelte-ignore a11y-autofocus -->
<input type="password" id={uniqueId} required autofocus bind:value={password} />
</Field>
<button
type="submit"
class="btn btn-lg btn-block"
class:btn-loading={isLoading}
disabled={isLoading}
>
<span class="txt">Confirm new email</span>
</button>
</form>
{/if}
</FullPage>

View File

@@ -0,0 +1,79 @@
<script>
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import FullPage from "@/components/base/FullPage.svelte";
import Field from "@/components/base/Field.svelte";
export let params;
let newPassword = "";
let newPasswordConfirm = "";
let isLoading = false;
let success = false;
$: email = CommonHelper.getJWTPayload(params?.token).email || "";
async function submit() {
if (isLoading) {
return;
}
isLoading = true;
try {
await ApiClient.Users.confirmPasswordReset(params?.token, newPassword, newPasswordConfirm);
success = true;
} catch (err) {
ApiClient.errorResponseHandler(err);
}
isLoading = false;
}
</script>
<FullPage nobranding>
{#if success}
<div class="alert alert-success">
<div class="icon"><i class="ri-checkbox-circle-line" /></div>
<div class="content txt-bold">
<p>Password changed</p>
<p>You can now sign in with your new password.</p>
</div>
</div>
<button type="button" class="btn btn-secondary btn-block" on:click={() => window.close()}>
Close
</button>
{:else}
<form on:submit|preventDefault={submit}>
<div class="content txt-center m-b-sm">
<h4 class="m-b-xs">
Reset your user password
{#if email}
for <strong>{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>
{/if}
</FullPage>

View File

@@ -0,0 +1,57 @@
<script>
import ApiClient from "@/utils/ApiClient";
import FullPage from "@/components/base/FullPage.svelte";
export let params;
let success = false;
let isLoading = false;
send();
async function send() {
isLoading = true;
try {
await ApiClient.Users.confirmVerification(params?.token);
success = true;
} catch (err) {
console.warn(err);
success = false;
}
isLoading = false;
}
</script>
<FullPage nobranding>
{#if isLoading}
<div class="txt-center">
<div class="loader loader-lg">
<em>Please wait...</em>
</div>
</div>
{:else if success}
<div class="alert alert-success">
<div class="icon"><i class="ri-checkbox-circle-line" /></div>
<div class="content txt-bold">
<p>Successfully verified email address.</p>
</div>
</div>
<button type="button" class="btn btn-secondary btn-block" on:click={() => window.close()}>
Close
</button>
{:else}
<div class="alert alert-danger">
<div class="icon"><i class="ri-error-warning-line" /></div>
<div class="content txt-bold">
<p>Invalid or expired verification token.</p>
</div>
</div>
<button type="button" class="btn btn-secondary btn-block" on:click={() => window.close()}>
Close
</button>
{/if}
</FullPage>

View File

@@ -0,0 +1,293 @@
<script>
import { Collection } from "pocketbase";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
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 UserUpsertPanel from "@/components/users/UserUpsertPanel.svelte";
import CollectionUpsertPanel from "@/components/collections/CollectionUpsertPanel.svelte";
import RecordUpsertPanel from "@/components/records/RecordUpsertPanel.svelte";
import RecordFieldCell from "@/components/records/RecordFieldCell.svelte";
const queryParams = CommonHelper.getQueryParams(window.location?.href);
const excludedProfileFields = ["id", "userId", "created", "updated"];
let userUpsertPanel;
let collectionUpsertPanel;
let recordUpsertPanel;
let users = [];
let currentPage = 1;
let totalItems = 0;
let isLoadingUsers = false;
let filter = queryParams.filter || "";
let sort = queryParams.sort || "-created";
let profileCollection = new Collection();
let isLoadingProfileCollection = false;
$: if (sort !== -1 && filter !== -1) {
// keep query params
CommonHelper.replaceClientQueryParams({
filter: filter,
sort: sort,
});
loadUsers();
}
$: canLoadMore = totalItems > users.length;
$: profileFields = profileCollection?.schema?.filter(
(field) => !excludedProfileFields.includes(field.name)
);
CommonHelper.setDocumentTitle("Users");
loadProfilesCollection();
export async function loadUsers(page = 1) {
isLoadingUsers = true;
return ApiClient.Users.getList(page, 50, {
sort: sort || "-created",
filter: filter,
})
.then((result) => {
if (page <= 1) {
clearList();
}
isLoadingUsers = false;
users = users.concat(result.items);
currentPage = result.page;
totalItems = result.totalItems;
})
.catch((err) => {
if (err !== null) {
isLoadingUsers = false;
console.warn(err);
clearList();
ApiClient.errorResponseHandler(err, false);
}
});
}
function clearList() {
users = [];
currentPage = 1;
totalItems = 0;
}
function setUserProfile(profile) {
const user = users.find((u) => u.id === profile?.userId);
if (user) {
user.profile = profile;
}
users = users;
}
async function loadProfilesCollection() {
isLoadingProfileCollection = true;
try {
profileCollection = await ApiClient.Collections.getOne(import.meta.env.PB_PROFILE_COLLECTION);
} catch (err) {
ApiClient.errorResponseHandler(err);
}
isLoadingProfileCollection = false;
}
</script>
{#if isLoadingProfileCollection}
<div class="placeholder-section m-b-base">
<span class="loader loader-lg" />
<h1>Loading users...</h1>
</div>
{:else}
<main class="page-wrapper">
<header class="page-header">
<nav class="breadcrumbs">
<div class="breadcrumb-item">Users</div>
</nav>
<button
type="button"
class="btn btn-secondary btn-circle"
use:tooltip={{ text: "Edit profile collection", position: "right" }}
on:click={() => collectionUpsertPanel?.show(profileCollection)}
>
<i class="ri-settings-4-line" />
</button>
<div class="flex-fill" />
<button type="button" class="btn btn-expanded" on:click={() => userUpsertPanel?.show()}>
<i class="ri-add-line" />
<span class="txt">New user</span>
</button>
</header>
<Searchbar
value={filter}
placeholder={"Search filter, eg. verified=1"}
extraAutocompleteKeys={["verified", "email"]}
on:submit={(e) => (filter = e.detail)}
/>
<div class="table-wrapper">
<table class="table" class:table-loading={isLoadingUsers}>
<thead>
<tr>
<SortHeader class="col-type-text col-field-id" 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>
{#each profileFields as field (field.name)}
<th class="col-type-{field.type} col-field-{field.name}" name={field.name}>
<div class="col-header-content">
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
<span class="txt">profile.{field.name}</span>
</div>
</th>
{/each}
<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 users as user (user.id)}
<tr>
<td class="col-type-text col-field-id">
<IdLabel id={user.id} />
</td>
<td class="col-type-email col-field-email">
<div class="inline-flex">
<span class="txt" title={user.email}>
{user.email}
</span>
<span
class="label"
class:label-success={user.verified}
class:label-warning={!user.verified}
>
{user.verified ? "Verified" : "Unverified"}
</span>
</div>
</td>
{#each profileFields as field (field.name)}
<RecordFieldCell {field} record={user.profile || {}} />
{/each}
<td class="col-type-date col-field-created">
<FormattedDate date={user.created} />
</td>
<td class="col-type-date col-field-updated">
<FormattedDate date={user.updated} />
</td>
<td class="col-type-action min-width">
<button
type="button"
class="btn btn-sm btn-outline"
on:click|stopPropagation={() => userUpsertPanel?.show(user)}
>
<i class="ri-user-settings-line" />
<span class="txt">Edit user</span>
</button>
<button
type="button"
class="btn btn-sm m-l-10"
on:click|stopPropagation={() => recordUpsertPanel?.show(user.profile)}
>
<i class="ri-profile-line" />
<span class="txt">Edit profile</span>
</button>
</td>
</tr>
{:else}
{#if isLoadingUsers}
<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 users 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 users.length}
<small class="block txt-hint txt-right m-t-sm">Showing {users.length} of {totalItems}</small>
{/if}
{#if users.length && canLoadMore}
<div class="block txt-center m-t-xs">
<button
type="button"
class="btn btn-lg btn-secondary btn-expanded"
class:btn-loading={isLoadingUsers}
class:btn-disabled={isLoadingUsers}
on:click={() => loadUsers(currentPage + 1)}
>
<span class="txt">Load more ({totalItems - users.length})</span>
</button>
</div>
{/if}
</main>
{/if}
<UserUpsertPanel bind:this={userUpsertPanel} on:save={() => loadUsers()} on:delete={() => loadUsers()} />
<CollectionUpsertPanel bind:this={collectionUpsertPanel} on:save={(e) => (profileCollection = e.detail)} />
<RecordUpsertPanel
bind:this={recordUpsertPanel}
collection={profileCollection}
on:save={(e) => setUserProfile(e.detail)}
/>

View File

@@ -0,0 +1,113 @@
<script>
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import ObjectSelect from "@/components/base/ObjectSelect.svelte";
import UserSelectOption from "./UserSelectOption.svelte";
const uniqueId = "select_" + CommonHelper.randomString(5);
// original select props
export let multiple = false;
export let selected = multiple ? [] : undefined;
export let keyOfSelected = multiple ? [] : undefined;
export let selectPlaceholder = "- Select -";
export let optionComponent = UserSelectOption; // custom component to use for each dropdown option item
let list = [];
let currentPage = 1;
let totalItems = 0;
let isLoadingList = false;
let isLoadingSelected = false;
$: isLoading = isLoadingList || isLoadingSelected;
$: canLoadMore = totalItems > list.length;
loadList();
loadSelected();
async function loadSelected() {
const selectedIds = CommonHelper.toArray(keyOfSelected);
if (!selectedIds.length) {
return;
}
isLoadingSelected = true;
try {
const filters = [];
for (const id of selectedIds) {
filters.push(`id="${id}"`);
}
selected = await ApiClient.Users.getFullList(100, {
sort: "-created",
filter: filters.join("||"),
$cancelKey: uniqueId + "loadSelected",
});
// add the selected models to the list (if not already)
list = CommonHelper.filterDuplicatesByKey(list.concat(selected));
} catch (err) {
ApiClient.errorResponseHandler(err);
}
isLoadingSelected = false;
}
async function loadList(reset = false) {
isLoadingList = true;
try {
const page = reset ? 1 : currentPage + 1;
const result = await ApiClient.Users.getList(page, 200, {
sort: "-created",
$cancelKey: uniqueId + "loadList",
});
if (reset) {
list = [];
}
list = CommonHelper.filterDuplicatesByKey(list.concat(result.items));
currentPage = result.page;
totalItems = result.totalItems;
} catch (err) {
ApiClient.errorResponseHandler(err);
}
isLoadingList = false;
}
</script>
<ObjectSelect
selectPlaceholder={isLoading ? "Loading..." : selectPlaceholder}
items={list}
searchable={list.length > 5}
selectionKey="id"
labelComponent={UserSelectOption}
{optionComponent}
{multiple}
bind:keyOfSelected
bind:selected
on:show
on:hide
class="users-select block-options"
{...$$restProps}
>
<svelte:fragment slot="afterOptions">
{#if canLoadMore}
<button
type="button"
class="btn btn-block btn-sm"
class:btn-loading={isLoadingList}
class:btn-disabled={isLoadingList}
on:click|stopPropagation={() => loadList()}
>
<span class="txt">Load more</span>
</button>
{/if}
</svelte:fragment>
</ObjectSelect>

View File

@@ -0,0 +1,15 @@
<script>
import tooltip from "@/actions/tooltip";
export let item = {}; // model
</script>
<i
class="ri-information-line link-hint"
use:tooltip={{ text: JSON.stringify(item, null, 2), position: "left", class: "code" }}
/>
<div class="content">
<div class="block txt-ellipsis">{item.id}</div>
<small class="block txt-hint txt-ellipsis">{item.email}</small>
</div>

View File

@@ -0,0 +1,263 @@
<script>
import { createEventDispatcher } from "svelte";
import { slide } from "svelte/transition";
import { User } from "pocketbase";
import CommonHelper from "@/utils/CommonHelper";
import ApiClient from "@/utils/ApiClient";
import tooltip from "@/actions/tooltip";
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 = "user_" + CommonHelper.randomString(5);
let panel;
let user = new User();
let isSaving = false;
let confirmClose = false; // prevent close recursion
let email = "";
let password = "";
let passwordConfirm = "";
let changePasswordToggle = false;
let verificationEmailToggle = true;
$: hasChanges = (user.isNew && email != "") || changePasswordToggle || email !== user.email;
export function show(model) {
load(model);
confirmClose = true;
return panel?.show();
}
export function hide() {
return panel?.hide();
}
function load(model) {
setErrors({}); // reset errors
user = model?.clone ? model.clone() : new User();
reset(); // reset form
}
function reset() {
changePasswordToggle = false;
verificationEmailToggle = true;
email = user?.email || "";
password = "";
passwordConfirm = "";
}
function save() {
if (isSaving || !hasChanges) {
return;
}
isSaving = true;
const data = { email: email };
if (user.isNew || changePasswordToggle) {
data["password"] = password;
data["passwordConfirm"] = passwordConfirm;
}
let request;
if (user.isNew) {
request = ApiClient.Users.create(data);
} else {
request = ApiClient.Users.update(user.id, data);
}
request
.then(async (result) => {
if (verificationEmailToggle) {
sendVerificationEmail(false);
}
confirmClose = false;
hide();
addSuccessToast(user.isNew ? "Successfully created user." : "Successfully updated user.");
dispatch("save", result);
})
.catch((err) => {
ApiClient.errorResponseHandler(err);
})
.finally(() => {
isSaving = false;
});
}
function deleteConfirm() {
if (!user?.id) {
return; // nothing to delete
}
confirm(`Do you really want to delete the selected user?`, () => {
return ApiClient.Users.delete(user.id)
.then(() => {
confirmClose = false;
hide();
addSuccessToast("Successfully deleted user.");
dispatch("delete", user);
})
.catch((err) => {
ApiClient.errorResponseHandler(err);
});
});
}
function sendVerificationEmail(notify = true) {
return ApiClient.Users.requestVerification(user.isNew ? email : user.email)
.then(() => {
confirmClose = false;
hide();
if (notify) {
addSuccessToast(`Successfully sent verification email to ${user.email}.`);
}
})
.catch((err) => {
ApiClient.errorResponseHandler(err);
});
}
</script>
<OverlayPanel
bind:this={panel}
popup
class="user-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>
{user.isNew ? "New user" : "Edit user"}
</h4>
</svelte:fragment>
<form id={formId} class="grid" autocomplete="off" on:submit|preventDefault={save}>
{#if !user.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={user.id} disabled />
</Field>
{/if}
<Field class="form-field required" name="email" let:uniqueId>
<label for={uniqueId}>
<i class={CommonHelper.getFieldTypeIcon("email")} />
<span class="txt">Email</span>
</label>
{#if user.verified}
<div class="form-field-addon txt-success" use:tooltip={"Verified"}>
<i class="ri-shield-check-line" />
</div>
{/if}
<input type="email" autocomplete="off" id={uniqueId} required bind:value={email} />
</Field>
{#if !user.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 user.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}
{#if user.isNew}
<Field class="form-field form-field-toggle" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={verificationEmailToggle} />
<label for={uniqueId}>Send verification email</label>
</Field>
{/if}
</form>
<svelte:fragment slot="footer">
{#if !user.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">
{#if !user.verified}
<button type="button" class="dropdown-item" on:click={() => sendVerificationEmail()}>
<i class="ri-mail-check-line" />
<span class="txt">Send verification email</span>
</button>
{/if}
<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">{user.isNew ? "Create" : "Save changes"}</span>
</button>
</svelte:fragment>
</OverlayPanel>