initial v0.8 pre-release

This commit is contained in:
Gani Georgiev
2022-10-30 10:28:14 +02:00
parent 9cbb2e750e
commit 90dba45d7c
388 changed files with 21580 additions and 13603 deletions
@@ -4,6 +4,7 @@
import { Admin } 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";
@@ -43,7 +44,6 @@
}
function load(model) {
setErrors({}); // reset errors
admin = model?.clone ? model.clone() : new Admin();
reset(); // reset form
}
@@ -54,6 +54,7 @@
avatar = admin?.avatar || 0;
password = "";
passwordConfirm = "";
setErrors({}); // reset errors
}
function save() {
@@ -146,6 +147,15 @@
<i class={CommonHelper.getFieldTypeIcon("primary")} />
<span class="txt">ID</span>
</label>
<div class="form-field-addon">
<i
class="ri-calendar-event-line txt-disabled"
use:tooltip={{
text: `Created: ${admin.created}\nUpdated: ${admin.updated}`,
position: "left",
}}
/>
</div>
<input type="text" id={uniqueId} value={admin.id} disabled />
</Field>
{/if}
@@ -154,22 +164,16 @@
<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"
<button
type="button"
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>
</button>
{/each}
</div>
</div>
@@ -234,7 +238,7 @@
<span />
<i class="ri-more-line" />
<Toggler class="dropdown dropdown-upside dropdown-left dropdown-nowrap">
<button type="button" class="dropdown-item" on:click={() => deleteConfirm()}>
<button type="button" class="dropdown-item txt-danger" on:click={() => deleteConfirm()}>
<i class="ri-delete-bin-7-line" />
<span class="txt">Delete</span>
</button>
@@ -3,7 +3,7 @@
import FullPage from "@/components/base/FullPage.svelte";
import ApiClient from "@/utils/ApiClient";
import Field from "@/components/base/Field.svelte";
import { addErrorToast } from "@/stores/toasts";
import { addErrorToast, removeAllToasts } from "@/stores/toasts";
const queryParams = new URLSearchParams($querystring);
@@ -18,8 +18,10 @@
isLoading = true;
return ApiClient.admins.authViaEmail(email, password)
return ApiClient.admins
.authWithPassword(email, password)
.then(() => {
removeAllToasts();
replace("/");
})
.catch(() => {
@@ -37,7 +39,7 @@
<h4>Admin sign in</h4>
</div>
<Field class="form-field required" name="email" let:uniqueId>
<Field class="form-field required" name="identity" let:uniqueId>
<label for={uniqueId}>Email</label>
<!-- svelte-ignore a11y-autofocus -->
<input type="email" id={uniqueId} bind:value={email} required autofocus />
+6 -2
View File
@@ -10,6 +10,7 @@
import SortHeader from "@/components/base/SortHeader.svelte";
import IdLabel from "@/components/base/IdLabel.svelte";
import FormattedDate from "@/components/base/FormattedDate.svelte";
import HorizontalScroller from "@/components/base/HorizontalScroller.svelte";
import SettingsSidebar from "@/components/settings/SettingsSidebar.svelte";
import AdminUpsertPanel from "@/components/admins/AdminUpsertPanel.svelte";
@@ -86,7 +87,7 @@
on:submit={(e) => (filter = e.detail)}
/>
<div class="table-wrapper">
<HorizontalScroller class="table-wrapper">
<table class="table" class:table-loading={isLoading}>
<thead>
<tr>
@@ -145,6 +146,7 @@
/>
</figure>
</td>
<td class="col-type-text col-field-id">
<IdLabel id={admin.id} />
{#if admin.id === $loggedAdmin.id}
@@ -161,9 +163,11 @@
<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>
@@ -194,7 +198,7 @@
{/each}
</tbody>
</table>
</div>
</HorizontalScroller>
{#if admins.length}
<small class="block txt-hint txt-right m-t-sm">Showing {admins.length} of {admins.length}</small>
+30 -5
View File
@@ -10,10 +10,13 @@
let classes = "";
export { classes as class }; // export reserved keyword
export let draggable = false;
export let active = false;
export let interactive = true;
export let single = false; // ensures that only one accordion is expanded in its given parent container
let isDragOver = false;
$: if (active) {
clearTimeout(expandTimeoutId);
expandTimeoutId = setTimeout(() => {
@@ -49,10 +52,10 @@
}
export function collapseSiblings() {
if (single && accordionElem.parentElement) {
const handlers = accordionElem.parentElement.querySelectorAll(
".accordion.active .accordion-header.interactive"
);
if (single && accordionElem.closest(".accordions")) {
const handlers = accordionElem
.closest(".accordions")
.querySelectorAll(".accordion.active .accordion-header.interactive");
for (const handler of handlers) {
handler.click(); // @todo consider using store or other more reliable approach
}
@@ -78,14 +81,36 @@
<div
bind:this={accordionElem}
tabindex={interactive ? 0 : -1}
class="accordion {classes}"
class="accordion {isDragOver ? 'drag-over' : ''} {classes}"
class:active
on:keydown|self={keyToggle}
>
<header
class="accordion-header"
{draggable}
class:interactive
on:click|preventDefault={() => interactive && toggle()}
on:drop|preventDefault={(e) => {
if (draggable) {
isDragOver = false;
collapseSiblings();
dispatch("drop", e);
}
}}
on:dragstart={(e) => draggable && dispatch("dragstart", e)}
on:dragenter={(e) => {
if (draggable) {
isDragOver = true;
dispatch("dragenter", e);
}
}}
on:dragleave={(e) => {
if (draggable) {
isDragOver = false;
dispatch("dragleave", e);
}
}}
on:dragover|preventDefault
>
<slot name="header" {active} />
</header>
+1 -2
View File
@@ -1,5 +1,4 @@
<script>
// @todo consider replacing with readonly CodeEditor
import Prism from "prismjs";
import "prismjs/plugins/normalize-whitespace/prism-normalize-whitespace.js";
import "prismjs/components/prism-dart.js";
@@ -40,7 +39,7 @@
code {
display: block;
width: 100%;
padding: var(--xsSpacing);
padding: 10px 15px;
white-space: pre-wrap;
word-break: break-word;
}
+6 -4
View File
@@ -5,8 +5,10 @@
let confirmationPopup;
let isConfirmationBusy = false;
let confirmed = false;
$: if ($confirmation?.text) {
confirmed = false;
confirmationPopup?.show();
}
</script>
@@ -19,10 +21,11 @@
btnClose={false}
popup
on:hide={async () => {
if ($confirmation?.noCallback) {
if (!confirmed && $confirmation?.noCallback) {
$confirmation.noCallback();
}
await tick();
confirmed = false;
resetConfirmation();
}}
>
@@ -36,9 +39,7 @@
class="btn btn-secondary btn-expanded-sm"
disabled={isConfirmationBusy}
on:click={() => {
if ($confirmation?.noCallback) {
$confirmation.noCallback();
}
confirmed = false;
confirmationPopup?.hide();
}}
>
@@ -55,6 +56,7 @@
await Promise.resolve($confirmation.yesCallback());
isConfirmationBusy = false;
}
confirmed = true;
confirmationPopup?.hide();
}}
>
@@ -185,17 +185,23 @@
prefix + "updated",
];
if (collection.isAuth) {
result.push(prefix + "username");
result.push(prefix + "email");
result.push(prefix + "emailVisibility");
result.push(prefix + "verified");
}
for (const field of collection.schema) {
const key = prefix + field.name;
result.push(key);
if (field.type === "relation" && field.options.collectionId) {
const subKeys = getCollectionFieldKeys(field.options.collectionId, key + ".", level + 1);
if (subKeys.length) {
result = result.concat(subKeys);
} else {
result.push(key);
}
} else {
result.push(key);
}
}
@@ -217,28 +223,27 @@
result.push("@request.method");
result.push("@request.query.");
result.push("@request.data.");
result.push("@request.user.id");
result.push("@request.user.email");
result.push("@request.user.verified");
result.push("@request.user.created");
result.push("@request.user.updated");
result.push("@request.auth.");
result.push("@request.auth.id");
result.push("@request.auth.collectionId");
result.push("@request.auth.collectionName");
result.push("@request.auth.username");
result.push("@request.auth.email");
result.push("@request.auth.emailVisibility");
result.push("@request.auth.verified");
result.push("@request.auth.created");
result.push("@request.auth.updated");
}
// add @collections and @request.user.profile keys
// add @collections.* keys
if (includeRequestKeys || includeIndirectCollectionsKeys) {
for (const collection of mergedCollections) {
let prefix = "";
if (collection.name === import.meta.env.PB_PROFILE_COLLECTION) {
if (!includeRequestKeys) {
continue;
}
prefix = "@request.user.profile.";
} else {
if (!includeIndirectCollectionsKeys) {
continue;
}
prefix = "@collection." + collection.name + ".";
if (!includeIndirectCollectionsKeys) {
continue;
}
prefix = "@collection." + collection.name + ".";
const keys = getCollectionFieldKeys(collection.name, prefix);
for (const key of keys) {
@@ -258,8 +263,8 @@
// Returns object with all the completions matching the context.
function completions(context) {
let word = context.matchBefore(/[\@\w\.]*/);
if (word.from == word.to && !context.explicit) {
let word = context.matchBefore(/[\'\"\@\w\.]*/);
if (word && word.from == word.to && !context.explicit) {
return null;
}
@@ -269,18 +274,8 @@
options.push({ label: "@collection.*", apply: "@collection." });
}
const skipFields = [
"@request.user.profile.userId",
"@request.user.profile.created",
"@request.user.profile.updated",
];
const keys = getAllKeys(!disableRequestKeys, !disableRequestKeys && word.text.startsWith("@c"));
for (const key of keys) {
if (skipFields.includes(key)) {
continue;
}
options.push({
label: key.endsWith(".") ? key + "*" : key,
apply: key,
+19 -4
View File
@@ -1,13 +1,28 @@
<script>
export let date = "";
// note: manual trim the ms without converting to DateTime
// to help improving the rendering performance in large data sets
$: shortDate = date.length > 19 ? date.substring(0, 19) : date;
$: dateOnly = date ? date.substring(0, 10) : null;
$: timeOnly = date ? date.substring(10, 19) : null;
</script>
{#if date}
<span class="txt">{shortDate} UTC</span>
<div class="datetime">
<div class="date">{dateOnly}</div>
<div class="time">{timeOnly} UTC</div>
</div>
{:else}
<span class="txt txt-hint">N/A</span>
{/if}
<style>
.datetime {
width: 100%;
display: block;
line-height: var(--smLineHeight);
}
.time {
font-size: var(--smFontSize);
color: var(--txtHintColor);
}
</style>
@@ -0,0 +1,81 @@
<script>
import { onMount } from "svelte";
let classes = "";
export { classes as class }; // export reserved keyword
let wrapper = null;
let scrollClasses = "";
let scrollTimeoutId = null;
let observer;
export function refresh() {
if (!wrapper) {
return;
}
clearTimeout(scrollTimeoutId);
scrollTimeoutId = setTimeout(() => {
const offsetWidth = wrapper.offsetWidth;
const scrollWidth = wrapper.scrollWidth;
if (scrollWidth - offsetWidth) {
scrollClasses = "scrollable";
if (wrapper.scrollLeft === 0) {
scrollClasses += " scroll-start";
} else if (wrapper.scrollLeft + offsetWidth == scrollWidth) {
scrollClasses += " scroll-end";
}
} else {
scrollClasses = "";
}
}, 100);
}
onMount(() => {
refresh();
observer = new MutationObserver(() => {
refresh();
});
observer.observe(wrapper, {
attributeFilter: ["width"],
childList: true,
subtree: true,
});
return () => {
observer?.disconnect();
clearTimeout(scrollTimeoutId);
};
});
</script>
<svelte:window on:resize={refresh} />
<div class="horizontal-scroller-wrapper">
<slot name="before" />
<div bind:this={wrapper} class="horizontal-scroller {classes} {scrollClasses}" on:scroll={refresh}>
<slot />
</div>
<slot name="after" />
</div>
<style>
.horizontal-scroller {
width: auto;
overflow-x: auto;
}
.horizontal-scroller-wrapper {
position: relative;
}
:global(.horizontal-scroller-wrapper .columns-dropdown) {
top: 40px;
z-index: 100;
max-height: 340px;
}
</style>
+1 -1
View File
@@ -24,7 +24,7 @@
passwordConfirm,
});
await ApiClient.admins.authViaEmail(email, password);
await ApiClient.admins.authWithPassword(email, password);
dispatch("submit");
} catch (err) {
+7 -1
View File
@@ -86,10 +86,12 @@
oldFocusedElem = document.activeElement;
wrapper?.focus();
dispatch("show");
document.body.classList.add("overlay-active");
} else {
clearTimeout(contentScrollThrottle);
oldFocusedElem?.focus();
dispatch("hide");
document.body.classList.remove("overlay-active");
}
await tick();
@@ -179,13 +181,17 @@
// ensures that no artifacts remains
// (currently there is a bug with svelte transition)
wrapper?.classList?.add("hidden");
setTimeout(() => {
wrapper?.remove();
}, 0);
};
});
</script>
<svelte:window on:resize={handleResize} on:keydown={handleEscPress} />
<div class="overlay-panel-wrapper" bind:this={wrapper}>
<div bind:this={wrapper} class="overlay-panel-wrapper">
{#if active}
<div class="overlay-panel-container" class:padded={popup} class:active>
<div
+1 -1
View File
@@ -15,7 +15,7 @@
href={import.meta.env.PB_RELEASES}
class="inline-flex flex-gap-5"
target="_blank"
rel="noopener"
rel="noopener noreferrer"
title="Releases"
>
<span class="txt">PocketBase {import.meta.env.PB_VERSION}</span>
+9 -3
View File
@@ -21,15 +21,21 @@
<OverlayPanel bind:this={panel} class="image-preview" btnClose={false} popup on:show on:hide>
<svelte:fragment slot="header">
<div class="overlay-close" on:click|preventDefault={hide}>
<button type="button" class="overlay-close" on:click|preventDefault={hide}>
<i class="ri-close-line" />
</div>
</button>
</svelte:fragment>
<img src={url} alt="Preview {url}" />
<svelte:fragment slot="footer">
<a href={url} title="Download" class="link-hint txt-ellipsis">
<a
href={url}
title="Download"
target="_blank"
rel="noreferrer noopener"
class="link-hint txt-ellipsis"
>
{url.substring(url.lastIndexOf("/") + 1)}
</a>
<div class="flex-fill" />
+2 -2
View File
@@ -20,7 +20,7 @@
refreshTimeoutId = setTimeout(() => {
refreshTimeoutId = null;
tooltipData = oldTooltipData;
}, 200);
}, 150);
}
onMount(() => {
@@ -45,6 +45,6 @@
}
}
.btn.refreshing i {
animation: refresh 200ms ease-out;
animation: refresh 150ms ease-out;
}
</style>
+2 -2
View File
@@ -53,8 +53,8 @@
});
</script>
<div class="searchbar-wrapper" on:click|stopPropagation>
<form class="searchbar" on:submit|preventDefault={submit}>
<div class="searchbar-wrapper">
<form class="searchbar" on:click|stopPropagation on:submit|preventDefault={submit}>
<label for={uniqueId} class="m-l-10 txt-xl">
<i class="ri-search-line" />
</label>
+9 -5
View File
@@ -194,13 +194,15 @@
});
</script>
<div class="select {classes}" class:multiple class:disabled bind:this={container}>
<div tabindex={disabled ? "-1" : "0"} class="selected-container" class:disabled bind:this={labelDiv}>
<div bind:this={container} class="select {classes}" class:multiple class:disabled>
<div bind:this={labelDiv} tabindex={disabled ? "-1" : "0"} class="selected-container" class:disabled>
{#each CommonHelper.toArray(selected) as item}
<div class="option">
{#if labelComponent}
<svelte:component this={labelComponent} {item} {...labelComponentProps} />
{:else}<span class="txt">{item}</span>{/if}
{:else}
<span class="txt">{item}</span>
{/if}
{#if multiple || toggle}
<span
@@ -213,17 +215,19 @@
{/if}
</div>
{:else}
<div class="txt-placeholder">{selectPlaceholder}</div>
<div class="block txt-placeholder" class:link-hint={!disabled}>
{selectPlaceholder}
</div>
{/each}
</div>
{#if !disabled}
<Toggler
bind:this={toggler}
class="dropdown dropdown-block options-dropdown dropdown-left"
trigger={labelDiv}
on:show={onDropdownShow}
on:hide
bind:this={toggler}
>
{#if searchable}
<div class="form-field form-field-sm options-search">
+2 -2
View File
@@ -29,9 +29,9 @@
<div class="content">{toast.message}</div>
<div class="close" on:click|preventDefault={() => removeToast(toast)}>
<button type="button" class="close" on:click|preventDefault={() => removeToast(toast)}>
<i class="ri-close-line" />
</div>
</button>
</div>
{/each}
</div>
+39 -14
View File
@@ -9,15 +9,20 @@
let classes = "";
export { classes as class }; // export reserved keyword
let container;
let container = undefined;
let activeTrigger = undefined;
const dispatch = createEventDispatcher();
$: if (container) {
bindTrigger(trigger);
}
$: if (active) {
trigger?.classList?.add("active");
activeTrigger?.classList?.add("active");
dispatch("show");
} else {
trigger?.classList?.remove("active");
activeTrigger?.classList?.remove("active");
dispatch("hide");
}
@@ -42,7 +47,7 @@
!container ||
elem.classList.contains(closableClass) ||
// is the trigger itself (or a direct child)
(trigger?.contains(elem) && !container.contains(elem)) ||
(activeTrigger?.contains(elem) && !container.contains(elem)) ||
// is closable toggler child
(container.contains(elem) && elem.closest && elem.closest("." + closableClass))
);
@@ -51,6 +56,8 @@
function handleClickToggle(e) {
if (!active || isClosable(e.target)) {
e.preventDefault();
e.stopPropagation();
toggle();
}
}
@@ -67,13 +74,13 @@
}
function handleOutsideClick(e) {
if (active && !container?.contains(e.target) && !trigger?.contains(e.target)) {
if (active && !container?.contains(e.target) && !activeTrigger?.contains(e.target)) {
hide();
}
}
function handleEscPress(e) {
if (active && escClose && e.code == "Escape") {
if (active && escClose && e.code === "Escape") {
e.preventDefault();
hide();
}
@@ -83,16 +90,34 @@
return handleOutsideClick(e);
}
function bindTrigger(newTrigger) {
cleanup();
activeTrigger = newTrigger || container?.parentNode;
if (!activeTrigger) {
return;
}
container?.addEventListener("click", handleClickToggle);
activeTrigger.addEventListener("click", handleClickToggle);
activeTrigger.addEventListener("keydown", handleKeydownToggle);
}
function cleanup() {
if (!activeTrigger) {
return;
}
container?.removeEventListener("click", handleClickToggle);
activeTrigger.removeEventListener("click", handleClickToggle);
activeTrigger.removeEventListener("keydown", handleKeydownToggle);
}
onMount(() => {
trigger = trigger || container.parentNode;
bindTrigger();
trigger.addEventListener("click", handleClickToggle);
trigger.addEventListener("keydown", handleKeydownToggle);
return () => {
trigger.removeEventListener("click", handleClickToggle);
trigger.removeEventListener("keydown", handleKeydownToggle);
};
return () => cleanup();
});
</script>
@@ -0,0 +1,221 @@
<script>
import { scale, slide } from "svelte/transition";
import { Collection } from "pocketbase";
import { errors } from "@/stores/errors";
import tooltip from "@/actions/tooltip";
import Field from "@/components/base/Field.svelte";
import CommonHelper from "@/utils/CommonHelper";
import MultipleValueInput from "@/components/base/MultipleValueInput.svelte";
import Accordion from "@/components/base/Accordion.svelte";
export let collection = new Collection();
$: if (collection.isAuth && CommonHelper.isEmpty(collection.options)) {
collection.options = {
allowEmailAuth: true,
allowUsernameAuth: true,
allowOAuth2Auth: true,
minPasswordLength: 8,
};
}
$: hasUsernameErrors = false;
$: hasEmailErrors =
!CommonHelper.isEmpty($errors?.options?.allowEmailAuth) ||
!CommonHelper.isEmpty($errors?.options?.onlyEmailDomains) ||
!CommonHelper.isEmpty($errors?.options?.exceptEmailDomains);
$: hasOAuth2Errors = !CommonHelper.isEmpty($errors?.options?.allowOAuth2Auth);
</script>
<h4 class="section-title">Auth methods</h4>
<div class="accordions">
<Accordion single>
<svelte:fragment slot="header">
<div class="inline-flex">
<i class="ri-user-star-line" />
<span class="txt">Username/Password</span>
</div>
<div class="flex-fill" />
{#if collection.options.allowUsernameAuth}
<span class="label label-success">Enabled</span>
{:else}
<span class="label">Disabled</span>
{/if}
{#if hasUsernameErrors}
<i
class="ri-error-warning-fill txt-danger"
transition:scale={{ duration: 150, start: 0.7 }}
use:tooltip={{ text: "Has errors", position: "left" }}
/>
{/if}
</svelte:fragment>
<Field class="form-field form-field-toggle m-b-0" name="options.allowUsernameAuth" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={collection.options.allowUsernameAuth} />
<label for={uniqueId}>Enable</label>
</Field>
</Accordion>
<Accordion single>
<svelte:fragment slot="header">
<div class="inline-flex">
<i class="ri-mail-star-line" />
<span class="txt">Email/Password</span>
</div>
<div class="flex-fill" />
{#if collection.options.allowEmailAuth}
<span class="label label-success">Enabled</span>
{:else}
<span class="label">Disabled</span>
{/if}
{#if hasEmailErrors}
<i
class="ri-error-warning-fill txt-danger"
transition:scale={{ duration: 150, start: 0.7 }}
use:tooltip={{ text: "Has errors", position: "left" }}
/>
{/if}
</svelte:fragment>
<Field class="form-field form-field-toggle m-0" name="options.allowEmailAuth" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={collection.options.allowEmailAuth} />
<label for={uniqueId}>Enable</label>
</Field>
{#if collection.options.allowEmailAuth}
<div class="grid grid-sm p-t-sm" transition:slide|local={{ duration: 150 }}>
<div class="col-lg-6">
<Field
class="form-field {!CommonHelper.isEmpty(collection.options.onlyEmailDomains)
? 'disabled'
: ''}"
name="options.exceptEmailDomains"
let:uniqueId
>
<label for={uniqueId}>
<span class="txt">Except domains</span>
<i
class="ri-information-line link-hint"
use:tooltip={{
text: 'Email domains that are NOT allowed to sign up. \n This field is disabled if "Only domains" is set.',
position: "top",
}}
/>
</label>
<MultipleValueInput
id={uniqueId}
disabled={!CommonHelper.isEmpty(collection.options.onlyEmailDomains)}
bind:value={collection.options.exceptEmailDomains}
/>
<div class="help-block">Use comma as separator.</div>
</Field>
</div>
<div class="col-lg-6">
<Field
class="form-field {!CommonHelper.isEmpty(collection.options.exceptEmailDomains)
? 'disabled'
: ''}"
name="options.onlyEmailDomains"
let:uniqueId
>
<label for={uniqueId}>
<span class="txt">Only domains</span>
<i
class="ri-information-line link-hint"
use:tooltip={{
text: 'Email domains that are ONLY allowed to sign up. \n This field is disabled if "Except domains" is set.',
position: "top",
}}
/>
</label>
<MultipleValueInput
id={uniqueId}
disabled={!CommonHelper.isEmpty(collection.options.exceptEmailDomains)}
bind:value={collection.options.onlyEmailDomains}
/>
<div class="help-block">Use comma as separator.</div>
</Field>
</div>
</div>
{/if}
</Accordion>
<Accordion single>
<svelte:fragment slot="header">
<div class="inline-flex">
<i class="ri-shield-star-line" />
<span class="txt">OAuth2</span>
</div>
<div class="flex-fill" />
{#if collection.options.allowOAuth2Auth}
<span class="label label-success">Enabled</span>
{:else}
<span class="label">Disabled</span>
{/if}
{#if hasOAuth2Errors}
<i
class="ri-error-warning-fill txt-danger"
transition:scale={{ duration: 150, start: 0.7 }}
use:tooltip={{ text: "Has errors", position: "left" }}
/>
{/if}
</svelte:fragment>
<Field class="form-field form-field-toggle m-b-0" name="options.allowOAuth2Auth" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={collection.options.allowOAuth2Auth} />
<label for={uniqueId}>Enable</label>
</Field>
{#if collection.options.allowOAuth2Auth}
<div class="block" transition:slide|local={{ duration: 150 }}>
<div class="flex p-t-base">
<a href="/_/#/settings/auth-providers" target="_blank" class="btn btn-sm btn-outline">
<span class="txt">Manage OAuth2 providers</span>
</a>
</div>
</div>
{/if}
</Accordion>
</div>
<hr />
<h4 class="section-title">General</h4>
<Field class="form-field required" name="options.minPasswordLength" let:uniqueId>
<label for={uniqueId}>Minimum password length</label>
<input
type="number"
id={uniqueId}
required
min="6"
max="72"
bind:value={collection.options.minPasswordLength}
/>
</Field>
<Field class="form-field form-field-toggle m-b-sm" name="options.requireEmail" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={collection.options.requireEmail} />
<label for={uniqueId}>
<span class="txt">Always require email</span>
<i
class="ri-information-line txt-sm link-hint"
use:tooltip={{
text: "The constraint is applied only for new records.\nAlso note that some OAuth2 providers (like Twitter), don't return an email and the authentication may fail if the email field is required.",
position: "right",
}}
/>
</label>
</Field>
@@ -0,0 +1,156 @@
<script>
import { Collection } from "pocketbase";
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
const baseTabs = {
list: {
label: "List/Search",
component: import("@/components/collections/docs/ListApiDocs.svelte"),
},
view: {
label: "View",
component: import("@/components/collections/docs/ViewApiDocs.svelte"),
},
create: {
label: "Create",
component: import("@/components/collections/docs/CreateApiDocs.svelte"),
},
update: {
label: "Update",
component: import("@/components/collections/docs/UpdateApiDocs.svelte"),
},
delete: {
label: "Delete",
component: import("@/components/collections/docs/DeleteApiDocs.svelte"),
},
realtime: {
label: "Realtime",
component: import("@/components/collections/docs/RealtimeApiDocs.svelte"),
},
};
const authTabs = {
"auth-with-password": {
label: "Auth with password",
component: import("@/components/collections/docs/AuthWithPasswordDocs.svelte"),
},
"auth-with-oauth2": {
label: "Auth with OAuth2",
component: import("@/components/collections/docs/AuthWithOAuth2Docs.svelte"),
},
refresh: {
label: "Auth refresh",
component: import("@/components/collections/docs/AuthRefreshDocs.svelte"),
},
"request-verification": {
label: "Request verification",
component: import("@/components/collections/docs/RequestVerificationDocs.svelte"),
},
"confirm-verification": {
label: "Confirm verification",
component: import("@/components/collections/docs/ConfirmVerificationDocs.svelte"),
},
"request-password-reset": {
label: "Request password reset",
component: import("@/components/collections/docs/RequestPasswordResetDocs.svelte"),
},
"confirm-password-reset": {
label: "Confirm password reset",
component: import("@/components/collections/docs/ConfirmPasswordResetDocs.svelte"),
},
"request-email-change": {
label: "Request email change",
component: import("@/components/collections/docs/RequestEmailChangeDocs.svelte"),
},
"confirm-email-change": {
label: "Confirm email change",
component: import("@/components/collections/docs/ConfirmEmailChangeDocs.svelte"),
},
"list-auth-methods": {
label: "List auth methods",
component: import("@/components/collections/docs/AuthMethodsDocs.svelte"),
},
"list-linked-accounts": {
label: "List OAuth2 accounts",
component: import("@/components/collections/docs/ListExternalAuthsDocs.svelte"),
},
"unlink-account": {
label: "Unlink OAuth2 account",
component: import("@/components/collections/docs/UnlinkExternalAuthDocs.svelte"),
},
};
let docsPanel;
let collection = new Collection();
let activeTab;
let tabs = [];
$: if (collection.isAuth) {
tabs = Object.assign({}, baseTabs, authTabs);
if (!collection?.options.allowUsernameAuth && !collection?.options.allowEmailAuth) {
delete tabs["auth-with-password"];
}
if (!collection?.options.allowOAuth2Auth) {
delete tabs["auth-with-oauth2"];
}
} else {
tabs = Object.assign({}, baseTabs);
}
// reset active tab on tabs list change
if (tabs.length) {
activeTab = Object.keys(tabs)[0];
}
export function show(model) {
collection = model;
changeTab(Object.keys(tabs)[0]);
return docsPanel?.show();
}
export function hide() {
return docsPanel?.hide();
}
export function changeTab(newTab) {
activeTab = newTab;
}
</script>
<OverlayPanel bind:this={docsPanel} on:hide on:show class="docs-panel">
<div class="docs-content-wrapper">
<aside class="docs-sidebar" class:compact={collection?.isAuth}>
<nav class="sidebar-content">
{#each Object.entries(tabs) as [key, tab] (key)}
<button
type="button"
class="sidebar-item"
class:active={activeTab === key}
on:click={() => changeTab(key)}
>
{tab.label}
</button>
{/each}
</nav>
</aside>
<div class="docs-content">
{#each Object.entries(tabs) as [key, tab] (key)}
{#if activeTab === key}
{#await tab.component then { default: TabComponent }}
<TabComponent {collection} />
{/await}
{/if}
{/each}
</div>
</div>
<!-- visible only on small screens -->
<svelte:fragment slot="footer">
<button type="button" class="btn btn-secondary" on:click={() => hide()}>
<span class="txt">Close</span>
</button>
</svelte:fragment>
</OverlayPanel>
@@ -2,10 +2,40 @@
import { SchemaField } from "pocketbase";
import FieldAccordion from "@/components/collections/FieldAccordion.svelte";
const reservedNames = ["id", "created", "updated"];
export let collection = {};
const baseReservedNames = [
"id",
"created",
"updated",
"collectionId",
"collectionName",
"expand",
"true",
"false",
"null",
];
let reservedNames = [];
$: if (collection.isAuth) {
reservedNames = baseReservedNames.concat([
"username",
"email",
"emailVisibility",
"verified",
"tokenKey",
"passwordHash",
"lastResetSentAt",
"lastVerificationSentAt",
"password",
"passwordConfirm",
"oldPassword",
]);
} else {
reservedNames = baseReservedNames.slice(0);
}
$: if (typeof collection?.schema === "undefined") {
collection = collection || {};
collection.schema = [];
@@ -58,15 +88,68 @@
return result;
}
// ---------------------------------------------------------------
// fields drag&drop handling
// ---------------------------------------------------------------
function onFieldDrag(event, i) {
if (!event) {
return;
}
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.dropEffect = "move";
event.dataTransfer.setData("text/plain", i);
}
function onFieldDrop(event, target) {
if (!event) {
return;
}
event.dataTransfer.dropEffect = "move";
const start = parseInt(event.dataTransfer.getData("text/plain"));
const newSchema = collection.schema;
if (start < target) {
newSchema.splice(target + 1, 0, newSchema[start]);
newSchema.splice(start, 1);
} else {
newSchema.splice(target, 0, newSchema[start]);
newSchema.splice(start + 1, 1);
}
collection.schema = newSchema;
}
</script>
<div class="block m-b-25">
<p class="txt-sm">
System fields:
<code class="txt-sm">id</code> ,
<code class="txt-sm">created</code> ,
<code class="txt-sm">updated</code>
{#if collection.isAuth}
,
<code class="txt-sm">username</code> ,
<code class="txt-sm">email</code> ,
<code class="txt-sm">emailVisibility</code> ,
<code class="txt-sm">verified</code>
{/if}
.
</p>
</div>
<div class="accordions">
{#each collection.schema as field, i (i)}
{#each collection.schema as field, i (i + field.id)}
<FieldAccordion
bind:field
key={i}
excludeNames={reservedNames.concat(getSiblingsFieldNames(field))}
on:remove={() => removeField(i)}
on:dragstart={(e) => onFieldDrag(e?.detail, i)}
on:drop={(e) => onFieldDrop(e?.detail, i)}
/>
{/each}
</div>
@@ -75,7 +158,7 @@
<button
type="button"
class="btn btn-block {collection.schema?.length ? 'btn-secondary' : 'btn-success'}"
class="btn btn-block {collection.schema?.length ? 'btn-secondary' : 'btn-warning'}"
on:click={newField}
>
<i class="ri-add-line" />
@@ -1,52 +1,18 @@
<script>
import { onMount, tick } from "svelte";
import { slide } from "svelte/transition";
import { Collection } from "pocketbase";
import tooltip from "@/actions/tooltip";
import Field from "@/components/base/Field.svelte";
import RuleField from "@/components/collections/RuleField.svelte";
export let collection = new Collection();
let tempValues = {};
let showFiltersInfo = false;
let editorRefs = {};
let ruleInputComponent;
let isRuleComponentLoading = false;
// all supported collection rules in "collection_rule_prop: label" format
const ruleProps = {
listRule: "List Action",
viewRule: "View Action",
createRule: "Create Action",
updateRule: "Update Action",
deleteRule: "Delete Action",
};
function isAdminOnly(propVal) {
return propVal === null;
}
async function loadEditorComponent() {
isRuleComponentLoading = true;
try {
ruleInputComponent = (await import("@/components/base/FilterAutocompleteInput.svelte")).default;
} catch (err) {
console.warn(err);
ruleInputComponent = null;
}
isRuleComponentLoading = false;
}
onMount(() => {
loadEditorComponent();
});
</script>
<div class="block m-b-base">
<div class="flex">
<div class="flex txt-sm m-b-5">
<p>
All rules follow the
<a href={import.meta.env.PB_RULES_SYNTAX_DOCS} target="_blank" rel="noopener">
<a href={import.meta.env.PB_RULES_SYNTAX_DOCS} target="_blank" rel="noopener noreferrer">
PocketBase filter syntax and operators
</a>.
</p>
@@ -85,7 +51,7 @@
<code>@request.method</code>
<code>@request.query.*</code>
<code>@request.data.*</code>
<code>@request.user.*</code>
<code>@request.auth.*</code>
</div>
<hr class="m-t-10 m-b-5" />
@@ -104,7 +70,7 @@
<p>
Example rule:
<br />
<code>@request.user.id!="" && created>"2022-01-01 00:00:00"</code>
<code>@request.auth.id != "" && created > "2022-01-01 00:00:00"</code>
</p>
</div>
</div>
@@ -112,78 +78,37 @@
{/if}
</div>
{#if isRuleComponentLoading}
<div class="txt-center">
<span class="loader" />
</div>
{:else}
{#each Object.entries(ruleProps) as [prop, label] (prop)}
<hr class="m-t-sm m-b-sm" />
<div class="rule-block">
{#if isAdminOnly(collection[prop])}
<button
type="button"
class="rule-toggle-btn btn btn-circle btn-outline btn-success"
use:tooltip={"Unlock and set custom rule"}
on:click={async () => {
collection[prop] = tempValues[prop] || "";
await tick();
editorRefs[prop]?.focus();
}}
>
<i class="ri-lock-unlock-line" />
</button>
{:else}
<button
type="button"
class="rule-toggle-btn btn btn-circle btn-outline"
use:tooltip={"Lock and set to Admins only"}
on:click={() => {
tempValues[prop] = collection[prop];
collection[prop] = null;
}}
>
<i class="ri-lock-line" />
</button>
{/if}
<RuleField label="List/Search action" formKey="listRule" {collection} bind:rule={collection.listRule} />
<Field
class="form-field rule-field m-0 {isAdminOnly(collection[prop]) ? 'disabled' : ''}"
name={prop}
let:uniqueId
>
<label for={uniqueId}>
{label} - {isAdminOnly(collection[prop]) ? "Admins only" : "Custom rule"}
</label>
<hr class="m-t-sm m-b-sm" />
<RuleField label="View action" formKey="viewRule" {collection} bind:rule={collection.viewRule} />
<svelte:component
this={ruleInputComponent}
id={uniqueId}
bind:this={editorRefs[prop]}
bind:value={collection[prop]}
baseCollection={collection}
disabled={isAdminOnly(collection[prop])}
/>
<hr class="m-t-sm m-b-sm" />
<RuleField label="Create action" formKey="createRule" {collection} bind:rule={collection.createRule} />
<div class="help-block">
{#if isAdminOnly(collection[prop])}
Only admins will be able to access (unlock to change)
{:else}
Leave empty to grant everyone access
{/if}
</div>
</Field>
</div>
{/each}
<hr class="m-t-sm m-b-sm" />
<RuleField label="Update action" formKey="updateRule" {collection} bind:rule={collection.updateRule} />
<hr class="m-t-sm m-b-sm" />
<RuleField label="Delete action" formKey="deleteRule" {collection} bind:rule={collection.deleteRule} />
{#if collection?.isAuth}
<hr class="m-t-sm m-b-sm" />
<RuleField
label="Manage action"
formKey="options.manageRule"
{collection}
bind:rule={collection.options.manageRule}
>
<svelte:fragment>
<p>
This API rule gives admin-like permissions to allow fully managing the auth record(s), eg.
changing the password without requiring to enter the old one, directly updating the verified
state or email, etc.
</p>
<p>
This rule is executed in addition to the <code>create</code> and <code>update</code> API rules.
</p>
</svelte:fragment>
</RuleField>
{/if}
<style>
.rule-block {
display: flex;
align-items: flex-start;
gap: var(--xsSpacing);
}
.rule-toggle-btn {
margin-top: 15px;
}
</style>
@@ -7,17 +7,27 @@
import { errors, setErrors } from "@/stores/errors";
import { confirm } from "@/stores/confirmation";
import { addSuccessToast } from "@/stores/toasts";
import { addCollection, removeCollection, activeCollection } from "@/stores/collections";
import { addCollection, removeCollection } from "@/stores/collections";
import tooltip from "@/actions/tooltip";
import Field from "@/components/base/Field.svelte";
import Toggler from "@/components/base/Toggler.svelte";
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
import CollectionFieldsTab from "@/components/collections/CollectionFieldsTab.svelte";
import CollectionRulesTab from "@/components/collections/CollectionRulesTab.svelte";
import CollectionAuthOptionsTab from "@/components/collections/CollectionAuthOptionsTab.svelte";
import CollectionUpdateConfirm from "@/components/collections/CollectionUpdateConfirm.svelte";
const TAB_FIELDS = "fields";
const TAB_RULES = "api_rules";
const TAB_OPTIONS = "options";
const TYPE_BASE = "base";
const TYPE_AUTH = "auth";
const collectionTypes = {};
collectionTypes[TYPE_BASE] = "Base";
collectionTypes[TYPE_AUTH] = "Auth";
const dispatch = createEventDispatcher();
let collectionPanel;
@@ -42,6 +52,11 @@
$: canSave = collection.isNew || hasChanges;
$: if (activeTab === TAB_OPTIONS && collection.type !== TYPE_AUTH) {
// reset selected tab
changeTab(TAB_FIELDS);
}
export function changeTab(newTab) {
activeTab = newTab;
}
@@ -111,11 +126,10 @@
);
addCollection(result);
if (collection.isNew) {
$activeCollection = result;
}
dispatch("save", result);
dispatch("save", {
isNew: collection.isNew,
collection: result,
});
})
.catch((err) => {
ApiClient.errorResponseHandler(err);
@@ -163,11 +177,15 @@
function calculateFormHash(m) {
return JSON.stringify(m);
}
function setCollectionType(t) {
collection.type = t;
}
</script>
<OverlayPanel
bind:this={collectionPanel}
class="overlay-panel-lg colored-header compact-header collection-panel"
class="overlay-panel-lg colored-header collection-panel"
beforeHide={() => {
if (hasChanges && confirmClose) {
confirm("You have unsaved changes. Do you really want to close the panel?", () => {
@@ -191,7 +209,11 @@
<button type="button" class="btn btn-sm btn-circle btn-secondary flex-gap-0">
<i class="ri-more-line" />
<Toggler class="dropdown dropdown-right m-t-5">
<button type="button" class="dropdown-item closable" on:click={() => deleteConfirm()}>
<button
type="button"
class="dropdown-item txt-danger closable"
on:click|preventDefault|stopPropagation={() => deleteConfirm()}
>
<i class="ri-delete-bin-7-line" />
<span class="txt">Delete</span>
</button>
@@ -206,11 +228,12 @@
}}
>
<Field
class="form-field required m-b-0 {isSystemUpdate ? 'disabled' : ''}"
class="form-field collection-field-name required m-b-0 {isSystemUpdate ? 'disabled' : ''}"
name="name"
let:uniqueId
>
<label for={uniqueId}>Name</label>
<!-- svelte-ignore a11y-autofocus -->
<input
type="text"
@@ -226,6 +249,35 @@
e.target.value = collection.name;
}}
/>
<div class="form-field-addon">
<button
type="button"
class="btn btn-sm p-r-10 p-l-10 {collection.isNew ? 'btn-hint' : 'btn-secondary'}"
disabled={!collection.isNew}
>
<!-- empty span for alignment -->
<span />
<span class="txt">Type: {collectionTypes[collection.type] || "N/A"}</span>
{#if collection.isNew}
<i class="ri-arrow-down-s-fill" />
<Toggler class="dropdown dropdown-right dropdown-nowrap m-t-5">
{#each Object.entries(collectionTypes) as [type, label]}
<button
type="button"
class="dropdown-item closable"
class:selected={type == collection.type}
on:click={() => setCollectionType(type)}
>
<i class={CommonHelper.getCollectionTypeIcon(type)} />
<span class="txt">{label} collection</span>
</button>
{/each}
</Toggler>
{/if}
</button>
</div>
{#if collection.system}
<div class="help-block">System collection</div>
{/if}
@@ -258,7 +310,7 @@
on:click={() => changeTab(TAB_RULES)}
>
<span class="txt">API Rules</span>
{#if !CommonHelper.isEmpty($errors?.listRule) || !CommonHelper.isEmpty($errors?.viewRule) || !CommonHelper.isEmpty($errors?.createRule) || !CommonHelper.isEmpty($errors?.updateRule) || !CommonHelper.isEmpty($errors?.deleteRule)}
{#if !CommonHelper.isEmpty($errors?.listRule) || !CommonHelper.isEmpty($errors?.viewRule) || !CommonHelper.isEmpty($errors?.createRule) || !CommonHelper.isEmpty($errors?.updateRule) || !CommonHelper.isEmpty($errors?.deleteRule) || !CommonHelper.isEmpty($errors?.options?.manageRule)}
<i
class="ri-error-warning-fill txt-danger"
transition:scale|local={{ duration: 150, start: 0.7 }}
@@ -266,6 +318,24 @@
/>
{/if}
</button>
{#if collection.isAuth}
<button
type="button"
class="tab-item"
class:active={activeTab === TAB_OPTIONS}
on:click={() => changeTab(TAB_OPTIONS)}
>
<span class="txt">Options</span>
{#if !CommonHelper.isEmpty($errors?.options) && !$errors?.options?.manageRule}
<i
class="ri-error-warning-fill txt-danger"
transition:scale|local={{ duration: 150, start: 0.7 }}
use:tooltip={"Has errors"}
/>
{/if}
</button>
{/if}
</div>
</svelte:fragment>
@@ -280,6 +350,12 @@
<CollectionRulesTab bind:collection />
</div>
{/if}
{#if collection.isAuth}
<div class="tab-item" class:active={activeTab === TAB_OPTIONS}>
<CollectionAuthOptionsTab bind:collection />
</div>
{/if}
</div>
<svelte:fragment slot="footer">
@@ -302,6 +378,6 @@
<style>
.tabs-content {
z-index: 3; /* autocomplete dropdown overlay fix */
z-index: auto; /* autocomplete dropdown overlay fix */
}
</style>
@@ -1,4 +1,6 @@
<script>
import { link } from "svelte-spa-router";
import CommonHelper from "@/utils/CommonHelper";
import { hideControls } from "@/stores/app";
import { collections, activeCollection } from "@/stores/collections";
import CollectionUpsertPanel from "@/components/collections/CollectionUpsertPanel.svelte";
@@ -12,15 +14,27 @@
$: filteredCollections = $collections.filter((collection) => {
return (
collection.name != import.meta.env.PB_PROFILE_COLLECTION &&
(collection.id == searchTerm ||
collection.name.replace(/\s+/g, "").toLowerCase().includes(normalizedSearch))
collection.id == searchTerm ||
collection.name.replace(/\s+/g, "").toLowerCase().includes(normalizedSearch)
);
});
$: if ($collections) {
scrollIntoView();
}
function selectCollection(collection) {
$activeCollection = collection;
}
function scrollIntoView() {
setTimeout(() => {
const activeItem = document.querySelector(".collection-sidebar .sidebar-list-item.active");
if (activeItem) {
activeItem?.scrollIntoView({ block: "nearest" });
}
}, 0);
}
</script>
<aside class="page-sidebar collection-sidebar">
@@ -42,21 +56,18 @@
<hr class="m-t-5 m-b-xs" />
<div class="sidebar-content">
<div class="sidebar-content" class:sidebar-content-compact={filteredCollections.length > 20}>
{#each filteredCollections as collection (collection.id)}
<div
tabindex="0"
<a
href="/collections?collectionId={collection.id}"
class="sidebar-list-item"
class:active={$activeCollection?.id === collection.id}
on:click={() => selectCollection(collection)}
use:link
>
{#if $activeCollection?.id === collection.id}
<i class="ri-folder-open-line" />
{:else}
<i class="ri-folder-2-line" />
{/if}
<i class={CommonHelper.getCollectionTypeIcon(collection.type)} />
<span class="txt">{collection.name}</span>
</div>
</a>
{:else}
{#if normalizedSearch.length}
<p class="txt-hint m-t-10 m-b-10 txt-center">No collections found.</p>
@@ -74,4 +85,11 @@
{/if}
</aside>
<CollectionUpsertPanel bind:this={collectionPanel} />
<CollectionUpsertPanel
bind:this={collectionPanel}
on:save={(e) => {
if (e.detail?.isNew && e.detail.collection) {
selectCollection(e.detail.collection);
}
}}
/>
@@ -121,9 +121,15 @@
on:expand
on:collapse
on:toggle
on:dragenter
on:dragleave
on:dragstart
on:drop
draggable
single
{interactive}
class={disabled || field.toDelete || field.system ? "field-accordion disabled" : "field-accordion"}
{...$$restProps}
>
<svelte:fragment slot="header">
<div class="inline-flex">
@@ -144,7 +150,7 @@
<span class="label" class:label-warning={interactive && !field.toDelete}>New</span>
{/if}
{#if field.required}
<span class="label label-success">Required</span>
<span class="label label-success">Nonempty</span>
{/if}
{#if field.unique}
<span class="label label-success">Unique</span>
@@ -177,6 +183,11 @@
<form
class="field-form"
on:dragstart={(e) => {
e.stopPropagation();
e.preventDefault();
e.stopImmediatePropagation();
}}
on:submit|preventDefault={() => {
canBeStored && collapse();
}}
@@ -192,6 +203,7 @@
<FieldTypeSelect id={uniqueId} disabled={field.id} bind:value={field.type} />
</Field>
</div>
<div class="col-sm-6">
<Field
class="
@@ -257,7 +269,18 @@
<div class="col-sm-4 flex">
<Field class="form-field form-field-toggle m-0" name="requried" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={field.required} />
<label for={uniqueId}>Required</label>
<label for={uniqueId}>
<span class="txt">Nonempty</span>
<i
class="ri-information-line link-hint"
use:tooltip={{
text: `Requires the field value to be nonempty\n(aka. not ${CommonHelper.zeroDefaultStr(
field
)}).`,
position: "right",
}}
/>
</label>
</Field>
</div>
@@ -0,0 +1,121 @@
<script context="module">
let cachedRuleComponent;
</script>
<script>
import { tick } from "svelte";
import tooltip from "@/actions/tooltip";
import Field from "@/components/base/Field.svelte";
export let collection = null;
export let rule = null;
export let label = "Rule";
export let formKey = "rule";
export let required = false;
let editorRef = null;
let tempValue = null;
let ruleInputComponent = cachedRuleComponent;
let isRuleComponentLoading = false;
$: isAdminOnly = rule === null;
async function loadEditorComponent() {
if (ruleInputComponent || isRuleComponentLoading) {
return; // already loaded or in the process
}
isRuleComponentLoading = true;
ruleInputComponent = (await import("@/components/base/FilterAutocompleteInput.svelte")).default;
cachedRuleComponent = ruleInputComponent;
isRuleComponentLoading = false;
}
loadEditorComponent();
</script>
{#if isRuleComponentLoading}
<div class="txt-center">
<span class="loader" />
</div>
{:else}
<div class="rule-block">
{#if isAdminOnly}
<button
type="button"
class="rule-toggle-btn btn btn-circle btn-outline btn-success"
use:tooltip={{
text: "Unlock and set custom rule",
position: "left",
}}
on:click={async () => {
rule = tempValue || "";
await tick();
editorRef?.focus();
}}
>
<i class="ri-lock-unlock-line" />
</button>
{:else}
<button
type="button"
class="rule-toggle-btn btn btn-circle btn-outline"
use:tooltip={{
text: "Lock and set to Admins only",
position: "left",
}}
on:click={() => {
tempValue = rule;
rule = null;
}}
>
<i class="ri-lock-line" />
</button>
{/if}
<Field
class="form-field rule-field m-0 {required ? 'requied' : ''} {isAdminOnly ? 'disabled' : ''}"
name={formKey}
let:uniqueId
>
<label for={uniqueId}>
{label} - {isAdminOnly ? "Admins only" : "Custom rule"}
</label>
<svelte:component
this={ruleInputComponent}
id={uniqueId}
bind:this={editorRef}
bind:value={rule}
baseCollection={collection}
disabled={isAdminOnly}
/>
<div class="help-block">
<slot {isAdminOnly}>
<p>
{#if isAdminOnly}
Only admins will be able to perform this action (unlock to change)
{:else}
Leave empty to grant everyone access
{/if}
</p>
</slot>
</div>
</Field>
</div>
{/if}
<style>
.rule-block {
display: flex;
align-items: flex-start;
gap: var(--xsSpacing);
}
.rule-toggle-btn {
margin-top: 15px;
}
</style>
@@ -0,0 +1,110 @@
<script>
import { Collection } from "pocketbase";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import CodeBlock from "@/components/base/CodeBlock.svelte";
import SdkTabs from "@/components/collections/docs/SdkTabs.svelte";
export let collection = new Collection();
let responseTab = 200;
let responses = [];
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseUrl);
$: responses = [
{
code: 200,
body: `
{
"usernamePassword": true,
"emailPassword": true,
"authProviders": [
{
"name": "github",
"state": "3Yd8jNkK_6PJG6hPWwBjLqKwse6Ejd",
"codeVerifier": "KxFDWz1B3fxscCDJ_9gHQhLuh__ie7",
"codeChallenge": "NM1oVexB6Q6QH8uPtOUfK7tq4pmu4Jz6lNDIwoxHZNE=",
"codeChallengeMethod": "S256",
"authUrl": "https://github.com/login/oauth/authorize?client_id=demo&code_challenge=NM1oVexB6Q6QH8uPtOUfK7tq4pmu4Jz6lNDIwoxHZNE%3D&code_challenge_method=S256&response_type=code&scope=user&state=3Yd8jNkK_6PJG6hPWwBjLqKwse6Ejd&redirect_uri="
},
{
"name": "gitlab",
"state": "NeQSbtO5cShr_mk5__3CUukiMnymeb",
"codeVerifier": "ahTFHOgua8mkvPAlIBGwCUJbWKR_xi",
"codeChallenge": "O-GATkTj4eXDCnfonsqGLCd6njvTixlpCMvy5kjgOOg=",
"codeChallengeMethod": "S256",
"authUrl": "https://gitlab.com/oauth/authorize?client_id=demo&code_challenge=O-GATkTj4eXDCnfonsqGLCd6njvTixlpCMvy5kjgOOg%3D&code_challenge_method=S256&response_type=code&scope=read_user&state=NeQSbtO5cShr_mk5__3CUukiMnymeb&redirect_uri="
},
{
"name": "google",
"state": "zB3ZPifV1TW2GMuvuFkamSXfSNkHPQ",
"codeVerifier": "t3CmO5VObGzdXqieakvR_fpjiW0zdO",
"codeChallenge": "KChwoQPKYlz2anAdqtgsSTdIo8hdwtc1fh2wHMwW2Yk=",
"codeChallengeMethod": "S256",
"authUrl": "https://accounts.google.com/o/oauth2/auth?client_id=demo&code_challenge=KChwoQPKYlz2anAdqtgsSTdIo8hdwtc1fh2wHMwW2Yk%3D&code_challenge_method=S256&response_type=code&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email&state=zB3ZPifV1TW2GMuvuFkamSXfSNkHPQ&redirect_uri="
}
]
}
`,
},
];
</script>
<h3 class="m-b-sm">List auth methods ({collection.name})</h3>
<div class="content txt-lg m-b-sm">
<p>Returns a public list with all allowed <strong>{collection.name}</strong> authentication methods.</p>
</div>
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${backendAbsUrl}');
...
const result = await pb.collection('${collection?.name}').listAuthMethods();
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${backendAbsUrl}');
...
final result = await pb.collection('${collection?.name}').listAuthMethods();
`}
/>
<h6 class="m-b-xs">API details</h6>
<div class="alert alert-info">
<strong class="label label-primary">GET</strong>
<div class="content">
<p>
/api/collections/<strong>{collection.name}</strong>/auth-methods
</p>
</div>
</div>
<div class="section-title">Responses</div>
<div class="tabs">
<div class="tabs-header compact left">
{#each responses as response (response.code)}
<button
class="tab-item"
class:active={responseTab === response.code}
on:click={() => (responseTab = response.code)}
>
{response.code}
</button>
{/each}
</div>
<div class="tabs-content">
{#each responses as response (response.code)}
<div class="tab-item" class:active={responseTab === response.code}>
<CodeBlock content={response.body} />
</div>
{/each}
</div>
</div>
@@ -0,0 +1,169 @@
<script>
import { Collection } from "pocketbase";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import CodeBlock from "@/components/base/CodeBlock.svelte";
import SdkTabs from "@/components/collections/docs/SdkTabs.svelte";
export let collection = new Collection();
let responseTab = 200;
let responses = [];
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseUrl);
$: responses = [
{
code: 200,
body: JSON.stringify(
{
token: "JWT_TOKEN",
record: CommonHelper.dummyCollectionRecord(collection),
},
null,
2
),
},
{
code: 400,
body: `
{
"code": 400,
"message": "Failed to authenticate.",
"data": {
"identity": {
"code": "validation_required",
"message": "Missing required value."
}
}
}
`,
},
{
code: 401,
body: `
{
"code": 401,
"message": "The request requires valid record authorization token to be set.",
"data": {}
}
`,
},
{
code: 403,
body: `
{
"code": 403,
"message": "The authorized record model is not allowed to perform this action.",
"data": {}
}
`,
},
];
</script>
<h3 class="m-b-sm">Auth refresh ({collection.name})</h3>
<div class="content txt-lg m-b-sm">
<p>
Returns a new auth response (token and account data) for an
<strong>already authenticated record</strong>.
</p>
<p>
<em>
This method is usually called by users on page/screen reload to ensure that the previously stored
data in <code>pb.authStore</code> is still valid and up-to-date.
</em>
</p>
</div>
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${backendAbsUrl}');
...
const authData = await pb.collection('${collection?.name}').authRefresh();
// after the above you can also access the refreshed auth data from the authStore
console.log(pb.authStore.isValid);
console.log(pb.authStore.token);
console.log(pb.authStore.model.id);
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${backendAbsUrl}');
...
final authData = await pb.collection('${collection?.name}').authRefresh();
// after the above you can also access the refreshed auth data from the authStore
print(pb.authStore.isValid);
print(pb.authStore.token);
print(pb.authStore.model.id);
`}
/>
<h6 class="m-b-xs">API details</h6>
<div class="alert alert-success">
<strong class="label label-primary">POST</strong>
<div class="content">
<p>
/api/collections/<strong>{collection.name}</strong>/auth-refresh
</p>
</div>
<p class="txt-hint txt-sm txt-right">Requires record <code>Authorization:TOKEN</code> header</p>
</div>
<div class="section-title">Query parameters</div>
<table class="table-compact table-border m-b-base">
<thead>
<tr>
<th>Param</th>
<th>Type</th>
<th width="60%">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>expand</td>
<td>
<span class="label">String</span>
</td>
<td>
Auto expand record relations. Ex.:
<CodeBlock content={`?expand=relField1,relField2.subRelField`} />
Supports up to 6-levels depth nested relations expansion. <br />
The expanded relations will be appended to the record under the
<code>expand</code> property (eg. <code>{`"expand": {"relField1": {...}, ...}`}</code>).
<br />
Only the relations to which the account has permissions to <strong>view</strong> will be expanded.
</td>
</tr>
</tbody>
</table>
<div class="section-title">Responses</div>
<div class="tabs">
<div class="tabs-header compact left">
{#each responses as response (response.code)}
<button
class="tab-item"
class:active={responseTab === response.code}
on:click={() => (responseTab = response.code)}
>
{response.code}
</button>
{/each}
</div>
<div class="tabs-content">
{#each responses as response (response.code)}
<div class="tab-item" class:active={responseTab === response.code}>
<CodeBlock content={response.body} />
</div>
{/each}
</div>
</div>
@@ -0,0 +1,261 @@
<script>
import { Collection } from "pocketbase";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import CodeBlock from "@/components/base/CodeBlock.svelte";
import SdkTabs from "@/components/collections/docs/SdkTabs.svelte";
export let collection = new Collection();
let responseTab = 200;
let responses = [];
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseUrl);
$: responses = [
{
code: 200,
body: JSON.stringify(
{
token: "JWT_TOKEN",
record: CommonHelper.dummyCollectionRecord(collection),
meta: {
id: "abc123",
name: "John Doe",
username: "john.doe",
email: "test@example.com",
avatarUrl: "https://example.com/avatar.png",
},
},
null,
2
),
},
{
code: 400,
body: `
{
"code": 400,
"message": "An error occurred while submitting the form.",
"data": {
"provider": {
"code": "validation_required",
"message": "Missing required value."
}
}
}
`,
},
];
</script>
<h3 class="m-b-sm">Auth with OAuth2 ({collection.name})</h3>
<div class="content txt-lg m-b-sm">
<p>Authenticate with an OAuth2 provider and returns a new auth token and account data.</p>
<p>This action usually should be called right after the provider login page redirect.</p>
<p>
You could also check the
<a href={import.meta.env.PB_OAUTH2_EXAMPLE} target="_blank" rel="noopener noreferrer">
OAuth2 web integration example
</a>.
</p>
</div>
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${backendAbsUrl}');
...
const authData = await pb.collection('${collection?.name}').authWithOAuth2(
'google',
'CODE',
'VERIFIER',
'REDIRECT_URL',
// optional data that will be used for the new account on OAuth2 sign-up
{
'name': 'test',
},
);
// after the above you can also access the auth data from the authStore
console.log(pb.authStore.isValid);
console.log(pb.authStore.token);
console.log(pb.authStore.model.id);
// "logout" the last authenticated account
pb.authStore.clear();
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${backendAbsUrl}');
...
final authData = await pb.collection('${collection?.name}').authWithOAuth2(
'google',
'CODE',
'VERIFIER',
'REDIRECT_URL',
// optional data that will be used for the new account on OAuth2 sign-up
createData: {
'name': 'test',
},
);
// after the above you can also access the auth data from the authStore
print(pb.authStore.isValid);
print(pb.authStore.token);
print(pb.authStore.model.id);
// "logout" the last authenticated account
pb.authStore.clear();
`}
/>
<h6 class="m-b-xs">API details</h6>
<div class="alert alert-success">
<strong class="label label-primary">POST</strong>
<div class="content">
<p>
/api/collections/<strong>{collection.name}</strong>/auth-with-oauth2
</p>
</div>
</div>
<div class="section-title">Body Parameters</div>
<table class="table-compact table-border m-b-base">
<thead>
<tr>
<th>Param</th>
<th>Type</th>
<th width="50%">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<div class="inline-flex">
<span class="label label-success">Required</span>
<span>provider</span>
</div>
</td>
<td>
<span class="label">String</span>
</td>
<td>The name of the OAuth2 client provider (eg. "google").</td>
</tr>
<tr>
<td>
<div class="inline-flex">
<span class="label label-success">Required</span>
<span>code</span>
</div>
</td>
<td>
<span class="label">String</span>
</td>
<td>The authorization code returned from the initial request.</td>
</tr>
<tr>
<td>
<div class="inline-flex">
<span class="label label-success">Required</span>
<span>codeVerifier</span>
</div>
</td>
<td>
<span class="label">String</span>
</td>
<td>The code verifier sent with the initial request as part of the code_challenge.</td>
</tr>
<tr>
<td>
<div class="inline-flex">
<span class="label label-success">Required</span>
<span>redirectUrl</span>
</div>
</td>
<td>
<span class="label">String</span>
</td>
<td>The redirect url sent with the initial request.</td>
</tr>
<tr>
<td>
<div class="inline-flex">
<span class="label label-warning">Optional</span>
<span>createData</span>
</div>
</td>
<td>
<span class="label">Object</span>
</td>
<td>
<p>Optional data that will be used when creating the auth record on OAuth2 sign-up.</p>
<p>
The created auth record must comply with the same requirements and validations in the
regular <strong>create</strong> action.
<br />
<em>
The data can only be in <code>json</code>, aka. <code>multipart/form-data</code> and files
upload currently are not supported during OAuth2 sign-ups.
</em>
</p>
</td>
</tr>
</tbody>
</table>
<div class="section-title">Query parameters</div>
<table class="table-compact table-border m-b-base">
<thead>
<tr>
<th>Param</th>
<th>Type</th>
<th width="60%">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>expand</td>
<td>
<span class="label">String</span>
</td>
<td>
Auto expand record relations. Ex.:
<CodeBlock content={`?expand=relField1,relField2.subRelField`} />
Supports up to 6-levels depth nested relations expansion. <br />
The expanded relations will be appended to the record under the
<code>expand</code> property (eg. <code>{`"expand": {"relField1": {...}, ...}`}</code>).
<br />
Only the relations to which the account has permissions to <strong>view</strong> will be expanded.
</td>
</tr>
</tbody>
</table>
<div class="section-title">Responses</div>
<div class="tabs">
<div class="tabs-header compact left">
{#each responses as response (response.code)}
<button
class="tab-item"
class:active={responseTab === response.code}
on:click={() => (responseTab = response.code)}
>
{response.code}
</button>
{/each}
</div>
<div class="tabs-content">
{#each responses as response (response.code)}
<div class="tab-item" class:active={responseTab === response.code}>
<CodeBlock content={response.body} />
</div>
{/each}
</div>
</div>
@@ -0,0 +1,223 @@
<script>
import { Collection } from "pocketbase";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import CodeBlock from "@/components/base/CodeBlock.svelte";
import SdkTabs from "@/components/collections/docs/SdkTabs.svelte";
export let collection = new Collection();
let responseTab = 200;
let responses = [];
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseUrl);
$: allowEmail = collection?.options?.allowEmailAuth;
$: allowUsername = collection?.options?.allowUsernameAuth;
$: exampleIdentityLabel =
allowUsername && allowEmail
? "YOUR_USERNAME_OR_EMAIL"
: allowUsername
? "YOUR_USERNAME"
: "YOUR_EMAIL";
$: responses = [
{
code: 200,
body: JSON.stringify(
{
token: "JWT_TOKEN",
record: CommonHelper.dummyCollectionRecord(collection),
},
null,
2
),
},
{
code: 400,
body: `
{
"code": 400,
"message": "Failed to authenticate.",
"data": {
"identity": {
"code": "validation_required",
"message": "Missing required value."
}
}
}
`,
},
];
</script>
<h3 class="m-b-sm">Auth with password ({collection.name})</h3>
<div class="content txt-lg m-b-sm">
<p>
Returns new auth token and account data by a combination of
<strong>
{#if allowUsername && allowEmail}
username/email
{:else if allowUsername}
username
{:else if allowEmail}
email
{/if}
</strong>
and <strong>password</strong>.
</p>
</div>
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${backendAbsUrl}');
...
const authData = await pb.collection('${collection?.name}').authWithPassword(
'${exampleIdentityLabel}',
'YOUR_PASSWORD',
);
// after the above you can also access the auth data from the authStore
console.log(pb.authStore.isValid);
console.log(pb.authStore.token);
console.log(pb.authStore.model.id);
// "logout" the last authenticated account
pb.authStore.clear();
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${backendAbsUrl}');
...
final authData = await pb.collection('${collection?.name}').authWithPassword(
'${exampleIdentityLabel}',
'YOUR_PASSWORD',
);
// after the above you can also access the auth data from the authStore
print(pb.authStore.isValid);
print(pb.authStore.token);
print(pb.authStore.model.id);
// "logout" the last authenticated account
pb.authStore.clear();
`}
/>
<h6 class="m-b-xs">API details</h6>
<div class="alert alert-success">
<strong class="label label-primary">POST</strong>
<div class="content">
<p>
/api/collections/<strong>{collection.name}</strong>/auth-with-password
</p>
</div>
</div>
<div class="section-title">Body Parameters</div>
<table class="table-compact table-border m-b-base">
<thead>
<tr>
<th>Param</th>
<th>Type</th>
<th width="50%">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<div class="inline-flex">
<span class="label label-success">Required</span>
<span>identity</span>
</div>
</td>
<td>
<span class="label">String</span>
</td>
<td>
The
{#if allowUsername}
<strong>username</strong>
{/if}
{#if allowUsername && allowEmail}
or
{/if}
{#if allowEmail}
<strong>email</strong>
{/if}
of the record to authenticate.
</td>
</tr>
<tr>
<td>
<div class="inline-flex">
<span class="label label-success">Required</span>
<span>password</span>
</div>
</td>
<td>
<span class="label">String</span>
</td>
<td>The auth record password.</td>
</tr>
</tbody>
</table>
<div class="section-title">Query parameters</div>
<table class="table-compact table-border m-b-base">
<thead>
<tr>
<th>Param</th>
<th>Type</th>
<th width="60%">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>expand</td>
<td>
<span class="label">String</span>
</td>
<td>
Auto expand record relations. Ex.:
<CodeBlock content={`?expand=relField1,relField2.subRelField`} />
Supports up to 6-levels depth nested relations expansion. <br />
The expanded relations will be appended to the record under the
<code>expand</code> property (eg. <code>{`"expand": {"relField1": {...}, ...}`}</code>).
<br />
Only the relations to which the account has permissions to <strong>view</strong> will be expanded.
</td>
</tr>
</tbody>
</table>
<div class="section-title">Responses</div>
<div class="tabs">
<div class="tabs-header compact left">
{#each responses as response (response.code)}
<button
class="tab-item"
class:active={responseTab === response.code}
on:click={() => (responseTab = response.code)}
>
{response.code}
</button>
{/each}
</div>
<div class="tabs-content">
{#each responses as response (response.code)}
<div class="tab-item" class:active={responseTab === response.code}>
<CodeBlock content={response.body} />
</div>
{/each}
</div>
</div>
@@ -1,111 +0,0 @@
<script>
import { Collection } from "pocketbase";
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
import ListApiDocs from "@/components/collections/docs/ListApiDocs.svelte";
import ViewApiDocs from "@/components/collections/docs/ViewApiDocs.svelte";
import CreateApiDocs from "@/components/collections/docs/CreateApiDocs.svelte";
import UpdateApiDocs from "@/components/collections/docs/UpdateApiDocs.svelte";
import DeleteApiDocs from "@/components/collections/docs/DeleteApiDocs.svelte";
import RealtimeApiDocs from "@/components/collections/docs/RealtimeApiDocs.svelte";
const tabs = [
{
id: "list",
label: "List",
component: ListApiDocs,
},
{
id: "view",
label: "View",
component: ViewApiDocs,
},
{
id: "create",
label: "Create",
component: CreateApiDocs,
},
{
id: "update",
label: "Update",
component: UpdateApiDocs,
},
{
id: "delete",
label: "Delete",
component: DeleteApiDocs,
},
{
id: "realtime",
label: "Realtime",
component: RealtimeApiDocs,
},
];
let collectionPanel;
let collection = new Collection();
let activeTab = tabs[0].id;
export function show(model) {
collection = model;
changeTab(tabs[0].id);
return collectionPanel?.show();
}
export function hide() {
return collectionPanel?.hide();
}
export function changeTab(newTab) {
activeTab = newTab;
}
function changeTabViaKey(e, newTab) {
if (e.code === "Enter" || e.code === "Space") {
e.preventDefault();
changeTab(newTab);
}
}
</script>
<OverlayPanel
bind:this={collectionPanel}
on:hide
on:show
class="overlay-panel-xl colored-header collection-panel"
>
<svelte:fragment slot="header">
<h4><strong>{collection.name}</strong> records API</h4>
<div class="tabs-header stretched">
{#each tabs as tab (tab.id)}
<button
tabindex="0"
class="tab-item"
class:active={activeTab === tab.id}
on:click={() => changeTab(tab.id)}
on:keydown|self={(e) => changeTabViaKey(e, tab.id)}
>
<span class="txt">{tab.label}</span>
</button>
{/each}
</div>
</svelte:fragment>
<div class="tabs-content">
{#each tabs as tab (tab.id)}
{#if activeTab === tab.id}
<div class="tab-item active">
<svelte:component this={tab.component} {collection} />
</div>
{/if}
{/each}
</div>
<svelte:fragment slot="footer">
<button type="button" class="btn btn-secondary" on:click={() => hide()}>
<span class="txt">Close</span>
</button>
</svelte:fragment>
</OverlayPanel>
@@ -0,0 +1,183 @@
<script>
import { Collection } from "pocketbase";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import CodeBlock from "@/components/base/CodeBlock.svelte";
import SdkTabs from "@/components/collections/docs/SdkTabs.svelte";
export let collection = new Collection();
let responseTab = 200;
let responses = [];
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseUrl);
$: responses = [
{
code: 200,
body: JSON.stringify(
{
token: "JWT_TOKEN",
record: CommonHelper.dummyCollectionRecord(collection),
},
null,
2
),
},
{
code: 400,
body: `
{
"code": 400,
"message": "Failed to authenticate.",
"data": {
"token": {
"code": "validation_required",
"message": "Missing required value."
}
}
}
`,
},
];
</script>
<h3 class="m-b-sm">Confirm email change ({collection.name})</h3>
<div class="content txt-lg m-b-sm">
<p>Confirms <strong>{collection.name}</strong> email change request.</p>
<p>Returns the refreshed auth data.</p>
</div>
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${backendAbsUrl}');
...
const authData = await pb.collection('${collection?.name}').confirmEmailChange(
'TOKEN',
'YOUR_PASSWORD',
);
// after the above you can also access the auth data from the authStore
console.log(pb.authStore.isValid);
console.log(pb.authStore.token);
console.log(pb.authStore.model.id);
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${backendAbsUrl}');
...
final authData = await pb.collection('${collection?.name}').confirmEmailChange(
'TOKEN',
'YOUR_PASSWORD',
);
// after the above you can also access the auth data from the authStore
console.log(pb.authStore.isValid);
console.log(pb.authStore.token);
console.log(pb.authStore.model.id);
`}
/>
<h6 class="m-b-xs">API details</h6>
<div class="alert alert-success">
<strong class="label label-primary">POST</strong>
<div class="content">
<p>
/api/collections/<strong>{collection.name}</strong>/confirm-email-change
</p>
</div>
</div>
<div class="section-title">Body Parameters</div>
<table class="table-compact table-border m-b-base">
<thead>
<tr>
<th>Param</th>
<th>Type</th>
<th width="50%">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<div class="inline-flex">
<span class="label label-success">Required</span>
<span>token</span>
</div>
</td>
<td>
<span class="label">String</span>
</td>
<td>The token from the change email request email.</td>
</tr>
<tr>
<td>
<div class="inline-flex">
<span class="label label-success">Required</span>
<span>password</span>
</div>
</td>
<td>
<span class="label">String</span>
</td>
<td>The account password to confirm the email change.</td>
</tr>
</tbody>
</table>
<div class="section-title">Query parameters</div>
<table class="table-compact table-border m-b-base">
<thead>
<tr>
<th>Param</th>
<th>Type</th>
<th width="60%">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>expand</td>
<td>
<span class="label">String</span>
</td>
<td>
Auto expand record relations. Ex.:
<CodeBlock content={`?expand=relField1,relField2.subRelField`} />
Supports up to 6-levels depth nested relations expansion. <br />
The expanded relations will be appended to the record under the
<code>expand</code> property (eg. <code>{`"expand": {"relField1": {...}, ...}`}</code>).
<br />
Only the relations to which the account has permissions to <strong>view</strong> will be expanded.
</td>
</tr>
</tbody>
</table>
<div class="section-title">Responses</div>
<div class="tabs">
<div class="tabs-header compact left">
{#each responses as response (response.code)}
<button
class="tab-item"
class:active={responseTab === response.code}
on:click={() => (responseTab = response.code)}
>
{response.code}
</button>
{/each}
</div>
<div class="tabs-content">
{#each responses as response (response.code)}
<div class="tab-item" class:active={responseTab === response.code}>
<CodeBlock content={response.body} />
</div>
{/each}
</div>
</div>
@@ -0,0 +1,197 @@
<script>
import { Collection } from "pocketbase";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import CodeBlock from "@/components/base/CodeBlock.svelte";
import SdkTabs from "@/components/collections/docs/SdkTabs.svelte";
export let collection = new Collection();
let responseTab = 200;
let responses = [];
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseUrl);
$: responses = [
{
code: 200,
body: JSON.stringify(
{
token: "JWT_TOKEN",
record: CommonHelper.dummyCollectionRecord(collection),
},
null,
2
),
},
{
code: 400,
body: `
{
"code": 400,
"message": "Failed to authenticate.",
"data": {
"token": {
"code": "validation_required",
"message": "Missing required value."
}
}
}
`,
},
];
</script>
<h3 class="m-b-sm">Confirm password reset ({collection.name})</h3>
<div class="content txt-lg m-b-sm">
<p>Confirms <strong>{collection.name}</strong> password reset request.</p>
<p>Returns the refreshed auth data.</p>
</div>
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${backendAbsUrl}');
...
const authData = await pb.collection('${collection?.name}').confirmPasswordReset(
'TOKEN',
'NEW_PASSWORD',
'NEW_PASSWORD_CONFIRM',
);
// after the above you can also access the refreshed auth data from the authStore
console.log(pb.authStore.isValid);
console.log(pb.authStore.token);
console.log(pb.authStore.model.id);
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${backendAbsUrl}');
...
final authData = await pb.collection('${collection?.name}').confirmPasswordReset(
'TOKEN',
'NEW_PASSWORD',
'NEW_PASSWORD_CONFIRM',
);
// after the above you can also access the refreshed auth data from the authStore
console.log(pb.authStore.isValid);
console.log(pb.authStore.token);
console.log(pb.authStore.model.id);
`}
/>
<h6 class="m-b-xs">API details</h6>
<div class="alert alert-success">
<strong class="label label-primary">POST</strong>
<div class="content">
<p>
/api/collections/<strong>{collection.name}</strong>/confirm-password-reset
</p>
</div>
</div>
<div class="section-title">Body Parameters</div>
<table class="table-compact table-border m-b-base">
<thead>
<tr>
<th>Param</th>
<th>Type</th>
<th width="50%">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<div class="inline-flex">
<span class="label label-success">Required</span>
<span>token</span>
</div>
</td>
<td>
<span class="label">String</span>
</td>
<td>The token from the password reset request email.</td>
</tr>
<tr>
<td>
<div class="inline-flex">
<span class="label label-success">Required</span>
<span>password</span>
</div>
</td>
<td>
<span class="label">String</span>
</td>
<td>The new password to set.</td>
</tr>
<tr>
<td>
<div class="inline-flex">
<span class="label label-success">Required</span>
<span>passwordConfirm</span>
</div>
</td>
<td>
<span class="label">String</span>
</td>
<td>The new password confirmation.</td>
</tr>
</tbody>
</table>
<div class="section-title">Query parameters</div>
<table class="table-compact table-border m-b-base">
<thead>
<tr>
<th>Param</th>
<th>Type</th>
<th width="60%">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>expand</td>
<td>
<span class="label">String</span>
</td>
<td>
Auto expand record relations. Ex.:
<CodeBlock content={`?expand=relField1,relField2.subRelField`} />
Supports up to 6-levels depth nested relations expansion. <br />
The expanded relations will be appended to the record under the
<code>expand</code> property (eg. <code>{`"expand": {"relField1": {...}, ...}`}</code>).
<br />
Only the relations to which the account has permissions to <strong>view</strong> will be expanded.
</td>
</tr>
</tbody>
</table>
<div class="section-title">Responses</div>
<div class="tabs">
<div class="tabs-header compact left">
{#each responses as response (response.code)}
<button
class="tab-item"
class:active={responseTab === response.code}
on:click={() => (responseTab = response.code)}
>
{response.code}
</button>
{/each}
</div>
<div class="tabs-content">
{#each responses as response (response.code)}
<div class="tab-item" class:active={responseTab === response.code}>
<CodeBlock content={response.body} />
</div>
{/each}
</div>
</div>
@@ -0,0 +1,165 @@
<script>
import { Collection } from "pocketbase";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import CodeBlock from "@/components/base/CodeBlock.svelte";
import SdkTabs from "@/components/collections/docs/SdkTabs.svelte";
export let collection = new Collection();
let responseTab = 200;
let responses = [];
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseUrl);
$: responses = [
{
code: 200,
body: JSON.stringify(
{
token: "JWT_TOKEN",
record: CommonHelper.dummyCollectionRecord(collection),
},
null,
2
),
},
{
code: 400,
body: `
{
"code": 400,
"message": "Failed to authenticate.",
"data": {
"token": {
"code": "validation_required",
"message": "Missing required value."
}
}
}
`,
},
];
</script>
<h3 class="m-b-sm">Confirm verification ({collection.name})</h3>
<div class="content txt-lg m-b-sm">
<p>Confirms <strong>{collection.name}</strong> account verification request.</p>
<p>Returns the refreshed auth data.</p>
</div>
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${backendAbsUrl}');
...
const authData = await pb.collection('${collection?.name}').confirmVerification('TOKEN');
// after the above you can also access the auth data from the authStore
console.log(pb.authStore.isValid);
console.log(pb.authStore.token);
console.log(pb.authStore.model.id);
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${backendAbsUrl}');
...
final authData = await pb.collection('${collection?.name}').confirmVerification('TOKEN');
// after the above you can also access the auth data from the authStore
console.log(pb.authStore.isValid);
console.log(pb.authStore.token);
console.log(pb.authStore.model.id);
`}
/>
<h6 class="m-b-xs">API details</h6>
<div class="alert alert-success">
<strong class="label label-primary">POST</strong>
<div class="content">
<p>
/api/collections/<strong>{collection.name}</strong>/confirm-verification
</p>
</div>
</div>
<div class="section-title">Body Parameters</div>
<table class="table-compact table-border m-b-base">
<thead>
<tr>
<th>Param</th>
<th>Type</th>
<th width="50%">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<div class="inline-flex">
<span class="label label-success">Required</span>
<span>token</span>
</div>
</td>
<td>
<span class="label">String</span>
</td>
<td>The token from the verification request email.</td>
</tr>
</tbody>
</table>
<div class="section-title">Query parameters</div>
<table class="table-compact table-border m-b-base">
<thead>
<tr>
<th>Param</th>
<th>Type</th>
<th width="60%">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>expand</td>
<td>
<span class="label">String</span>
</td>
<td>
Auto expand record relations. Ex.:
<CodeBlock content={`?expand=relField1,relField2.subRelField`} />
Supports up to 6-levels depth nested relations expansion. <br />
The expanded relations will be appended to the record under the
<code>expand</code> property (eg. <code>{`"expand": {"relField1": {...}, ...}`}</code>).
<br />
Only the relations to which the account has permissions to <strong>view</strong> will be expanded.
</td>
</tr>
</tbody>
</table>
<div class="section-title">Responses</div>
<div class="tabs">
<div class="tabs-header compact left">
{#each responses as response (response.code)}
<button
class="tab-item"
class:active={responseTab === response.code}
on:click={() => (responseTab = response.code)}
>
{response.code}
</button>
{/each}
</div>
<div class="tabs-content">
{#each responses as response (response.code)}
<div class="tab-item" class:active={responseTab === response.code}>
<CodeBlock content={response.body} />
</div>
{/each}
</div>
</div>
@@ -9,6 +9,7 @@
let responseTab = 200;
let responses = [];
let baseData = {};
$: adminsOnly = collection?.createRule === null;
@@ -45,8 +46,66 @@
`,
},
];
$: if (collection.isAuth) {
baseData = {
username: "test_username",
email: "test@exampe.com",
emailVisibility: true,
password: "12345678",
passwordConfirm: "12345678",
};
} else {
baseData = {};
}
</script>
<h3 class="m-b-sm">Create ({collection.name})</h3>
<div class="content txt-lg m-b-sm">
<p>Create a new <strong>{collection.name}</strong> record.</p>
<p>
Body parameters could be sent as <code>application/json</code> or
<code>multipart/form-data</code>.
</p>
<p>
File upload is supported only via <code>multipart/form-data</code>.
<br />
For more info and examples you could check the detailed
<a href={import.meta.env.PB_FILE_UPLOAD_DOCS} target="_blank" rel="noopener noreferrer">
Files upload and handling docs
</a>.
</p>
</div>
<!-- prettier-ignore -->
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${backendAbsUrl}');
...
// example create data
const data = ${JSON.stringify(Object.assign({}, baseData, CommonHelper.dummyCollectionSchemaData(collection)), null, 4)};
const record = await pb.collection('${collection?.name}').create(data);
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${backendAbsUrl}');
...
// example create body
final body = <String, dynamic>${JSON.stringify(Object.assign({}, baseData, CommonHelper.dummyCollectionSchemaData(collection)), null, 2)};
final record = await pb.collection('${collection?.name}').create(body: body);
`}
/>
<h6 class="m-b-xs">API details</h6>
<div class="alert alert-success">
<strong class="label label-primary">POST</strong>
<div class="content">
@@ -55,49 +114,12 @@
</p>
</div>
{#if adminsOnly}
<p class="txt-hint txt-sm txt-right">Requires <code>Authorization: Admin TOKEN</code> header</p>
<p class="txt-hint txt-sm txt-right">Requires admin <code>Authorization:TOKEN</code> header</p>
{/if}
</div>
<div class="content m-b-base">
<p>Create a new <strong>{collection.name}</strong> record.</p>
<p>
Body parameters could be sent as <code>application/json</code> or
<code>multipart/form-data</code>.
</p>
<p>
File upload is supported only via <code>multipart/form-data</code>.
</p>
</div>
<div class="section-title">Client SDKs example</div>
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const client = new PocketBase('${backendAbsUrl}');
...
const data = { ... };
const record = await client.records.create('${collection?.name}', data);
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final client = PocketBase('${backendAbsUrl}');
...
final body = <String, dynamic>{ ... };
final record = await client.records.create('${collection?.name}', body: body);
`}
/>
<div class="section-title">Body Parameters</div>
<table class="table-compact table-border m-b-lg">
<table class="table-compact table-border m-b-base">
<thead>
<tr>
<th>Param</th>
@@ -122,6 +144,100 @@
If not set, it will be auto generated.
</td>
</tr>
{#if collection?.isAuth}
<tr>
<td colspan="3" class="txt-hint">Auth fields</td>
</tr>
<tr>
<td>
<div class="inline-flex">
<span class="label label-warning">Optional</span>
<span>username</span>
</div>
</td>
<td>
<span class="label">String</span>
</td>
<td>
The username of the auth record.
<br />
If not set, it will be auto generated.
</td>
</tr>
<tr>
<td>
<div class="inline-flex">
{#if collection?.options?.requireEmail}
<span class="label label-success">Required</span>
{:else}
<span class="label label-warning">Optional</span>
{/if}
<span>email</span>
</div>
</td>
<td>
<span class="label">String</span>
</td>
<td>Auth record email address.</td>
</tr>
<tr>
<td>
<div class="inline-flex">
<span class="label label-warning">Optional</span>
<span>emailVisibility</span>
</div>
</td>
<td>
<span class="label">Boolean</span>
</td>
<td>Whether to show/hide the auth record email when fetching the record data.</td>
</tr>
<tr>
<td>
<div class="inline-flex">
<span class="label label-success">Required</span>
<span>password</span>
</div>
</td>
<td>
<span class="label">String</span>
</td>
<td>Auth record password.</td>
</tr>
<tr>
<td>
<div class="inline-flex">
<span class="label label-success">Required</span>
<span>passwordConfirm</span>
</div>
</td>
<td>
<span class="label">String</span>
</td>
<td>Auth record password confirmation.</td>
</tr>
<tr>
<td>
<div class="inline-flex">
<span class="label label-warning">Optional</span>
<span>verified</span>
</div>
</td>
<td>
<span class="label">Boolean</span>
</td>
<td>
Indicates whether the auth record is verified or not.
<br />
This field can be set only by admins or auth records with "Manage" access.
</td>
</tr>
<tr>
<td colspan="3" class="txt-hint">Schema fields</td>
</tr>
{/if}
{#each collection?.schema as field (field.name)}
<tr>
<td>
@@ -152,9 +268,7 @@
File object.<br />
Set to <code>null</code> to delete already uploaded file(s).
{:else if field.type === "relation"}
Relation record {field.options?.maxSelect > 1 ? "ids" : "id"}.
{:else if field.type === "user"}
User {field.options?.maxSelect > 1 ? "ids" : "id"}.
Relation record {field.options?.maxSelect === 1 ? "id" : "ids"}.
{/if}
</td>
</tr>
@@ -163,7 +277,7 @@
</table>
<div class="section-title">Query parameters</div>
<table class="table-compact table-border m-b-lg">
<table class="table-compact table-border m-b-base">
<thead>
<tr>
<th>Param</th>
@@ -179,15 +293,12 @@
</td>
<td>
Auto expand relations when returning the created record. Ex.:
<CodeBlock
content={`
?expand=rel1,rel2.subrel21.subrel22
`}
/>
<CodeBlock content={`?expand=relField1,relField2.subRelField`} />
Supports up to 6-levels depth nested relations expansion. <br />
The expanded relations will be appended to the record under the
<code>@expand</code> property (eg. <code>{`"@expand": {"rel1": {...}, ...}`}</code>). Only the
relations that the user has permissions to <strong>view</strong> will be expanded.
<code>expand</code> property (eg. <code>{`"expand": {"relField1": {...}, ...}`}</code>).
<br />
Only the relations to which the account has permissions to <strong>view</strong> will be expanded.
</td>
</tr>
</tbody>
@@ -59,6 +59,33 @@
}
</script>
<h3 class="m-b-sm">Delete ({collection.name})</h3>
<div class="content txt-lg m-b-sm">
<p>Delete a single <strong>{collection.name}</strong> record.</p>
</div>
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${backendAbsUrl}');
...
await pb.collection('${collection?.name}').delete('RECORD_ID');
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${backendAbsUrl}');
...
await pb.collection('${collection?.name}').delete('RECORD_ID');
`}
/>
<h6 class="m-b-xs">API details</h6>
<div class="alert alert-danger">
<strong class="label label-primary">DELETE</strong>
<div class="content">
@@ -67,38 +94,12 @@
</p>
</div>
{#if adminsOnly}
<p class="txt-hint txt-sm txt-right">Requires <code>Authorization: Admin TOKEN</code> header</p>
<p class="txt-hint txt-sm txt-right">Requires admin <code>Authorization:TOKEN</code> header</p>
{/if}
</div>
<div class="content m-b-base">
<p>Delete a single <strong>{collection.name}</strong> record.</p>
</div>
<div class="section-title">Client SDKs example</div>
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const client = new PocketBase('${backendAbsUrl}');
...
await client.records.delete('${collection?.name}', 'RECORD_ID');
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final client = PocketBase('${backendAbsUrl}');
...
await client.records.delete('${collection?.name}', 'RECORD_ID');
`}
/>
<div class="section-title">Path parameters</div>
<table class="table-compact table-border m-b-lg">
<table class="table-compact table-border m-b-base">
<thead>
<tr>
<th>Param</th>
@@ -1,66 +1,86 @@
<p>
The syntax basically follows the format
<code>
<span class="txt-success">OPERAND</span>
<span class="txt-danger">OPERATOR</span>
<span class="txt-success">OPERAND</span></code
>, where:
</p>
<ul>
<li>
<code class="txt-success">OPERAND</code> - could be any of the above field literal, string (single or double
quoted), number, null, true, false
</li>
<li>
<code class="txt-danger">OPERATOR</code> - is one of:
<br />
<ul>
<li>
<code class="filter-op">{"="}</code>
<span class="txt-hint">Equal</span>
</li>
<li>
<code class="filter-op">{"!="}</code>
<span class="txt-hint">NOT equal</span>
</li>
<li>
<code class="filter-op">{">"}</code>
<span class="txt-hint">Greater than</span>
</li>
<li>
<code class="filter-op">{">="}</code>
<span class="txt-hint">Greater than or equal</span>
</li>
<li>
<code class="filter-op">{"<"}</code>
<span class="txt-hint">Less than or equal</span>
</li>
<li>
<code class="filter-op">{"<="}</code>
<span class="txt-hint">Less than or equal</span>
</li>
<li>
<code class="filter-op">{"~"}</code>
<span class="txt-hint">
Like/Contains (if not specified auto wraps the right string OPERAND in a "%" for wildcard
match)
</span>
</li>
<li>
<code class="filter-op">{"!~"}</code>
<span class="txt-hint">
NOT Like/Contains (if not specified auto wraps the right string OPERAND in a "%" for
wildcard match)
</span>
</li>
</ul>
</li>
</ul>
<script>
let expanded = false;
<p>
To group and combine several expressions you could use brackets
<code>(...)</code>, <code>&&</code> (AND) and <code>||</code> (OR) tokens.
</p>
function toggle() {
expanded = !expanded;
}
</script>
<button class="btn btn-sm btn-secondary m-t-5" on:click={toggle}>
{#if expanded}
<span class="txt">Hide details</span>
<i class="ri-arrow-up-s-line" />
{:else}
<span class="txt">Show details</span>
<i class="ri-arrow-down-s-line" />
{/if}
</button>
{#if expanded}
<p>
The syntax basically follows the format
<code>
<span class="txt-success">OPERAND</span>
<span class="txt-danger">OPERATOR</span>
<span class="txt-success">OPERAND</span></code
>, where:
</p>
<ul>
<li>
<code class="txt-success">OPERAND</code> - could be any of the above field literal, string (single
or double quoted), number, null, true, false
</li>
<li>
<code class="txt-danger">OPERATOR</code> - is one of:
<br />
<ul>
<li>
<code class="filter-op">{"="}</code>
<span class="txt-hint">Equal</span>
</li>
<li>
<code class="filter-op">{"!="}</code>
<span class="txt-hint">NOT equal</span>
</li>
<li>
<code class="filter-op">{">"}</code>
<span class="txt-hint">Greater than</span>
</li>
<li>
<code class="filter-op">{">="}</code>
<span class="txt-hint">Greater than or equal</span>
</li>
<li>
<code class="filter-op">{"<"}</code>
<span class="txt-hint">Less than or equal</span>
</li>
<li>
<code class="filter-op">{"<="}</code>
<span class="txt-hint">Less than or equal</span>
</li>
<li>
<code class="filter-op">{"~"}</code>
<span class="txt-hint">
Like/Contains (if not specified auto wraps the right string OPERAND in a "%" for
wildcard match)
</span>
</li>
<li>
<code class="filter-op">{"!~"}</code>
<span class="txt-hint">
NOT Like/Contains (if not specified auto wraps the right string OPERAND in a "%" for
wildcard match)
</span>
</li>
</ul>
</li>
</ul>
<p>
To group and combine several expressions you could use brackets
<code>(...)</code>, <code>&&</code> (AND) and <code>||</code> (OR) tokens.
</p>
{/if}
<style>
.filter-op {
@@ -22,6 +22,7 @@
{
page: 1,
perPage: 30,
totalPages: 1,
totalItems: 2,
items: [
CommonHelper.dummyCollectionRecord(collection),
@@ -70,6 +71,65 @@
}
</script>
<h3 class="m-b-sm">List/Search ({collection.name})</h3>
<div class="content txt-lg m-b-sm">
<p>
Fetch a paginated <strong>{collection.name}</strong> records list, supporting sorting and filtering.
</p>
</div>
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${backendAbsUrl}');
...
// fetch a paginated records list
const resultList = await pb.collection('${collection?.name}').getList(1, 50, {
filter: 'created >= "2022-01-01 00:00:00" && someFiled1 != someField2',
});
// you can also fetch all records at once via getFullList:
const records = await pb.collection('${collection?.name}').getFullList(200 /* batch size */, {
sort: '-created'
});
// or fetch only the first record that matches the specified filter
const record = await pb.collection('${collection?.name}').getFirstListItem('someField="test"', {
expand: 'relField1,relField2.subRelField',
});
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${backendAbsUrl}');
...
// fetch a paginated records list
final result = await pb.collection('${collection?.name}').getList(
page: 1,
perPage: 50,
filter: 'created >= "2022-01-01 00:00:00" && someFiled1 != someField2',
);
// alternatively you can also fetch all records at once via getFullList:
final records = await pb.collection('${collection?.name}').getFullList(
batch: 200,
sort: '-created',
);
// or fetch only the first record that matches the specified filter
final record2 = await pb.collection('${collection?.name}').getFirstListItem(
'someField="test"',
expand: 'relField1,relField2.subRelField',
);
`}
/>
<h6 class="m-b-xs">API details</h6>
<div class="alert alert-info">
<strong class="label label-primary">GET</strong>
<div class="content">
@@ -78,55 +138,12 @@
</p>
</div>
{#if adminsOnly}
<p class="txt-hint txt-sm txt-right">Requires <code>Authorization: Admin TOKEN</code> header</p>
<p class="txt-hint txt-sm txt-right">Requires admin <code>Authorization:TOKEN</code> header</p>
{/if}
</div>
<div class="content m-b-base">
<p>Fetch a paginated <strong>{collection.name}</strong> records list.</p>
</div>
<div class="section-title">Client SDKs example</div>
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const client = new PocketBase('${backendAbsUrl}');
...
// fetch a paginated records list
const resultList = await client.records.getList('${collection?.name}', 1, 50, {
filter: 'created >= "2022-01-01 00:00:00"',
});
// alternatively you can also fetch all records at once via getFullList:
const records = await client.records.getFullList('${collection?.name}', 200 /* batch size */, {
sort: '-created',
});
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final client = PocketBase('${backendAbsUrl}');
...
// fetch a paginated records list
final result = await client.records.getList(
'${collection?.name}',
page: 1,
perPage: 50,
filter: 'created >= "2022-01-01 00:00:00"',
);
// alternatively you can also fetch all records at once via getFullList:
final records = await client.records.getFullList('${collection?.name}', batch: 200, sort: '-created');
`}
/>
<div class="section-title">Query parameters</div>
<table class="table-compact table-border m-b-lg">
<table class="table-compact table-border m-b-base">
<thead>
<tr>
<th>Param</th>
@@ -188,15 +205,12 @@
</td>
<td>
Auto expand record relations. Ex.:
<CodeBlock
content={`
?expand=rel1,rel2.subrel21.subrel22
`}
/>
<CodeBlock content={`?expand=relField1,relField2.subRelField`} />
Supports up to 6-levels depth nested relations expansion. <br />
The expanded relations will be appended to each individual record under the
<code>@expand</code> property (eg. <code>{`"@expand": {"rel1": {...}, ...}`}</code>). Only the
relations that the user has permissions to <strong>view</strong> will be expanded.
<code>expand</code> property (eg. <code>{`"expand": {"relField1": {...}, ...}`}</code>).
<br />
Only the relations to which the account has permissions to <strong>view</strong> will be expanded.
</td>
</tr>
</tbody>
@@ -0,0 +1,162 @@
<script>
import { Collection } from "pocketbase";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import CodeBlock from "@/components/base/CodeBlock.svelte";
import SdkTabs from "@/components/collections/docs/SdkTabs.svelte";
export let collection = new Collection();
let responseTab = 200;
let responses = [];
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseUrl);
$: responses = [
{
code: 200,
body: `
[
{
"id": "8171022dc95a4e8",
"created": "2022-09-01 10:24:18.434",
"updated": "2022-09-01 10:24:18.889",
"recordId": "e22581b6f1d44ea",
"collectionId": "${collection.id}",
"provider": "google",
"providerId": "2da15468800514p",
},
{
"id": "171022dc895a4e8",
"created": "2022-09-01 10:24:18.434",
"updated": "2022-09-01 10:24:18.889",
"recordId": "e22581b6f1d44ea",
"collectionId": "${collection.id}",
"provider": "twitter",
"providerId": "720688005140514",
}
]
`,
},
{
code: 401,
body: `
{
"code": 401,
"message": "The request requires valid record authorization token to be set.",
"data": {}
}
`,
},
{
code: 403,
body: `
{
"code": 403,
"message": "The authorized record model is not allowed to perform this action.",
"data": {}
}
`,
},
{
code: 404,
body: `
{
"code": 404,
"message": "The requested resource wasn't found.",
"data": {}
}
`,
},
];
</script>
<h3 class="m-b-sm">List OAuth2 accounts ({collection.name})</h3>
<div class="content txt-lg m-b-sm">
<p>
Returns a list with all OAuth2 providers linked to a single <strong>{collection.name}</strong>.
</p>
<p>Only admins and the account owner can access this action.</p>
</div>
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${backendAbsUrl}');
...
await pb.collection('${collection?.name}').authViaEmail('test@example.com', '123456');
const result = await pb.collection('${collection?.name}').listExternalAuths(
pb.authStore.model.id
);
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${backendAbsUrl}');
...
await pb.collection('${collection?.name}').authViaEmail('test@example.com', '123456');
final result = await pb.collection('${collection?.name}').listExternalAuths(
pb.authStore.model.id,
);
`}
/>
<h6 class="m-b-xs">API details</h6>
<div class="alert alert-info">
<strong class="label label-primary">GET</strong>
<div class="content">
<p>
/api/collections/<strong>{collection.name}</strong>/records/<strong>:id</strong>/external-auths
</p>
</div>
<p class="txt-hint txt-sm txt-right">Requires <code>Authorization:TOKEN</code> header</p>
</div>
<div class="section-title">Path Parameters</div>
<table class="table-compact table-border m-b-base">
<thead>
<tr>
<th>Param</th>
<th>Type</th>
<th width="60%">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>id</td>
<td>
<span class="label">String</span>
</td>
<td>ID of the auth record.</td>
</tr>
</tbody>
</table>
<div class="section-title">Responses</div>
<div class="tabs">
<div class="tabs-header compact left">
{#each responses as response (response.code)}
<button
class="tab-item"
class:active={responseTab === response.code}
on:click={() => (responseTab = response.code)}
>
{response.code}
</button>
{/each}
</div>
<div class="tabs-content">
{#each responses as response (response.code)}
<div class="tab-item" class:active={responseTab === response.code}>
<CodeBlock content={response.body} />
</div>
{/each}
</div>
</div>
@@ -10,6 +10,87 @@
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseUrl);
</script>
<h3 class="m-b-sm">Realtime ({collection.name})</h3>
<div class="content txt-lg m-b-sm">
<p>Subscribe to realtime changes via Server-Sent Events (SSE).</p>
<p>
Events are sent for <strong>create</strong>, <strong>update</strong>
and <strong>delete</strong> record operations (see "Event data format" section below).
</p>
</div>
<div class="alert alert-info m-t-10 m-b-sm">
<div class="icon">
<i class="ri-information-line" />
</div>
<div class="contet">
<p>
<strong>You could subscribe to a single record or to an entire collection.</strong>
</p>
<p>
When you subscribe to a <strong>single record</strong>, the collection's
<strong>ViewRule</strong> will be used to determine whether the subscriber has access to receive the
event message.
</p>
<p>
When you subscribe to an <strong>entire collection</strong>, the collection's
<strong>ListRule</strong> will be used to determine whether the subscriber has access to receive the
event message.
</p>
</div>
</div>
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${backendAbsUrl}');
...
// (Optionally) authenticate
await pb.collection('users').authWithPassword('test@example.com', '123456');
// Subscribe to changes in any record from the collection
pb.collection('${collection?.name}').subscribe(function (e) {
console.log(e.record);
});
// Subscribe to changes in a single record
pb.collection('${collection?.name}').subscribeOne('RECORD_ID', function (e) {
console.log(e.record);
});
// Unsubscribe
pb.collection('${collection?.name}').unsubscribe() // remove all collection subscriptions
pb.collection('${collection?.name}').unsubscribe('RECORD_ID') // remove only the record subscription
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${backendAbsUrl}');
...
// (Optionally) authenticate
await pb.collection('users').authWithPassword('test@example.com', '123456');
// Subscribe to changes in any record from the collection
pb.collection('${collection?.name}').subscribe((e) {
print(e.record);
});
// Subscribe to changes in a single record
pb.collection('${collection?.name}').subscribeOne('RECORD_ID', (e) {
print(e.record);
});
// Unsubscribe
pb.collection('${collection?.name}').unsubscribe() // remove all collection subscriptions
pb.collection('${collection?.name}').unsubscribe('RECORD_ID') // remove only the record subscription
`}
/>
<h6 class="m-b-xs">API details</h6>
<div class="alert">
<strong class="label label-primary">SSE</strong>
<div class="content">
@@ -17,88 +98,6 @@
</div>
</div>
<div class="content m-b-base">
<p>Subscribe to realtime changes via Server-Sent Events (SSE).</p>
<p>
Events are sent for <strong>create</strong>, <strong>update</strong>
and <strong>delete</strong> record operations (see "Event data format" section below).
</p>
<div class="alert alert-info m-t-10">
<div class="icon">
<i class="ri-information-line" />
</div>
<div class="contet">
<p>
<strong>You could subscribe to a single record or to an entire collection.</strong>
</p>
<p>
When you subscribe to a <strong>single record</strong>, the collection's
<strong>ViewRule</strong> will be used to determine whether the subscriber has access to receive
the event message.
</p>
<p>
When you subscribe to an <strong>entire collection</strong>, the collection's
<strong>ListRule</strong> will be used to determine whether the subscriber has access to receive
the event message.
</p>
</div>
</div>
</div>
<div class="section-title">Client SDKs example</div>
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const client = new PocketBase('${backendAbsUrl}');
...
// (Optionally) authenticate
client.users.authViaEmail('test@example.com', '123456');
// Subscribe to changes in any record from the collection
client.realtime.subscribe('${collection?.name}', function (e) {
console.log(e.record);
});
// Subscribe to changes in a single record
client.realtime.subscribe('${collection?.name}/RECORD_ID', function (e) {
console.log(e.record);
});
// Unsubscribe
client.realtime.unsubscribe() // remove all subscriptions
client.realtime.unsubscribe('${collection?.name}') // remove only the collection subscription
client.realtime.unsubscribe('${collection?.name}/RECORD_ID') // remove only the record subscription
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final client = PocketBase('${backendAbsUrl}');
...
// (Optionally) authenticate
client.users.authViaEmail('test@example.com', '123456');
// Subscribe to changes in any record from the collection
client.realtime.subscribe('${collection?.name}', (e) {
print(e.record);
});
// Subscribe to changes in a single record
client.realtime.subscribe('${collection?.name}/RECORD_ID', (e) {
print(e.record);
});
// Unsubscribe
client.realtime.unsubscribe() // remove all subscriptions
client.realtime.unsubscribe('${collection?.name}') // remove only the collection subscription
client.realtime.unsubscribe('${collection?.name}/RECORD_ID') // remove only the record subscription
`}
/>
<div class="section-title">Event data format</div>
<CodeBlock
content={JSON.stringify(
@@ -0,0 +1,144 @@
<script>
import { Collection } from "pocketbase";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import CodeBlock from "@/components/base/CodeBlock.svelte";
import SdkTabs from "@/components/collections/docs/SdkTabs.svelte";
export let collection = new Collection();
let responseTab = 204;
let responses = [];
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseUrl);
$: responses = [
{
code: 204,
body: "null",
},
{
code: 400,
body: `
{
"code": 400,
"message": "Failed to authenticate.",
"data": {
"newEmail": {
"code": "validation_required",
"message": "Missing required value."
}
}
}
`,
},
{
code: 401,
body: `
{
"code": 401,
"message": "The request requires valid record authorization token to be set.",
"data": {}
}
`,
},
{
code: 403,
body: `
{
"code": 403,
"message": "The authorized record model is not allowed to perform this action.",
"data": {}
}
`,
},
];
</script>
<h3 class="m-b-sm">Request email change ({collection.name})</h3>
<div class="content txt-lg m-b-sm">
<p>Sends <strong>{collection.name}</strong> email change request.</p>
</div>
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${backendAbsUrl}');
...
await pb.collection('${collection?.name}').authViaEmail('test@example.com', '123456');
await pb.collection('${collection?.name}').requestEmailChange('new@example.com');
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${backendAbsUrl}');
...
await pb.collection('${collection?.name}').authViaEmail('test@example.com', '123456');
await pb.collection('${collection?.name}').requestEmailChange('new@example.com');
`}
/>
<h6 class="m-b-xs">API details</h6>
<div class="alert alert-success">
<strong class="label label-primary">POST</strong>
<div class="content">
<p>
/api/collections/<strong>{collection.name}</strong>/confirm-email-change
</p>
</div>
<p class="txt-hint txt-sm txt-right">Requires record <code>Authorization:TOKEN</code> header</p>
</div>
<div class="section-title">Body Parameters</div>
<table class="table-compact table-border m-b-base">
<thead>
<tr>
<th>Param</th>
<th>Type</th>
<th width="50%">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<div class="inline-flex">
<span class="label label-success">Required</span>
<span>newEmail</span>
</div>
</td>
<td>
<span class="label">String</span>
</td>
<td>The new email address to send the change email request.</td>
</tr>
</tbody>
</table>
<div class="section-title">Responses</div>
<div class="tabs">
<div class="tabs-header compact left">
{#each responses as response (response.code)}
<button
class="tab-item"
class:active={responseTab === response.code}
on:click={() => (responseTab = response.code)}
>
{response.code}
</button>
{/each}
</div>
<div class="tabs-content">
{#each responses as response (response.code)}
<div class="tab-item" class:active={responseTab === response.code}>
<CodeBlock content={response.body} />
</div>
{/each}
</div>
</div>
@@ -0,0 +1,119 @@
<script>
import { Collection } from "pocketbase";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import CodeBlock from "@/components/base/CodeBlock.svelte";
import SdkTabs from "@/components/collections/docs/SdkTabs.svelte";
export let collection = new Collection();
let responseTab = 204;
let responses = [];
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseUrl);
$: responses = [
{
code: 204,
body: "null",
},
{
code: 400,
body: `
{
"code": 400,
"message": "Failed to authenticate.",
"data": {
"email": {
"code": "validation_required",
"message": "Missing required value."
}
}
}
`,
},
];
</script>
<h3 class="m-b-sm">Request password reset ({collection.name})</h3>
<div class="content txt-lg m-b-sm">
<p>Sends <strong>{collection.name}</strong> password reset email request.</p>
</div>
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${backendAbsUrl}');
...
await pb.collection('${collection?.name}').requestPasswordReset('test@example.com');
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${backendAbsUrl}');
...
await pb.collection('${collection?.name}').requestPasswordReset('test@example.com');
`}
/>
<h6 class="m-b-xs">API details</h6>
<div class="alert alert-success">
<strong class="label label-primary">POST</strong>
<div class="content">
<p>
/api/collections/<strong>{collection.name}</strong>/request-password-reset
</p>
</div>
</div>
<div class="section-title">Body Parameters</div>
<table class="table-compact table-border m-b-base">
<thead>
<tr>
<th>Param</th>
<th>Type</th>
<th width="50%">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<div class="inline-flex">
<span class="label label-success">Required</span>
<span>email</span>
</div>
</td>
<td>
<span class="label">String</span>
</td>
<td>The auth record email address to send the password reset request (if exists).</td>
</tr>
</tbody>
</table>
<div class="section-title">Responses</div>
<div class="tabs">
<div class="tabs-header compact left">
{#each responses as response (response.code)}
<button
class="tab-item"
class:active={responseTab === response.code}
on:click={() => (responseTab = response.code)}
>
{response.code}
</button>
{/each}
</div>
<div class="tabs-content">
{#each responses as response (response.code)}
<div class="tab-item" class:active={responseTab === response.code}>
<CodeBlock content={response.body} />
</div>
{/each}
</div>
</div>
@@ -0,0 +1,119 @@
<script>
import { Collection } from "pocketbase";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import CodeBlock from "@/components/base/CodeBlock.svelte";
import SdkTabs from "@/components/collections/docs/SdkTabs.svelte";
export let collection = new Collection();
let responseTab = 204;
let responses = [];
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseUrl);
$: responses = [
{
code: 204,
body: "null",
},
{
code: 400,
body: `
{
"code": 400,
"message": "Failed to authenticate.",
"data": {
"email": {
"code": "validation_required",
"message": "Missing required value."
}
}
}
`,
},
];
</script>
<h3 class="m-b-sm">Request verification ({collection.name})</h3>
<div class="content txt-lg m-b-sm">
<p>Sends <strong>{collection.name}</strong> verification email request.</p>
</div>
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${backendAbsUrl}');
...
await pb.collection('${collection?.name}').requestVerification('test@example.com');
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${backendAbsUrl}');
...
await pb.collection('${collection?.name}').requestVerification('test@example.com');
`}
/>
<h6 class="m-b-xs">API details</h6>
<div class="alert alert-success">
<strong class="label label-primary">POST</strong>
<div class="content">
<p>
/api/collections/<strong>{collection.name}</strong>/request-password-reset
</p>
</div>
</div>
<div class="section-title">Body Parameters</div>
<table class="table-compact table-border m-b-base">
<thead>
<tr>
<th>Param</th>
<th>Type</th>
<th width="50%">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<div class="inline-flex">
<span class="label label-success">Required</span>
<span>email</span>
</div>
</td>
<td>
<span class="label">String</span>
</td>
<td>The auth record email address to send the verification request (if exists).</td>
</tr>
</tbody>
</table>
<div class="section-title">Responses</div>
<div class="tabs">
<div class="tabs-header compact left">
{#each responses as response (response.code)}
<button
class="tab-item"
class:active={responseTab === response.code}
on:click={() => (responseTab = response.code)}
>
{response.code}
</button>
{/each}
</div>
<div class="tabs-content">
{#each responses as response (response.code)}
<div class="tab-item" class:active={responseTab === response.code}>
<CodeBlock content={response.body} />
</div>
{/each}
</div>
</div>
@@ -18,16 +18,18 @@
title: "JavaScript",
language: "javascript",
content: js,
url: import.meta.env.PB_JS_SDK_URL,
},
{
title: "Dart",
language: "dart",
content: dart,
url: import.meta.env.PB_DART_SDK_URL,
},
];
</script>
<div class="tabs sdk-tabs m-b-lg">
<div class="tabs sdk-tabs m-b-base">
<div class="tabs-header compact left">
{#each sdkExamples as example (example.language)}
<button
@@ -43,6 +45,13 @@
{#each sdkExamples as example (example.language)}
<div class="tab-item" class:active={activeTab === example.language}>
<CodeBlock language={example.language} content={example.content} />
<div class="txt-right">
<em class="txt-sm txt-hint">
<a href={example.url} target="_blank" rel="noopener noreferrer">
{example.title} SDK
</a>
</em>
</div>
</div>
{/each}
</div>
@@ -0,0 +1,154 @@
<script>
import { Collection } from "pocketbase";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import CodeBlock from "@/components/base/CodeBlock.svelte";
import SdkTabs from "@/components/collections/docs/SdkTabs.svelte";
export let collection = new Collection();
let responseTab = 204;
let responses = [];
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseUrl);
$: responses = [
{
code: 204,
body: "null",
},
{
code: 401,
body: `
{
"code": 401,
"message": "The request requires valid record authorization token to be set.",
"data": {}
}
`,
},
{
code: 403,
body: `
{
"code": 403,
"message": "The authorized record model is not allowed to perform this action.",
"data": {}
}
`,
},
{
code: 404,
body: `
{
"code": 404,
"message": "The requested resource wasn't found.",
"data": {}
}
`,
},
];
</script>
<h3 class="m-b-sm">Unlink OAuth2 account ({collection.name})</h3>
<div class="content txt-lg m-b-sm">
<p>
Unlink a single external OAuth2 provider from <strong>{collection.name}</strong> record.
</p>
<p>Only admins and the account owner can access this action.</p>
</div>
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${backendAbsUrl}');
...
await pb.collection('${collection?.name}').authViaEmail('test@example.com', '123456');
await pb.collection('${collection?.name}').unlinkExternalAuth(
pb.authStore.model.id,
'google'
);
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${backendAbsUrl}');
...
await pb.collection('${collection?.name}').authViaEmail('test@example.com', '123456');
await pb.collection('${collection?.name}').unlinkExternalAuth(
pb.authStore.model.id,
'google',
);
`}
/>
<h6 class="m-b-xs">API details</h6>
<div class="alert alert-danger">
<strong class="label label-primary">DELETE</strong>
<div class="content">
<p>
/api/collections/<strong>{collection.name}</strong>/records/<strong>:id</strong
>/external-auths/<strong>:provider</strong>
</p>
</div>
<p class="txt-hint txt-sm txt-right">Requires <code>Authorization:TOKEN</code> header</p>
</div>
<div class="section-title">Path Parameters</div>
<table class="table-compact table-border m-b-base">
<thead>
<tr>
<th>Param</th>
<th>Type</th>
<th width="60%">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>id</td>
<td>
<span class="label">String</span>
</td>
<td>ID of the auth record.</td>
</tr>
<tr>
<td>provider</td>
<td>
<span class="label">String</span>
</td>
<td>
The name of the auth provider to unlink, eg. <code>google</code>, <code>twitter</code>,
<code>github</code>, etc.
</td>
</tr>
</tbody>
</table>
<div class="section-title">Responses</div>
<div class="tabs">
<div class="tabs-header compact left">
{#each responses as response (response.code)}
<button
class="tab-item"
class:active={responseTab === response.code}
on:click={() => (responseTab = response.code)}
>
{response.code}
</button>
{/each}
</div>
<div class="tabs-content">
{#each responses as response (response.code)}
<div class="tab-item" class:active={responseTab === response.code}>
<CodeBlock content={response.body} />
</div>
{/each}
</div>
</div>
@@ -9,6 +9,7 @@
let responseTab = 200;
let responses = [];
let baseData = {};
$: adminsOnly = collection?.updateRule === null;
@@ -55,8 +56,66 @@
`,
},
];
$: if (collection.isAuth) {
baseData = {
username: "test_username_update",
emailVisibility: false,
password: "87654321",
passwordConfirm: "87654321",
oldPassword: "12345678",
};
} else {
baseData = {};
}
</script>
<h3 class="m-b-sm">Update ({collection.name})</h3>
<div class="content txt-lg m-b-sm">
<p>Update a single <strong>{collection.name}</strong> record.</p>
<p>
Body parameters could be sent as <code>application/json</code> or
<code>multipart/form-data</code>.
</p>
<p>
File upload is supported only via <code>multipart/form-data</code>.
<br />
For more info and examples you could check the detailed
<a href={import.meta.env.PB_FILE_UPLOAD_DOCS} target="_blank" rel="noopener noreferrer">
Files upload and handling docs
</a>.
</p>
</div>
<!-- prettier-ignore -->
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${backendAbsUrl}');
...
// example update data
const data = ${JSON.stringify(Object.assign({}, baseData, CommonHelper.dummyCollectionSchemaData(collection)), null, 4)};
const record = await pb.collection('${collection?.name}').update('RECORD_ID', data);
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${backendAbsUrl}');
...
// example update body
final body = <String, dynamic>${JSON.stringify(Object.assign({}, baseData, CommonHelper.dummyCollectionSchemaData(collection)), null, 2)};
final record = await pb.collection('${collection?.name}').update('RECORD_ID', body: body);
`}
/>
<h6 class="m-b-xs">API details</h6>
<div class="alert alert-warning">
<strong class="label label-primary">PATCH</strong>
<div class="content">
@@ -65,49 +124,12 @@
</p>
</div>
{#if adminsOnly}
<p class="txt-hint txt-sm txt-right">Requires <code>Authorization: Admin TOKEN</code> header</p>
<p class="txt-hint txt-sm txt-right">Requires admin <code>Authorization:TOKEN</code> header</p>
{/if}
</div>
<div class="content m-b-base">
<p>Update a single <strong>{collection.name}</strong> record.</p>
<p>
Body parameters could be sent as <code>application/json</code> or
<code>multipart/form-data</code>.
</p>
<p>
File upload is supported only via <code>multipart/form-data</code>.
</p>
</div>
<div class="section-title">Client SDKs example</div>
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const client = new PocketBase('${backendAbsUrl}');
...
const data = { ... };
const record = await client.records.update('${collection?.name}', 'RECORD_ID', data);
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final client = PocketBase('${backendAbsUrl}');
...
final body = <String, dynamic>{ ... };
final record = await client.records.update('${collection?.name}', 'RECORD_ID', body: body);
`}
/>
<div class="section-title">Path parameters</div>
<table class="table-compact table-border m-b-lg">
<table class="table-compact table-border m-b-base">
<thead>
<tr>
<th>Param</th>
@@ -127,7 +149,7 @@
</table>
<div class="section-title">Body Parameters</div>
<table class="table-compact table-border m-b-lg">
<table class="table-compact table-border m-b-base">
<thead>
<tr>
<th>Param</th>
@@ -136,6 +158,114 @@
</tr>
</thead>
<tbody>
{#if collection?.isAuth}
<tr>
<td colspan="3" class="txt-hint">Auth fields</td>
</tr>
<tr>
<td>
<div class="inline-flex">
<span class="label label-warning">Optional</span>
<span>username</span>
</div>
</td>
<td>
<span class="label">String</span>
</td>
<td>The username of the auth record.</td>
</tr>
<tr>
<td>
<div class="inline-flex">
<span class="label label-warning">Optional</span>
<span>email</span>
</div>
</td>
<td>
<span class="label">String</span>
</td>
<td>
The auth record email address.
<br />
This field can be updated only by admins or auth records with "Manage" access.
<br />
Regular accounts can update their email by calling "Request email change".
</td>
</tr>
<tr>
<td>
<div class="inline-flex">
<span class="label label-warning">Optional</span>
<span>emailVisibility</span>
</div>
</td>
<td>
<span class="label">Boolean</span>
</td>
<td>Whether to show/hide the auth record email when fetching the record data.</td>
</tr>
<tr>
<td>
<div class="inline-flex">
<span class="label label-warning">Optional</span>
<span>oldPassword</span>
</div>
</td>
<td>
<span class="label">String</span>
</td>
<td>
Old auth record password.
<br />
This field is required only when changing the record password. Admins and auth records with
"Manage" access can skip this field.
</td>
</tr>
<tr>
<td>
<div class="inline-flex">
<span class="label label-warning">Optional</span>
<span>password</span>
</div>
</td>
<td>
<span class="label">String</span>
</td>
<td>New auth record password.</td>
</tr>
<tr>
<td>
<div class="inline-flex">
<span class="label label-warning">Optional</span>
<span>passwordConfirm</span>
</div>
</td>
<td>
<span class="label">String</span>
</td>
<td>New auth record password confirmation.</td>
</tr>
<tr>
<td>
<div class="inline-flex">
<span class="label label-warning">Optional</span>
<span>verified</span>
</div>
</td>
<td>
<span class="label">Boolean</span>
</td>
<td>
Indicates whether the auth record is verified or not.
<br />
This field can be set only by admins or auth records with "Manage" access.
</td>
</tr>
<tr>
<td colspan="3" class="txt-hint">Schema fields</td>
</tr>
{/if}
{#each collection?.schema as field (field.name)}
<tr>
<td>
@@ -193,15 +323,11 @@
</td>
<td>
Auto expand relations when returning the updated record. Ex.:
<CodeBlock
content={`
?expand=rel1,rel2.subrel21.subrel22
`}
/>
<CodeBlock content={`?expand=relField1,relField2.subRelField21`} />
Supports up to 6-levels depth nested relations expansion. <br />
The expanded relations will be appended to the record under the
<code>@expand</code> property (eg. <code>{`"@expand": {"rel1": {...}, ...}`}</code>). Only the
relations that the user has permissions to <strong>view</strong> will be expanded.
<code>expand</code> property (eg. <code>{`"expand": {"relField1": {...}, ...}`}</code>). Only
the relations that the user has permissions to <strong>view</strong> will be expanded.
</td>
</tr>
</tbody>
@@ -46,6 +46,37 @@
}
</script>
<h3 class="m-b-sm">View ({collection.name})</h3>
<div class="content txt-lg m-b-sm">
<p>Fetch a single <strong>{collection.name}</strong> record.</p>
</div>
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${backendAbsUrl}');
...
const record1 = await pb.collection('${collection?.name}').getOne('RECORD_ID', {
expand: 'relField1,relField2.subRelField',
});
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${backendAbsUrl}');
...
final record1 = await pb.collection('${collection?.name}').getOne('RECORD_ID',
'expand': 'relField1,relField2.subRelField',
);
`}
/>
<h6 class="m-b-xs">API details</h6>
<div class="alert alert-info">
<strong class="label label-primary">GET</strong>
<div class="content">
@@ -54,42 +85,12 @@
</p>
</div>
{#if adminsOnly}
<p class="txt-hint txt-sm txt-right">Requires <code>Authorization: Admin TOKEN</code> header</p>
<p class="txt-hint txt-sm txt-right">Requires admin <code>Authorization:TOKEN</code> header</p>
{/if}
</div>
<div class="content m-b-base">
<p>Fetch a single <strong>{collection.name}</strong> record.</p>
</div>
<div class="section-title">Client SDKs example</div>
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const client = new PocketBase('${backendAbsUrl}');
...
const record = await client.records.getOne('${collection?.name}', 'RECORD_ID', {
expand: 'some_relation'
});
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final client = PocketBase('${backendAbsUrl}');
...
final record = await client.records.getOne('${collection?.name}', 'RECORD_ID', query: {
'expand': 'some_relation',
});
`}
/>
<div class="section-title">Path Parameters</div>
<table class="table-compact table-border m-b-lg">
<table class="table-compact table-border m-b-base">
<thead>
<tr>
<th>Param</th>
@@ -109,7 +110,7 @@
</table>
<div class="section-title">Query parameters</div>
<table class="table-compact table-border m-b-lg">
<table class="table-compact table-border m-b-base">
<thead>
<tr>
<th>Param</th>
@@ -125,15 +126,12 @@
</td>
<td>
Auto expand record relations. Ex.:
<CodeBlock
content={`
?expand=rel1,rel2.subrel21.subrel22
`}
/>
<CodeBlock content={`?expand=relField1,relField2.subRelField`} />
Supports up to 6-levels depth nested relations expansion. <br />
The expanded relations will be appended to the record under the
<code>@expand</code> property (eg. <code>{`"@expand": {"rel1": {...}, ...}`}</code>). Only the
relations that the user has permissions to <strong>view</strong> will be expanded.
<code>expand</code> property (eg. <code>{`"expand": {"relField1": {...}, ...}`}</code>).
<br />
Only the relations to which the account has permissions to <strong>view</strong> will be expanded.
</td>
</tr>
</tbody>
@@ -39,7 +39,7 @@
icon: CommonHelper.getFieldTypeIcon("date"),
},
{
label: "Multiple choices",
label: "Select",
value: "select",
icon: CommonHelper.getFieldTypeIcon("select"),
},
@@ -58,17 +58,11 @@
value: "relation",
icon: CommonHelper.getFieldTypeIcon("relation"),
},
{
label: "User",
value: "user",
icon: CommonHelper.getFieldTypeIcon("user"),
},
];
</script>
<ObjectSelect
class="field-type-select {classes}"
searchable
items={types}
bind:keyOfSelected={value}
{...$$restProps}
@@ -1,8 +1,10 @@
<script>
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import Field from "@/components/base/Field.svelte";
import ObjectSelect from "@/components/base/ObjectSelect.svelte";
import CollectionUpsertPanel from "@/components/collections/CollectionUpsertPanel.svelte";
export let key = "";
export let options = {};
@@ -14,6 +16,7 @@
let isLoading = false;
let collections = [];
let upsertPanel = null;
// load defaults
$: if (CommonHelper.isEmpty(options)) {
@@ -26,19 +29,20 @@
loadCollections();
function loadCollections() {
async function loadCollections() {
isLoading = true;
ApiClient.collections.getFullList(200, { sort: "-created" })
.then((items) => {
collections = items;
})
.catch((err) => {
ApiClient.errorResponseHandler(err);
})
.finally(() => {
isLoading = false;
try {
const result = await ApiClient.collections.getFullList(200, {
sort: "created",
});
collections = CommonHelper.sortCollections(result);
} catch (err) {
ApiClient.errorResponseHandler(err);
}
isLoading = false;
}
</script>
@@ -53,13 +57,32 @@
selectionKey="id"
items={collections}
bind:keyOfSelected={options.collectionId}
/>
>
<svelte:fragment slot="afterOptions">
<button
type="button"
class="btn btn-warning btn-block btn-sm m-t-5"
on:click={() => upsertPanel?.show()}
>
<span class="txt">New collection</span>
</button>
</svelte:fragment>
</ObjectSelect>
</Field>
</div>
<div class="col-sm-3">
<Field class="form-field required" name="schema.{key}.options.maxSelect" let:uniqueId>
<label for={uniqueId}>Max select</label>
<input type="number" id={uniqueId} step="1" min="1" required bind:value={options.maxSelect} />
<Field class="form-field" name="schema.{key}.options.maxSelect" let:uniqueId>
<label for={uniqueId}>
<span class="txt">Max select</span>
<i
class="ri-information-line link-hint"
use:tooltip={{
text: "Leave empty for no limit.",
position: "top",
}}
/>
</label>
<input type="number" id={uniqueId} step="1" min="1" bind:value={options.maxSelect} />
</Field>
</div>
<div class="col-sm-12">
@@ -69,3 +92,13 @@
</Field>
</div>
</div>
<CollectionUpsertPanel
bind:this={upsertPanel}
on:save={(e) => {
if (e?.detail?.collection?.id) {
options.collectionId = e.detail.collection.id;
}
loadCollections();
}}
/>
+1 -2
View File
@@ -2,7 +2,6 @@
import { onMount } from "svelte";
import { scale } from "svelte/transition";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import {
Chart,
LineElement,
@@ -44,7 +43,7 @@
resetData();
for (let item of result) {
chartData.push({
x: CommonHelper.getDateTime(item.date).toLocal().toJSDate(),
x: new Date(item.date),
y: item.total,
});
totalRequests += item.total;
+41 -10
View File
@@ -4,6 +4,7 @@
import CommonHelper from "@/utils/CommonHelper";
import SortHeader from "@/components/base/SortHeader.svelte";
import FormattedDate from "@/components/base/FormattedDate.svelte";
import HorizontalScroller from "@/components/base/HorizontalScroller.svelte";
const dispatch = createEventDispatcher();
const labelMethodClass = {
@@ -21,6 +22,7 @@
let currentPage = 1;
let totalItems = 0;
let isLoading = false;
let yieldedItemsId = 0;
$: if (typeof sort !== "undefined" || typeof filter !== "undefined" || typeof presets !== "undefined") {
clearList();
@@ -29,24 +31,39 @@
$: canLoadMore = totalItems > items.length;
export async function load(page = 1) {
export async function load(page = 1, breakTasks = true) {
isLoading = true;
return ApiClient.logs.getRequestsList(page, 40, {
sort: sort,
filter: [presets, filter].filter(Boolean).join("&&"),
})
.then((result) => {
return ApiClient.logs
.getRequestsList(page, 30, {
sort: sort,
filter: [presets, filter].filter(Boolean).join("&&"),
})
.then(async (result) => {
if (page <= 1) {
clearList();
}
isLoading = false;
items = items.concat(result.items);
currentPage = result.page;
totalItems = result.totalItems;
dispatch("load", items.concat(result.items));
dispatch("load", items);
// optimize the items listing by rendering the rows in task batches
if (breakTasks) {
const currentYieldId = ++yieldedItemsId;
while (result.items.length) {
if (yieldedItemsId != currentYieldId) {
break; // new yeild has been started
}
items = items.concat(result.items.splice(0, 10));
await CommonHelper.yieldToMain();
}
} else {
items = items.concat(result.items);
}
})
.catch((err) => {
if (!err?.isAbort) {
@@ -65,7 +82,7 @@
}
</script>
<div class="table-wrapper">
<HorizontalScroller class="table-wrapper">
<table class="table" class:table-loading={isLoading}>
<thead>
<tr>
@@ -90,6 +107,13 @@
</div>
</SortHeader>
<SortHeader disable class="col-type-number col-field-userIp" name="userIp" bind:sort>
<div class="col-header-content">
<i class={CommonHelper.getFieldTypeIcon("number")} />
<span class="txt">User IP</span>
</div>
</SortHeader>
<SortHeader disable class="col-type-number col-field-status" name="status" bind:sort>
<div class="col-header-content">
<i class={CommonHelper.getFieldTypeIcon("number")} />
@@ -103,6 +127,7 @@
<span class="txt">created</span>
</div>
</SortHeader>
<th class="col-type-action min-width" />
</tr>
</thead>
@@ -140,6 +165,12 @@
</span>
</td>
<td class="col-type-number col-field-userIp">
<span class="txt txt-ellipsis" class:txt-hint={!item.userIp} title={item.userIp}>
{item.userIp || "N/A"}
</span>
</td>
<td class="col-type-number col-field-status">
<span class="label" class:label-danger={item.status >= 400}>
{item.status}
@@ -180,7 +211,7 @@
{/each}
</tbody>
</table>
</div>
</HorizontalScroller>
{#if items.length}
<small class="block txt-hint txt-right m-t-sm">Showing {items.length} of {totalItems}</small>
@@ -1,5 +1,5 @@
<script>
import { onMount, createEventDispatcher } from "svelte";
import { createEventDispatcher } from "svelte";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import { confirm } from "@/stores/confirmation";
@@ -8,7 +8,7 @@
const dispatch = createEventDispatcher();
export let user;
export let record;
let externalAuths = [];
let isLoading = false;
@@ -22,7 +22,7 @@
}
async function loadExternalAuths() {
if (!user?.id) {
if (!record?.id) {
externalAuths = [];
isLoading = false;
return;
@@ -31,7 +31,7 @@
isLoading = true;
try {
externalAuths = await ApiClient.users.listExternalAuths(user.id);
externalAuths = await ApiClient.collection(record.collectionId).listExternalAuths(record.id);
} catch (err) {
ApiClient.errorResponseHandler(err);
}
@@ -40,13 +40,13 @@
}
function unlinkExternalAuth(provider) {
if (!user?.id || !provider) {
if (!record?.id || !provider) {
return; // nothing to unlink
}
confirm(`Do you really want to unlink the ${getProviderTitle(provider)} provider?`, () => {
return ApiClient.users
.unlinkExternalAuth(user.id, provider)
return ApiClient.collection(record.collectionId)
.unlinkExternalAuth(record.id, provider)
.then(() => {
addSuccessToast(`Successfully unlinked the ${getProviderTitle(provider)} provider.`);
dispatch("unlink", provider);
@@ -58,16 +58,14 @@
});
}
onMount(() => {
loadExternalAuths();
});
loadExternalAuths();
</script>
{#if isLoading}
<div class="block txt-center">
<span class="loader" />
</div>
{:else if user?.id && externalAuths.length}
{:else if record?.id && externalAuths.length}
<div class="list">
{#each externalAuths as auth}
<div class="list-item">
@@ -85,5 +83,5 @@
{/each}
</div>
{:else}
<p class="txt-hint txt-center">No authorized OAuth2 providers.</p>
<p class="txt-hint txt-center">No linked OAuth2 providers.</p>
{/if}
@@ -1,5 +1,5 @@
<script>
import PocketBase from "pocketbase";
import PocketBase, { getTokenPayload } from "pocketbase";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import FullPage from "@/components/base/FullPage.svelte";
@@ -24,7 +24,8 @@
const client = new PocketBase(import.meta.env.PB_BACKEND_URL);
try {
await client.users.confirmEmailChange(params?.token, password);
const payload = getTokenPayload(params?.token);
await client.collection(payload.collectionId).confirmEmailChange(params?.token, password);
success = true;
} catch (err) {
ApiClient.errorResponseHandler(err);
@@ -1,5 +1,5 @@
<script>
import PocketBase from "pocketbase";
import PocketBase, { getTokenPayload } from "pocketbase";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import FullPage from "@/components/base/FullPage.svelte";
@@ -25,7 +25,10 @@
const client = new PocketBase(import.meta.env.PB_BACKEND_URL);
try {
await client.users.confirmPasswordReset(params?.token, newPassword, newPasswordConfirm);
const payload = getTokenPayload(params?.token);
await client
.collection(payload.collectionId)
.confirmPasswordReset(params?.token, newPassword, newPasswordConfirm);
success = true;
} catch (err) {
ApiClient.errorResponseHandler(err);
@@ -1,5 +1,5 @@
<script>
import PocketBase from "pocketbase";
import PocketBase, { getTokenPayload } from "pocketbase";
import FullPage from "@/components/base/FullPage.svelte";
export let params;
@@ -16,7 +16,8 @@
const client = new PocketBase(import.meta.env.PB_BACKEND_URL);
try {
await client.users.confirmVerification(params?.token);
const payload = getTokenPayload(params?.token);
await client.collection(payload.collectionId).confirmVerification(params?.token);
success = true;
} catch (err) {
success = false;
+12 -3
View File
@@ -5,6 +5,7 @@
activeCollection,
isCollectionsLoading,
loadCollections,
changeActiveCollectionById,
} from "@/stores/collections";
import tooltip from "@/actions/tooltip";
import { pageTitle, hideControls } from "@/stores/app";
@@ -13,7 +14,7 @@
import RefreshButton from "@/components/base/RefreshButton.svelte";
import CollectionsSidebar from "@/components/collections/CollectionsSidebar.svelte";
import CollectionUpsertPanel from "@/components/collections/CollectionUpsertPanel.svelte";
import CollectionDocsPanel from "@/components/collections/docs/CollectionDocsPanel.svelte";
import CollectionDocsPanel from "@/components/collections/CollectionDocsPanel.svelte";
import RecordUpsertPanel from "@/components/records/RecordUpsertPanel.svelte";
import RecordsList from "@/components/records/RecordsList.svelte";
@@ -29,7 +30,15 @@
let sort = queryParams.get("sort") || "-created";
let selectedCollectionId = queryParams.get("collectionId") || "";
$: viewableCollections = $collections.filter((c) => c.name != import.meta.env.PB_PROFILE_COLLECTION);
$: reactiveParams = new URLSearchParams($querystring);
$: if (
!$isCollectionsLoading &&
reactiveParams.has("collectionId") &&
reactiveParams.get("collectionId") != selectedCollectionId
) {
changeActiveCollectionById(reactiveParams.get("collectionId"));
}
// reset filter and sort on collection change
$: if ($activeCollection?.id && selectedCollectionId != $activeCollection.id) {
@@ -62,7 +71,7 @@
<h1>Loading collections...</h1>
</div>
</PageWrapper>
{:else if !viewableCollections.length}
{:else if !$collections.length}
<PageWrapper center>
<div class="placeholder-section m-b-base">
<div class="icon">
@@ -25,7 +25,7 @@
class="txt-ellipsis"
href={record[field.name]}
target="_blank"
rel="noopener"
rel="noopener noreferrer"
use:tooltip={"Open in new tab"}
on:click|stopPropagation
>
@@ -45,9 +45,12 @@
</div>
{:else if field.type === "relation" || field.type === "user"}
<div class="inline-flex">
{#each CommonHelper.toArray(record[field.name]) as item, i (i + item)}
{#each CommonHelper.toArray(record[field.name]).slice(0, 20) as item, i (i + item)}
<IdLabel id={item} />
{/each}
{#if CommonHelper.toArray(record[field.name]).length > 20}
...
{/if}
</div>
{:else if field.type === "file"}
<div class="inline-flex">
@@ -13,7 +13,7 @@
$: hasPreview = CommonHelper.hasImageExtension(filename);
$: if (hasPreview) {
originalUrl = ApiClient.records.getFileUrl(record, `${filename}`);
originalUrl = ApiClient.getFileUrl(record, `${filename}`);
}
$: thumbUrl = originalUrl ? originalUrl + "?thumb=100x100" : "";
+67 -8
View File
@@ -3,6 +3,7 @@
import CommonHelper from "@/utils/CommonHelper";
import ObjectSelect from "@/components/base/ObjectSelect.svelte";
import RecordSelectOption from "./RecordSelectOption.svelte";
import RecordUpsertPanel from "@/components/records/RecordUpsertPanel.svelte";
const uniqueId = "select_" + CommonHelper.randomString(5);
@@ -21,8 +22,12 @@
let totalItems = 0;
let isLoadingList = false;
let isLoadingSelected = false;
let isLoadingCollection = false;
let collection = null;
let upsertPanel;
$: if (collectionId) {
loadCollection();
loadSelected().then(() => {
loadList(true);
});
@@ -32,6 +37,26 @@
$: canLoadMore = totalItems > list.length;
async function loadCollection() {
if (!collectionId) {
collection = null;
isLoadingCollection = false;
return;
}
isLoadingCollection = true;
try {
collection = await ApiClient.collections.getOne(collectionId, {
$cancelKey: "collection_" + uniqueId,
});
} catch (err) {
ApiClient.errorResponseHandler(err);
}
isLoadingCollection = false;
}
async function loadSelected() {
const selectedIds = CommonHelper.toArray(keyOfSelected);
@@ -41,21 +66,34 @@
isLoadingSelected = true;
try {
let loadedItems = [];
// batch load all selected records to avoid parser stack overflow errors
const filterIds = selectedIds.slice();
const loadPromises = [];
while (filterIds.length > 0) {
const filters = [];
for (const id of selectedIds) {
for (const id of filterIds.splice(0, 50)) {
filters.push(`id="${id}"`);
}
const result = await ApiClient.records.getFullList(collectionId, 200, {
filter: filters.join("||"),
$cancelKey: uniqueId + "loadSelected",
loadPromises.push(
ApiClient.collection(collectionId).getFullList(200, {
filter: filters.join("||"),
$autoCancel: false,
})
);
}
try {
await Promise.all(loadPromises).then((values) => {
loadedItems = loadedItems.concat(...values);
});
// preserve selected order
selected = [];
for (const id of selectedIds) {
const item = CommonHelper.findByKey(result, "id", id);
const item = CommonHelper.findByKey(loadedItems, "id", id);
if (item) {
selected.push(item);
}
@@ -80,7 +118,7 @@
try {
const page = reset ? 1 : currentPage + 1;
const result = await ApiClient.records.getList(collectionId, page, 200, {
const result = await ApiClient.collection(collectionId).getList(page, 200, {
sort: "-created",
$cancelKey: uniqueId + "loadList",
});
@@ -108,6 +146,7 @@
searchable={list.length > 5}
selectionKey="id"
labelComponent={optionComponent}
disabled={isLoading}
{optionComponent}
{multiple}
bind:keyOfSelected
@@ -118,10 +157,19 @@
{...$$restProps}
>
<svelte:fragment slot="afterOptions">
{#if !isLoadingCollection && collection}
<button
type="button"
class="btn btn-warning btn-block btn-sm m-t-5"
on:click={() => upsertPanel?.show()}
>
<span class="txt">New record</span>
</button>
{/if}
{#if canLoadMore}
<button
type="button"
class="btn btn-block btn-sm"
class="btn btn-block btn-sm m-t-5"
class:btn-loading={isLoadingList}
class:btn-disabled={isLoadingList}
on:click|stopPropagation={() => loadList()}
@@ -131,3 +179,14 @@
{/if}
</svelte:fragment>
</ObjectSelect>
<RecordUpsertPanel
bind:this={upsertPanel}
{collection}
on:save={(e) => {
if (e?.detail?.id) {
keyOfSelected = CommonHelper.toArray(keyOfSelected).concat(e.detail.id);
}
loadList(true);
}}
/>
@@ -13,13 +13,15 @@
const props = [
// prioritized common displayable props
"name",
"title",
"name",
"email",
"username",
"label",
"key",
"email",
"heading",
"content",
"description",
// fallback to the available props
...Object.keys(model),
];
@@ -3,12 +3,14 @@
import { Record } 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";
import AuthFields from "@/components/records/fields/AuthFields.svelte";
import TextField from "@/components/records/fields/TextField.svelte";
import NumberField from "@/components/records/fields/NumberField.svelte";
import BoolField from "@/components/records/fields/BoolField.svelte";
@@ -19,10 +21,13 @@
import JsonField from "@/components/records/fields/JsonField.svelte";
import FileField from "@/components/records/fields/FileField.svelte";
import RelationField from "@/components/records/fields/RelationField.svelte";
import UserField from "@/components/records/fields/UserField.svelte";
import ExternalAuthsList from "@/components/records/ExternalAuthsList.svelte";
const dispatch = createEventDispatcher();
const formId = "record_" + CommonHelper.randomString(5);
const TAB_FORM = "form";
const TAB_PROVIDERS = "providers";
export let collection;
@@ -34,6 +39,7 @@
let uploadedFilesMap = {}; // eg.: {"field1":[File1, File2], ...}
let deletedFileIndexesMap = {}; // eg.: {"field1":[0, 1], ...}
let initialFormHash = "";
let activeTab = TAB_FORM;
$: hasFileChanges =
CommonHelper.hasNonEmptyProps(uploadedFilesMap) ||
@@ -43,13 +49,13 @@
$: canSave = record.isNew || hasChanges;
$: isProfileCollection = collection?.name !== import.meta.env.PB_PROFILE_COLLECTION;
export function show(model) {
load(model);
confirmClose = true;
activeTab = TAB_FORM;
return recordPanel?.show();
}
@@ -60,7 +66,11 @@
async function load(model) {
setErrors({}); // reset errors
original = model || {};
record = model?.clone ? model.clone() : new Record();
if (model?.clone) {
record = model.clone();
} else {
record = new Record();
}
uploadedFilesMap = {};
deletedFileIndexesMap = {};
await tick(); // wait to populate the fields to get the normalized values
@@ -72,7 +82,7 @@
}
function save() {
if (isSaving || !canSave) {
if (isSaving || !canSave || !collection?.id) {
return;
}
@@ -82,13 +92,13 @@
let request;
if (record.isNew) {
request = ApiClient.records.create(collection?.id, data);
request = ApiClient.collection(collection.id).create(data);
} else {
request = ApiClient.records.update(collection?.id, record.id, data);
request = ApiClient.collection(collection.id).update(record.id, data);
}
request
.then(async (result) => {
.then((result) => {
addSuccessToast(
record.isNew ? "Successfully created record." : "Successfully updated record."
);
@@ -110,7 +120,8 @@
}
confirm(`Do you really want to delete the selected record?`, () => {
return ApiClient.records.delete(original["@collectionId"], original.id)
return ApiClient.collection(original.collectionId)
.delete(original.id)
.then(() => {
hide();
addSuccessToast("Successfully deleted record.");
@@ -126,15 +137,24 @@
const data = record?.export() || {};
const formData = new FormData();
const schemaMap = {};
const exportableFields = {};
for (const field of collection?.schema || []) {
schemaMap[field.name] = field;
exportableFields[field.name] = true;
}
if (collection?.isAuth) {
exportableFields["username"] = true;
exportableFields["email"] = true;
exportableFields["emailVisibility"] = true;
exportableFields["password"] = true;
exportableFields["passwordConfirm"] = true;
exportableFields["verified"] = true;
}
// export base fields
for (const key in data) {
// skip non-schema fields
if (!schemaMap[key]) {
if (!exportableFields[key]) {
continue;
}
@@ -164,11 +184,45 @@
return formData;
}
function sendVerificationEmail() {
if (!collection?.id || !original?.email) {
return;
}
confirm(`Do you really want to sent verification email to ${original.email}?`, () => {
return ApiClient.collection(collection.id)
.requestVerification(original.email)
.then(() => {
addSuccessToast(`Successfully sent verification email to ${original.email}.`);
})
.catch((err) => {
ApiClient.errorResponseHandler(err);
});
});
}
function sendPasswordResetEmail() {
if (!collection?.id || !original?.email) {
return;
}
confirm(`Do you really want to sent password reset email to ${original.email}?`, () => {
return ApiClient.collection(collection.id)
.requestPasswordReset(original.email)
.then(() => {
addSuccessToast(`Successfully sent password reset email to ${original.email}.`);
})
.catch((err) => {
ApiClient.errorResponseHandler(err);
});
});
}
</script>
<OverlayPanel
bind:this={recordPanel}
class="overlay-panel-lg record-panel"
class="overlay-panel-lg record-panel {collection?.isAuth && !record.isNew ? 'colored-header' : ''}"
beforeHide={() => {
if (hasChanges && confirmClose) {
confirm("You have unsaved changes. Do you really want to close the panel?", () => {
@@ -177,6 +231,7 @@
});
return false;
}
setErrors({});
return true;
}}
on:hide
@@ -185,73 +240,143 @@
<svelte:fragment slot="header">
<h4>
{record.isNew ? "New" : "Edit"}
{collection.name} record
<strong>{collection?.name}</strong> record
</h4>
{#if !record.isNew && isProfileCollection}
{#if !record.isNew}
<div class="flex-fill" />
<button type="button" class="btn btn-sm btn-circle btn-secondary">
<div class="content">
<i class="ri-more-line" />
<Toggler class="dropdown dropdown-right m-t-5">
<div tabindex="0" class="dropdown-item closable" on:click={() => deleteConfirm()}>
<Toggler class="dropdown dropdown-right dropdown-nowrap">
{#if collection.isAuth && !original.verified && original.email}
<button
type="button"
class="dropdown-item closable"
on:click={() => sendVerificationEmail()}
>
<i class="ri-mail-check-line" />
<span class="txt">Send verification email</span>
</button>
{/if}
{#if collection.isAuth && original.email}
<button
type="button"
class="dropdown-item closable"
on:click={() => sendPasswordResetEmail()}
>
<i class="ri-mail-lock-line" />
<span class="txt">Send password reset email</span>
</button>
{/if}
<button
type="button"
class="dropdown-item txt-danger closable"
on:click|preventDefault|stopPropagation={() => deleteConfirm()}
>
<i class="ri-delete-bin-7-line" />
<span class="txt">Delete</span>
</div>
</button>
</Toggler>
</div>
</button>
{/if}
{#if collection.isAuth && !record.isNew}
<div class="tabs-header stretched">
<button
type="button"
class="tab-item"
class:active={activeTab === TAB_FORM}
on:click={() => (activeTab = TAB_FORM)}
>
Account
</button>
<button
type="button"
class="tab-item"
class:active={activeTab === TAB_PROVIDERS}
on:click={() => (activeTab = TAB_PROVIDERS)}
>
Authorized providers
</button>
</div>
{/if}
</svelte:fragment>
<form id={formId} class="block" on:submit|preventDefault={save}>
{#if !record.isNew}
<Field class="form-field disabled" name="id" let:uniqueId>
<label for={uniqueId}>
<i class={CommonHelper.getFieldTypeIcon("primary")} />
<span class="txt">id</span>
<span class="flex-fill" />
</label>
<input type="text" id={uniqueId} value={record.id} disabled />
</Field>
{/if}
{#each collection?.schema || [] as field (field.name)}
{#if field.type === "text"}
<TextField {field} bind:value={record[field.name]} />
{:else if field.type === "number"}
<NumberField {field} bind:value={record[field.name]} />
{:else if field.type === "bool"}
<BoolField {field} bind:value={record[field.name]} />
{:else if field.type === "email"}
<EmailField {field} bind:value={record[field.name]} />
{:else if field.type === "url"}
<UrlField {field} bind:value={record[field.name]} />
{:else if field.type === "date"}
<DateField {field} bind:value={record[field.name]} />
{:else if field.type === "select"}
<SelectField {field} bind:value={record[field.name]} />
{:else if field.type === "json"}
<JsonField {field} bind:value={record[field.name]} />
{:else if field.type === "file"}
<FileField
{field}
{record}
bind:value={record[field.name]}
bind:uploadedFiles={uploadedFilesMap[field.name]}
bind:deletedFileIndexes={deletedFileIndexesMap[field.name]}
/>
{:else if field.type === "relation"}
<RelationField {field} bind:value={record[field.name]} />
{:else if field.type === "user"}
<UserField {field} bind:value={record[field.name]} />
<div class="tabs-content">
<form
id={formId}
class="tab-item"
class:active={activeTab === TAB_FORM}
on:submit|preventDefault={save}
>
{#if !record.isNew}
<Field class="form-field disabled" name="id" let:uniqueId>
<label for={uniqueId}>
<i class={CommonHelper.getFieldTypeIcon("primary")} />
<span class="txt">id</span>
<span class="flex-fill" />
</label>
<div class="form-field-addon">
<i
class="ri-calendar-event-line txt-disabled"
use:tooltip={{
text: `Created: ${record.created}\nUpdated: ${record.updated}`,
position: "left",
}}
/>
</div>
<input type="text" id={uniqueId} value={record.id} readonly />
</Field>
{/if}
{:else}
<div class="block txt-center txt-disabled">
<h5>No custom fields to be set</h5>
{#if collection?.isAuth}
<AuthFields bind:record {collection} />
<hr />
{/if}
{#each collection?.schema || [] as field (field.name)}
{#if field.type === "text"}
<TextField {field} bind:value={record[field.name]} />
{:else if field.type === "number"}
<NumberField {field} bind:value={record[field.name]} />
{:else if field.type === "bool"}
<BoolField {field} bind:value={record[field.name]} />
{:else if field.type === "email"}
<EmailField {field} bind:value={record[field.name]} />
{:else if field.type === "url"}
<UrlField {field} bind:value={record[field.name]} />
{:else if field.type === "date"}
<DateField {field} bind:value={record[field.name]} />
{:else if field.type === "select"}
<SelectField {field} bind:value={record[field.name]} />
{:else if field.type === "json"}
<JsonField {field} bind:value={record[field.name]} />
{:else if field.type === "file"}
<FileField
{field}
{record}
bind:value={record[field.name]}
bind:uploadedFiles={uploadedFilesMap[field.name]}
bind:deletedFileIndexes={deletedFileIndexesMap[field.name]}
/>
{:else if field.type === "relation"}
<RelationField {field} bind:value={record[field.name]} />
{/if}
{:else}
<div class="block txt-center txt-disabled">
<h5>No custom fields to be set</h5>
</div>
{/each}
</form>
{#if collection.isAuth && !record.isNew}
<div class="tab-item" class:active={activeTab === TAB_PROVIDERS}>
<ExternalAuthsList {record} />
</div>
{/each}
</form>
{/if}
</div>
<svelte:fragment slot="footer">
<button type="button" class="btn btn-secondary" disabled={isSaving} on:click={() => hide()}>
+149 -15
View File
@@ -3,11 +3,15 @@
import { fly } from "svelte/transition";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import { confirm } from "@/stores/confirmation";
import { addSuccessToast } from "@/stores/toasts";
import SortHeader from "@/components/base/SortHeader.svelte";
import Toggler from "@/components/base/Toggler.svelte";
import Field from "@/components/base/Field.svelte";
import FormattedDate from "@/components/base/FormattedDate.svelte";
import IdLabel from "@/components/base/IdLabel.svelte";
import HorizontalScroller from "@/components/base/HorizontalScroller.svelte";
import RecordFieldCell from "@/components/records/RecordFieldCell.svelte";
const dispatch = createEventDispatcher();
@@ -22,8 +26,12 @@
let bulkSelected = {};
let isLoading = true;
let isDeleting = false;
let yieldedRecordsId = 0;
let hiddenColumns = [];
let columnsTrigger;
$: if (collection?.id) {
loadStoredHiddenColumns();
clearList();
}
@@ -35,43 +43,84 @@
$: fields = collection?.schema || [];
$: visibleFields = fields.filter((field) => !hiddenColumns.includes(field.id));
$: totalBulkSelected = Object.keys(bulkSelected).length;
$: areAllRecordsSelected = records.length && totalBulkSelected === records.length;
$: if (hiddenColumns !== -1) {
updateStoredHiddenColumns();
}
function updateStoredHiddenColumns() {
if (!collection?.id) {
return;
}
localStorage.setItem(collection?.id + "@hiddenCollumns", JSON.stringify(hiddenColumns));
}
function loadStoredHiddenColumns() {
hiddenColumns = [];
if (!collection?.id) {
return;
}
try {
const encoded = localStorage.getItem(collection.id + "@hiddenCollumns");
if (encoded) hiddenColumns = JSON.parse(encoded) || [];
} catch (_) {}
}
export async function reloadLoadedPages() {
const loadedPages = currentPage;
for (let i = 1; i <= loadedPages; i++) {
if (i === 1 || canLoadMore) {
await load(i);
await load(i, false);
}
}
}
export async function load(page = 1) {
export async function load(page = 1, breakTasks = true) {
if (!collection?.id) {
return;
}
isLoading = true;
return ApiClient.records
.getList(collection.id, page, 50, {
return ApiClient.collection(collection.id)
.getList(page, 30, {
sort: sort,
filter: filter,
})
.then((result) => {
.then(async (result) => {
if (page <= 1) {
clearList();
}
isLoading = false;
records = records.concat(result.items);
currentPage = result.page;
totalRecords = result.totalItems;
dispatch("load", records.concat(result.items));
dispatch("load", records);
// optimize the records listing by rendering the rows in task batches
if (breakTasks) {
const currentYieldId = ++yieldedRecordsId;
while (result.items.length) {
if (yieldedRecordsId != currentYieldId) {
break; // new yeild has been started
}
records = records.concat(result.items.splice(0, 15));
await CommonHelper.yieldToMain();
}
} else {
records = records.concat(result.items);
}
})
.catch((err) => {
if (!err?.isAbort) {
@@ -128,13 +177,13 @@
}
async function deleteSelected() {
if (isDeleting || !totalBulkSelected) {
if (isDeleting || !totalBulkSelected || !collection?.id) {
return;
}
let promises = [];
for (const recordId of Object.keys(bulkSelected)) {
promises.push(ApiClient.records.delete(collection?.id, recordId));
promises.push(ApiClient.collection(collection.id).delete(recordId));
}
isDeleting = true;
@@ -158,7 +207,31 @@
}
</script>
<div class="table-wrapper">
<HorizontalScroller class="table-wrapper">
<svelte:fragment slot="before">
<Toggler class="dropdown dropdown-right dropdown-nowrap columns-dropdown" trigger={columnsTrigger}>
<div class="txt-hint txt-sm p-5 m-b-5">Toggle Columns</div>
{#each fields as field (field.id + field.name)}
<Field class="form-field form-field-sm form-field-toggle m-0 p-5" let:uniqueId>
<input
type="checkbox"
id={uniqueId}
checked={!hiddenColumns.includes(field.id)}
on:change={(e) => {
if (e.target.checked) {
CommonHelper.removeByValue(hiddenColumns, field.id);
} else {
CommonHelper.pushUnique(hiddenColumns, field.id);
}
hiddenColumns = hiddenColumns;
}}
/>
<label for={uniqueId}>{field.name}</label>
</Field>
{/each}
</Toggler>
</svelte:fragment>
<table class="table" class:table-loading={isLoading}>
<thead>
<tr>
@@ -178,13 +251,30 @@
</div>
{/if}
</th>
<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>
{#each fields as field (field.name)}
{#if collection.isAuth}
<SortHeader class="col-type-text col-field-id" name="username" bind:sort>
<div class="col-header-content">
<i class={CommonHelper.getFieldTypeIcon("user")} />
<span class="txt">username</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>
{/if}
{#each visibleFields as field (field.name)}
<SortHeader
class="col-type-{field.type} col-field-{field.name}"
name={field.name}
@@ -196,19 +286,26 @@
</div>
</SortHeader>
{/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" />
<th class="col-type-action min-width">
<button bind:this={columnsTrigger} type="button" class="btn btn-sm btn-secondary p-0">
<i class="ri-more-line" />
</button>
</th>
</tr>
</thead>
<tbody>
@@ -237,10 +334,47 @@
</td>
<td class="col-type-text col-field-id">
<IdLabel id={record.id} />
<div class="flex flex-gap-5">
<IdLabel id={record.id} />
{#if collection.isAuth}
{#if record.verified}
<i
class="ri-checkbox-circle-fill txt-sm txt-success"
use:tooltip={"Verified"}
/>
{:else}
<i
class="ri-error-warning-fill txt-sm txt-hint"
use:tooltip={"Unverified"}
/>
{/if}
{/if}
</div>
</td>
{#each fields as field (field.name)}
{#if collection.isAuth}
<td class="col-type-text col-field-username">
{#if CommonHelper.isEmpty(record.username)}
<span class="txt-hint">N/A</span>
{:else}
<span class="txt txt-ellipsis" title={record.username}>
{record.username}
</span>
{/if}
</td>
<td class="col-type-text col-field-email">
{#if CommonHelper.isEmpty(record.email)}
<span class="txt-hint">N/A</span>
{:else}
<span class="txt txt-ellipsis" title={record.email}>
{record.email}
</span>
{/if}
</td>
{/if}
{#each visibleFields as field (field.name)}
<RecordFieldCell {record} {field} />
{/each}
@@ -282,7 +416,7 @@
{/each}
</tbody>
</table>
</div>
</HorizontalScroller>
{#if records.length}
<small class="block txt-hint txt-right m-t-sm">Showing {records.length} of {totalRecords}</small>
@@ -0,0 +1,155 @@
<script>
import { slide } from "svelte/transition";
import { Collection, Record } from "pocketbase";
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import { confirm } from "@/stores/confirmation";
import { removeError } from "@/stores/errors";
import Field from "@/components/base/Field.svelte";
export let collection = new Collection();
export let record = new Record();
let originalUsername = record.username || null;
let changePasswordToggle = false;
$: if (!record.username && record.username !== null) {
record.username = null;
}
$: if (!changePasswordToggle) {
record.password = null;
record.passwordConfirm = null;
removeError("password");
removeError("passwordConfirm");
}
</script>
<div class="grid m-b-base">
<div class="col-lg-6">
<Field class="form-field {!record.isNew ? 'required' : ''}" name="username" let:uniqueId>
<label for={uniqueId}>
<i class={CommonHelper.getFieldTypeIcon("user")} />
<span class="txt">Username</span>
</label>
<input
type="text"
requried={!record.isNew}
placeholder={record.isNew ? "Leave empty to auto generate..." : originalUsername}
id={uniqueId}
bind:value={record.username}
/>
</Field>
</div>
<div class="col-lg-6">
<Field
class="form-field {collection.options?.requireEmail ? 'required' : ''}"
name="email"
let:uniqueId
>
<label for={uniqueId}>
<i class={CommonHelper.getFieldTypeIcon("email")} />
<span class="txt">Email</span>
</label>
<div class="form-field-addon email-visibility-addon">
<button
type="button"
class="btn btn-sm btn-secondary {record.emailVisibility ? 'btn-success' : 'btn-hint'}"
use:tooltip={{
text: "Make email public or private",
position: "top-right",
}}
on:click={() => (record.emailVisibility = !record.emailVisibility)}
>
<span class="txt">Public: {record.emailVisibility ? "On" : "Off"}</span>
</button>
</div>
<!-- svelte-ignore a11y-autofocus -->
<input
type="email"
autofocus={record.isNew}
autocomplete="off"
id={uniqueId}
required={collection.options?.requireEmail}
bind:value={record.email}
/>
</Field>
</div>
<div class="col-lg-12">
{#if !record.isNew}
<Field class="form-field form-field-toggle" name="verified" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={changePasswordToggle} />
<label for={uniqueId}>Change password</label>
</Field>
{/if}
{#if record.isNew || changePasswordToggle}
<div class="block" transition:slide|local={{ duration: 150 }}>
<div class="grid" class:p-t-xs={changePasswordToggle}>
<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={record.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={record.passwordConfirm}
/>
</Field>
</div>
</div>
</div>
{/if}
</div>
<div class="col-lg-12">
<Field class="form-field form-field-toggle" name="verified" let:uniqueId>
<input
type="checkbox"
id={uniqueId}
bind:checked={record.verified}
on:change|preventDefault={(e) => {
if (record.isNew) {
return; // no confirmation required
}
confirm(
`Do you really want to manually change the verified account state?`,
() => {},
() => {
record.verified = !e.target.checked;
}
);
}}
/>
<label for={uniqueId}>Verified</label>
</Field>
</div>
</div>
<style>
.email-visibility-addon ~ input {
padding-right: 100px;
}
</style>
@@ -6,6 +6,13 @@
export let field = new SchemaField();
export let value = undefined;
// strip ms and zone for backwards compatibility with the older format
// and because flatpickr currently doesn't have integrated
// zones support and requires manual parsing and formatting
$: if (value && value.length > 19) {
value = value.substring(0, 19);
}
</script>
<Field class="form-field {field.required ? 'required' : ''}" name={field.name} let:uniqueId>
@@ -83,12 +83,12 @@
<RecordFilePreview {record} {filename} />
</figure>
<a
href={ApiClient.records.getFileUrl(record, filename)}
href={ApiClient.getFileUrl(record, filename)}
class="filename link-hint"
class:txt-strikethrough={deletedFileIndexes.includes(i)}
use:tooltip={{ position: "right", text: "Download" }}
target="_blank"
rel="noopener"
rel="noopener noreferrer"
>
{filename}
</a>
@@ -7,9 +7,14 @@
export let field = new SchemaField();
export let value = undefined;
$: isMultiple = field.options?.maxSelect > 1;
$: isMultiple = field.options?.maxSelect != 1;
$: if (isMultiple && Array.isArray(value) && value.length > field.options.maxSelect) {
$: if (
isMultiple &&
Array.isArray(value) &&
field.options?.maxSelect &&
value.length > field.options.maxSelect
) {
value = value.slice(field.options.maxSelect - 1);
}
</script>
@@ -1,33 +0,0 @@
<script>
import { SchemaField } from "pocketbase";
import CommonHelper from "@/utils/CommonHelper";
import UserSelect from "@/components/users/UserSelect.svelte";
import Field from "@/components/base/Field.svelte";
export let field = new SchemaField();
export let value = undefined;
// to prevent accidental changes, disable editing system user field values from the UI
$: isDisabled = !CommonHelper.isEmpty(value) && field.system;
$: isMultiple = field.options?.maxSelect > 1;
$: if (isMultiple && Array.isArray(value) && value.length > field.options.maxSelect) {
value = value.slice(field.options.maxSelect - 1);
}
</script>
<Field
class="form-field {field.required ? 'required' : ''} {isDisabled ? 'disabled' : ''}"
name={field.name}
let:uniqueId
>
<label for={uniqueId}>
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
<span class="txt">{field.name}</span>
</label>
<UserSelect toggle id={uniqueId} multiple={isMultiple} disabled={isDisabled} bind:keyOfSelected={value} />
{#if field.options?.maxSelect > 1}
<div class="help-block">Select up to {field.options.maxSelect} users.</div>
{/if}
</Field>
@@ -82,13 +82,6 @@
</Field>
</div>
<div class="col-lg-12">
<Field class="form-field" name="{key}.allowRegistrations" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={config.allowRegistrations} />
<label for={uniqueId}>Allow registration for new users</label>
</Field>
</div>
{#if showSelfHostedFields}
<div class="col-lg-12">
<div class="section-title">Optional endpoints (if you self host the OAUTH2 service)</div>
@@ -1,123 +0,0 @@
<script>
import { scale, slide } from "svelte/transition";
import tooltip from "@/actions/tooltip";
import { errors } from "@/stores/errors";
import CommonHelper from "@/utils/CommonHelper";
import Accordion from "@/components/base/Accordion.svelte";
import Field from "@/components/base/Field.svelte";
import MultipleValueInput from "@/components/base/MultipleValueInput.svelte";
export let config = {}; // EmailAuthConfig
let accordion;
$: hasErrors = !CommonHelper.isEmpty($errors?.emailAuth);
export function expand() {
accordion?.expand();
}
export function collapse() {
accordion?.collapse();
}
export function collapseSiblings() {
accordion?.collapseSiblings();
}
</script>
<Accordion bind:this={accordion} on:expand on:collapse on:toggle {...$$restProps}>
<svelte:fragment slot="header">
<div class="inline-flex">
<i class="ri-mail-lock-line" />
<span class="txt">Email/Password</span>
</div>
{#if config.enabled}
<span class="label label-success">Enabled</span>
{:else}
<span class="label">Disabled</span>
{/if}
<div class="flex-fill" />
{#if hasErrors}
<i
class="ri-error-warning-fill txt-danger"
transition:scale={{ duration: 150, start: 0.7 }}
use:tooltip={{ text: "Has errors", position: "left" }}
/>
{/if}
</svelte:fragment>
<Field class="form-field form-field-toggle m-b-0" name="emailAuth.enabled" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={config.enabled} />
<label for={uniqueId}>Enable</label>
</Field>
{#if config.enabled}
<div class="grid" transition:slide|local={{ duration: 150 }}>
<div class="col-sm-12 m-t-sm">
<Field class="form-field required" name="emailAuth.minPasswordLength" let:uniqueId>
<label for={uniqueId}>Minimum password length</label>
<input
type="number"
id={uniqueId}
required
min="5"
max="200"
bind:value={config.minPasswordLength}
/>
</Field>
</div>
<div class="col-lg-6">
<Field
class="form-field {!CommonHelper.isEmpty(config.onlyDomains) ? 'disabled' : ''}"
name="emailAuth.exceptDomains"
let:uniqueId
>
<label for={uniqueId}>
<span class="txt">Except domains</span>
<i
class="ri-information-line link-hint"
use:tooltip={{
text: 'Email domains that are NOT allowed to sign up. \n This field is disabled if "Only domains" is set.',
position: "top",
}}
/>
</label>
<MultipleValueInput
id={uniqueId}
disabled={!CommonHelper.isEmpty(config.onlyDomains)}
bind:value={config.exceptDomains}
/>
<div class="help-block">Use comma as separator.</div>
</Field>
</div>
<div class="col-lg-6">
<Field
class="form-field {!CommonHelper.isEmpty(config.exceptDomains) ? 'disabled' : ''}"
name="emailAuth.onlyDomains"
let:uniqueId
>
<label for="{uniqueId}.config.onlyDomains">
<span class="txt">Only domains</span>
<i
class="ri-information-line link-hint"
use:tooltip={{
text: 'Email domains that are ONLY allowed to sign up. \n This field is disabled if "Except domains" is set.',
position: "top",
}}
/>
</label>
<MultipleValueInput
id="{uniqueId}.config.onlyDomains"
disabled={!CommonHelper.isEmpty(config.exceptDomains)}
bind:value={config.onlyDomains}
/>
<div class="help-block">Use comma as separator.</div>
</Field>
</div>
</div>
{/if}
</Accordion>
@@ -1,3 +1,7 @@
<script context="module">
let cachedEditorComponent;
</script>
<script>
import { scale } from "svelte/transition";
import tooltip from "@/actions/tooltip";
@@ -12,7 +16,7 @@
export let config = {};
let accordion;
let editorComponent;
let editorComponent = cachedEditorComponent;
let isEditorComponentLoading = false;
$: hasErrors = !CommonHelper.isEmpty(CommonHelper.getNestedVal($errors, key));
@@ -42,6 +46,8 @@
editorComponent = (await import("@/components/base/CodeEditor.svelte")).default;
cachedEditorComponent = editorComponent;
isEditorComponentLoading = false;
}
@@ -6,13 +6,12 @@
import { addSuccessToast } from "@/stores/toasts";
import PageWrapper from "@/components/base/PageWrapper.svelte";
import SettingsSidebar from "@/components/settings/SettingsSidebar.svelte";
import EmailAuthAccordion from "@/components/settings/EmailAuthAccordion.svelte";
import AuthProviderAccordion from "@/components/settings/AuthProviderAccordion.svelte";
import providersList from "@/providers.js";
$pageTitle = "Auth providers";
let emailAuthAccordion;
let accordions = {};
let originalFormSettings = {};
let formSettings = {};
let isLoading = false;
@@ -48,7 +47,8 @@
const result = await ApiClient.settings.update(CommonHelper.filterRedactedProps(formSettings));
initSettings(result);
setErrors({});
emailAuthAccordion?.collapseSiblings();
accordions[Object.keys(accordions)[0]]?.collapseSiblings();
addSuccessToast("Successfully updated auth providers.");
} catch (err) {
ApiClient.errorResponseHandler(err);
@@ -60,15 +60,10 @@
function initSettings(data) {
data = data || {};
formSettings = {
emailAuth: Object.assign({ enabled: true }, data.emailAuth),
};
formSettings = {};
for (const providerKey in providersList) {
formSettings[providerKey] = Object.assign(
{ enabled: false, allowRegistrations: true },
data[providerKey]
);
formSettings[providerKey] = Object.assign({ enabled: false }, data[providerKey]);
}
originalFormSettings = JSON.parse(JSON.stringify(formSettings));
@@ -97,14 +92,9 @@
<div class="loader" />
{:else}
<div class="accordions">
<EmailAuthAccordion
bind:this={emailAuthAccordion}
single
bind:config={formSettings.emailAuth}
/>
{#each Object.entries(providersList) as [key, provider]}
<AuthProviderAccordion
bind:this={accordions[key]}
single
{key}
title={provider.title}
+36 -29
View File
@@ -1,4 +1,5 @@
<script>
import { onMount } from "svelte";
import { slide } from "svelte/transition";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
@@ -42,24 +43,6 @@
isLoading = false;
}
async function testS3() {
testS3Error = null;
if (!formSettings.s3.enabled) {
return; // nothing to test
}
isTesting = true;
try {
await ApiClient.settings.testS3({ $cancelKey: testRequestKey });
} catch (err) {
testS3Error = err;
}
isTesting = false;
}
async function save() {
if (isSaving || !hasChanges) {
return;
@@ -67,13 +50,6 @@
isSaving = true;
// auto cancel the test request after 30sec
clearTimeout(testS3TimeoutId);
testS3TimeoutId = setTimeout(() => {
ApiClient.cancelRequest(testRequestKey);
addErrorToast("S3 test connection timeout.");
}, 30000);
try {
ApiClient.cancelRequest(testRequestKey);
const settings = await ApiClient.settings.update(CommonHelper.filterRedactedProps(formSettings));
@@ -92,8 +68,6 @@
ApiClient.errorResponseHandler(err);
}
clearTimeout(testS3TimeoutId);
isSaving = false;
}
@@ -111,6 +85,39 @@
await testS3();
}
async function testS3() {
testS3Error = null;
if (!formSettings.s3.enabled) {
return; // nothing to test
}
// auto cancel the test request after 30sec
ApiClient.cancelRequest(testRequestKey);
clearTimeout(testS3TimeoutId);
testS3TimeoutId = setTimeout(() => {
ApiClient.cancelRequest(testRequestKey);
addErrorToast("S3 test connection timeout.");
}, 30000);
isTesting = true;
try {
await ApiClient.settings.testS3({ $cancelKey: testRequestKey });
} catch (err) {
testS3Error = err;
}
isTesting = false;
clearTimeout(testS3TimeoutId);
}
onMount(() => {
return () => {
clearTimeout(testS3TimeoutId);
};
});
</script>
<SettingsSidebar />
@@ -160,7 +167,7 @@
<a
href="https://github.com/rclone/rclone"
target="_blank"
rel="noopener"
rel="noopener noreferrer"
class="txt-bold"
>
rclone
@@ -168,7 +175,7 @@
<a
href="https://github.com/peak/s5cmd"
target="_blank"
rel="noopener"
rel="noopener noreferrer"
class="txt-bold"
>
s5cmd
@@ -8,10 +8,10 @@
import SettingsSidebar from "@/components/settings/SettingsSidebar.svelte";
const tokensList = [
{ key: "userAuthToken", label: "Users auth token" },
{ key: "userVerificationToken", label: "Users email verification token" },
{ key: "userPasswordResetToken", label: "Users password reset token" },
{ key: "userEmailChangeToken", label: "Users email change token" },
{ key: "recordAuthToken", label: "Auth record authentication token" },
{ key: "recordVerificationToken", label: "Auth record email verification token" },
{ key: "recordPasswordResetToken", label: "Auth record password reset token" },
{ key: "recordEmailChangeToken", label: "Auth record email change token" },
{ key: "adminAuthToken", label: "Admins auth token" },
{ key: "adminPasswordResetToken", label: "Admins password reset token" },
];
-305
View File
@@ -1,305 +0,0 @@
<script>
import { replace, querystring } from "svelte-spa-router";
import { Collection } from "pocketbase";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import { pageTitle, hideControls } from "@/stores/app";
import PageWrapper from "@/components/base/PageWrapper.svelte";
import Searchbar from "@/components/base/Searchbar.svelte";
import RefreshButton from "@/components/base/RefreshButton.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";
$pageTitle = "Users";
const queryParams = new URLSearchParams($querystring);
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.get("filter") || "";
let sort = queryParams.get("sort") || "-created";
let profileCollection = new Collection();
let isLoadingProfileCollection = false;
$: if (sort !== -1 && filter !== -1) {
// keep query params
const query = new URLSearchParams({ filter, sort }).toString();
replace("/users?" + query);
loadUsers();
}
$: canLoadMore = totalItems > users.length;
$: profileFields = profileCollection?.schema?.filter(
(field) => !excludedProfileFields.includes(field.name)
);
loadProfilesCollection();
export async function loadUsers(page = 1) {
isLoadingUsers = true;
if (page <= 1) {
clearList();
}
return ApiClient.users
.getList(page, 50, {
sort: sort || "-created",
filter: filter,
})
.then((result) => {
isLoadingUsers = false;
users = users.concat(result.items);
currentPage = result.page;
totalItems = result.totalItems;
})
.catch((err) => {
if (!err?.isAbort) {
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>
<PageWrapper>
{#if isLoadingProfileCollection}
<div class="placeholder-section m-b-base">
<span class="loader loader-lg" />
<h1>Loading users...</h1>
</div>
{:else}
<header class="page-header">
<nav class="breadcrumbs">
<div class="breadcrumb-item">{$pageTitle}</div>
</nav>
{#if !$hideControls}
<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>
{/if}
<RefreshButton on:refresh={() => loadUsers()} />
<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">
{#if user.email}
<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>
{:else}
<div class="txt-hint">N/A</div>
{#if user.verified}
<span class="label label-success">OAuth2 verified</span>
{/if}
{/if}
</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}
{/if}
</PageWrapper>
<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)}
/>
-123
View File
@@ -1,123 +0,0 @@
<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(true).then(() => {
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}"`);
}
const result = await ApiClient.users.getFullList(100, {
filter: filters.join("||"),
$cancelKey: uniqueId + "loadSelected",
});
// preserve selected order
selected = [];
for (const id of selectedIds) {
const item = CommonHelper.findByKey(result, "id", id);
if (item) {
selected.push(item);
}
}
// add the selected models to the list (if not already)
list = CommonHelper.filterDuplicatesByKey(selected.concat(list));
} 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 = CommonHelper.toArray(selected).slice();
}
list = CommonHelper.filterDuplicatesByKey(
list.concat(result.items, CommonHelper.toArray(selected))
);
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>
@@ -1,17 +0,0 @@
<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>
{#if item.email}
<small class="block txt-hint txt-ellipsis">{item.email}</small>
{/if}
</div>
@@ -1,308 +0,0 @@
<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";
import ExternalAuthsList from "./ExternalAuthsList.svelte";
const dispatch = createEventDispatcher();
const formId = "user_" + CommonHelper.randomString(5);
const accountTab = "account";
const providersTab = "providers";
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;
let activeTab = accountTab;
$: hasChanges = (user.isNew && email != "") || changePasswordToggle || email !== user.email;
export function show(model) {
load(model);
confirmClose = true;
activeTab = user.isNew || user.email ? accountTab : providersTab;
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) => {
user = 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.email || 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}
class="user-panel"
popup={user.isNew}
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>
{#if !user.isNew}
<button type="button" class="btn btn-sm btn-circle btn-secondary m-l-auto">
<!-- empty span for alignment -->
<span />
<i class="ri-more-line" />
<Toggler class="dropdown dropdown-right dropdown-nowrap">
{#if !user.verified && user.email}
<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>
{/if}
</svelte:fragment>
<div class="tabs user-tabs">
{#if !user.isNew}
<div class="tabs-header stretched">
<button
type="button"
class="tab-item"
class:active={activeTab === accountTab}
on:click={() => (activeTab = accountTab)}
>
Account
</button>
<button
type="button"
class="tab-item"
class:active={activeTab === providersTab}
on:click={() => (activeTab = providersTab)}
>
Authorized providers
</button>
</div>
{/if}
<div class="tabs-content">
<div class="tab-item" class:active={activeTab === accountTab}>
<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 && user.email}
<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 && user.email}
<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 || !user.email || changePasswordToggle}
<div class="col-12">
<div class="grid" transition:slide|local={{ 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 || !user.email}
<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>
</div>
{#if !user.isNew}
<div class="tab-item" class:active={activeTab === providersTab}>
<ExternalAuthsList {user} />
</div>
{/if}
</div>
</div>
<svelte:fragment slot="footer">
<button type="button" class="btn btn-secondary" disabled={isSaving} on:click={() => hide()}>
<span class="txt">Cancel</span>
</button>
{#if activeTab === accountTab}
<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>
{/if}
</svelte:fragment>
</OverlayPanel>