merge v0.23.0-rc changes

This commit is contained in:
Gani Georgiev
2024-09-29 19:23:19 +03:00
parent ad92992324
commit 844f18cac3
753 changed files with 85141 additions and 63396 deletions
+3 -3
View File
@@ -1,9 +1,9 @@
<script>
import { tick } from "svelte";
import { replace } from "svelte-spa-router";
import ApiClient from "@/utils/ApiClient";
import FullPage from "@/components/base/FullPage.svelte";
import Installer from "@/components/base/Installer.svelte";
import ApiClient from "@/utils/ApiClient";
import { tick } from "svelte";
import { replace } from "svelte-spa-router";
let showInstaller = false;
@@ -1,270 +0,0 @@
<script>
import { createEventDispatcher } from "svelte";
import { slide } from "svelte/transition";
import CommonHelper from "@/utils/CommonHelper";
import ApiClient from "@/utils/ApiClient";
import { setErrors } from "@/stores/errors";
import { confirm } from "@/stores/confirmation";
import { addSuccessToast } from "@/stores/toasts";
import Field from "@/components/base/Field.svelte";
import Toggler from "@/components/base/Toggler.svelte";
import ModelDateIcon from "@/components/base/ModelDateIcon.svelte";
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
import SecretGeneratorButton from "@/components/base/SecretGeneratorButton.svelte";
const dispatch = createEventDispatcher();
const formId = "admin_" + CommonHelper.randomString(5);
let panel;
let admin = {};
let isSaving = false;
let confirmClose = false; // prevent close recursion
let avatar = 0;
let email = "";
let password = "";
let passwordConfirm = "";
let changePasswordToggle = false;
$: isNew = !admin?.id;
$: hasChanges =
(isNew && email != "") || changePasswordToggle || email !== admin.email || avatar !== admin.avatar;
export function show(model) {
load(model);
confirmClose = true;
return panel?.show();
}
export function hide() {
return panel?.hide();
}
function load(model) {
admin = structuredClone(model || {});
reset(); // reset form
}
function reset() {
changePasswordToggle = false;
email = admin?.email || "";
avatar = admin?.avatar || 0;
password = "";
passwordConfirm = "";
setErrors({}); // reset errors
}
function save() {
if (isSaving || !hasChanges) {
return;
}
isSaving = true;
const data = { email, avatar };
if (isNew || changePasswordToggle) {
data["password"] = password;
data["passwordConfirm"] = passwordConfirm;
}
let request;
if (isNew) {
request = ApiClient.admins.create(data);
} else {
request = ApiClient.admins.update(admin.id, data);
}
request
.then(async (result) => {
confirmClose = false;
hide();
addSuccessToast(isNew ? "Successfully created admin." : "Successfully updated admin.");
if (ApiClient.authStore.model?.id === result.id) {
ApiClient.authStore.save(ApiClient.authStore.token, result);
}
dispatch("save", result);
})
.catch((err) => {
ApiClient.error(err);
})
.finally(() => {
isSaving = false;
});
}
function deleteConfirm() {
if (!admin?.id) {
return; // nothing to delete
}
confirm(`Do you really want to delete the selected admin?`, () => {
return ApiClient.admins
.delete(admin.id)
.then(() => {
confirmClose = false;
hide();
addSuccessToast("Successfully deleted admin.");
dispatch("delete", admin);
})
.catch((err) => {
ApiClient.error(err);
});
});
}
</script>
<OverlayPanel
bind:this={panel}
popup
class="admin-panel"
beforeHide={() => {
if (hasChanges && confirmClose) {
confirm("You have unsaved changes. Do you really want to close the panel?", () => {
confirmClose = false;
hide();
});
return false;
}
return true;
}}
on:hide
on:show
>
<svelte:fragment slot="header">
<h4>
{isNew ? "New admin" : "Edit admin"}
</h4>
</svelte:fragment>
<form id={formId} class="grid" autocomplete="off" on:submit|preventDefault={save}>
{#if !isNew}
<Field class="form-field readonly" name="id" let:uniqueId>
<label for={uniqueId}>
<i class={CommonHelper.getFieldTypeIcon("primary")} />
<span class="txt">id</span>
</label>
<div class="form-field-addon">
<ModelDateIcon model={admin} />
</div>
<input type="text" id={uniqueId} value={admin.id} readonly />
</Field>
{/if}
<div class="content">
<p class="section-title">Avatar</p>
<div class="flex flex-gap-xs flex-wrap">
{#each [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] as index}
<button
type="button"
class="link-fade thumb thumb-circle {index == avatar ? 'thumb-primary' : 'thumb-sm'}"
on:click={() => (avatar = index)}
>
<img
src="{import.meta.env.BASE_URL}images/avatars/avatar{index}.svg"
alt="Avatar {index}"
/>
</button>
{/each}
</div>
</div>
<Field class="form-field required" name="email" let:uniqueId>
<label for={uniqueId}>
<i class={CommonHelper.getFieldTypeIcon("email")} />
<span class="txt">Email</span>
</label>
<input type="email" autocomplete="off" id={uniqueId} required bind:value={email} />
</Field>
{#if !isNew}
<Field class="form-field form-field-toggle" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={changePasswordToggle} />
<label for={uniqueId}>Change password</label>
</Field>
{/if}
{#if isNew || changePasswordToggle}
<div class="col-12">
<div class="grid" transition:slide={{ duration: 150 }}>
<div class="col-sm-6">
<Field class="form-field required" name="password" let:uniqueId>
<label for={uniqueId}>
<i class="ri-lock-line" />
<span class="txt">Password</span>
</label>
<input
type="password"
autocomplete="new-password"
id={uniqueId}
required
bind:value={password}
/>
<div class="form-field-addon">
<SecretGeneratorButton />
</div>
</Field>
</div>
<div class="col-sm-6">
<Field class="form-field required" name="passwordConfirm" let:uniqueId>
<label for={uniqueId}>
<i class="ri-lock-line" />
<span class="txt">Password confirm</span>
</label>
<input
type="password"
autocomplete="new-password"
id={uniqueId}
required
bind:value={passwordConfirm}
/>
</Field>
</div>
</div>
</div>
{/if}
</form>
<svelte:fragment slot="footer">
{#if !isNew}
<div
tabindex="0"
role="button"
aria-label="More admin options"
class="btn btn-sm btn-circle btn-transparent"
>
<!-- empty span for alignment -->
<span aria-hidden="true" />
<i class="ri-more-line" aria-hidden="true" />
<Toggler class="dropdown dropdown-upside dropdown-left dropdown-nowrap">
<button
type="button"
class="dropdown-item txt-danger"
role="menuitem"
on:click={() => deleteConfirm()}
>
<i class="ri-delete-bin-7-line" aria-hidden="true" />
<span class="txt">Delete</span>
</button>
</Toggler>
</div>
<div class="flex-fill" />
{/if}
<button type="button" class="btn btn-transparent" disabled={isSaving} on:click={() => hide()}>
<span class="txt">Cancel</span>
</button>
<button
type="submit"
form={formId}
class="btn btn-expanded"
class:btn-loading={isSaving}
disabled={!hasChanges || isSaving}
>
<span class="txt">{isNew ? "Create" : "Save changes"}</span>
</button>
</svelte:fragment>
</OverlayPanel>
@@ -1,66 +0,0 @@
<script>
import { link, replace, querystring } from "svelte-spa-router";
import FullPage from "@/components/base/FullPage.svelte";
import ApiClient from "@/utils/ApiClient";
import Field from "@/components/base/Field.svelte";
import { addErrorToast, removeAllToasts } from "@/stores/toasts";
const queryParams = new URLSearchParams($querystring);
let email = queryParams.get("demoEmail") || "";
let password = queryParams.get("demoPassword") || "";
let isLoading = false;
function login() {
if (isLoading) {
return;
}
isLoading = true;
return ApiClient.admins
.authWithPassword(email, password)
.then(() => {
removeAllToasts();
replace("/");
})
.catch(() => {
addErrorToast("Invalid login credentials.");
})
.finally(() => {
isLoading = false;
});
}
</script>
<FullPage>
<form class="block" on:submit|preventDefault={login}>
<div class="content txt-center m-b-base">
<h4>Admin sign in</h4>
</div>
<Field class="form-field required" name="identity" let:uniqueId>
<label for={uniqueId}>Email</label>
<!-- svelte-ignore a11y-autofocus -->
<input type="email" id={uniqueId} bind:value={email} required autofocus />
</Field>
<Field class="form-field required" name="password" let:uniqueId>
<label for={uniqueId}>Password</label>
<input type="password" id={uniqueId} bind:value={password} required />
<div class="help-block">
<a href="/request-password-reset" class="link-hint" use:link>Forgotten password?</a>
</div>
</Field>
<button
type="submit"
class="btn btn-lg btn-block btn-next"
class:btn-disabled={isLoading}
class:btn-loading={isLoading}
>
<span class="txt">Login</span>
<i class="ri-arrow-right-line" />
</button>
</form>
</FullPage>
-221
View File
@@ -1,221 +0,0 @@
<script>
import { replace, querystring } from "svelte-spa-router";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import { pageTitle } from "@/stores/app";
import { admin as loggedAdmin } from "@/stores/admin";
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 FormattedDate from "@/components/base/FormattedDate.svelte";
import Scroller from "@/components/base/Scroller.svelte";
import CopyIcon from "@/components/base/CopyIcon.svelte";
import SettingsSidebar from "@/components/settings/SettingsSidebar.svelte";
import AdminUpsertPanel from "@/components/admins/AdminUpsertPanel.svelte";
$pageTitle = "Admins";
const queryParams = new URLSearchParams($querystring);
let adminUpsertPanel;
let admins = [];
let isLoading = false;
let filter = queryParams.get("filter") || "";
let sort = queryParams.get("sort") || "-created";
$: if (sort !== -1 && filter !== -1) {
// keep listing params in sync
const query = new URLSearchParams({ filter, sort }).toString();
replace("/settings/admins?" + query);
loadAdmins();
}
export function loadAdmins() {
isLoading = true;
admins = []; // reset
const normalizedFilter = CommonHelper.normalizeSearchFilter(filter, [
"id",
"email",
"created",
"updated",
]);
return ApiClient.admins
.getFullList(100, {
sort: sort || "-created",
filter: normalizedFilter,
})
.then((result) => {
admins = result;
isLoading = false;
})
.catch((err) => {
if (!err?.isAbort) {
isLoading = false;
console.warn(err);
clearList();
ApiClient.error(err, !normalizedFilter || err?.status != 400); // silence filter errors
}
});
}
function clearList() {
admins = [];
}
</script>
<SettingsSidebar />
<PageWrapper>
<header class="page-header">
<nav class="breadcrumbs">
<div class="breadcrumb-item">Settings</div>
<div class="breadcrumb-item">{$pageTitle}</div>
</nav>
<RefreshButton on:refresh={() => loadAdmins()} />
<div class="flex-fill" />
<div class="btns-group">
<button type="button" class="btn btn-expanded" on:click={() => adminUpsertPanel?.show()}>
<i class="ri-add-line" />
<span class="txt">New admin</span>
</button>
</div>
</header>
<Searchbar
value={filter}
placeholder={"Search term or filter like email='test@example.com'"}
extraAutocompleteKeys={["email"]}
on:submit={(e) => (filter = e.detail)}
/>
<div class="clearfix m-b-base" />
<Scroller class="table-wrapper">
<table class="table" class:table-loading={isLoading}>
<thead>
<tr>
<th class="min-width" />
<SortHeader class="col-type-text" name="id" bind:sort>
<div class="col-header-content">
<i class={CommonHelper.getFieldTypeIcon("primary")} />
<span class="txt">id</span>
</div>
</SortHeader>
<SortHeader class="col-type-email col-field-email" name="email" bind:sort>
<div class="col-header-content">
<i class={CommonHelper.getFieldTypeIcon("email")} />
<span class="txt">email</span>
</div>
</SortHeader>
<SortHeader class="col-type-date col-field-created" name="created" bind:sort>
<div class="col-header-content">
<i class={CommonHelper.getFieldTypeIcon("date")} />
<span class="txt">created</span>
</div>
</SortHeader>
<SortHeader class="col-type-date col-field-updated" name="updated" bind:sort>
<div class="col-header-content">
<i class={CommonHelper.getFieldTypeIcon("date")} />
<span class="txt">updated</span>
</div>
</SortHeader>
<th class="col-type-action min-width" />
</tr>
</thead>
<tbody>
{#each admins as admin (admin.id)}
<tr
tabindex="0"
class="row-handle"
on:click={() => adminUpsertPanel?.show(admin)}
on:keydown={(e) => {
if (e.code === "Enter" || e.code === "Space") {
e.preventDefault();
adminUpsertPanel?.show(admin);
}
}}
>
<td class="min-width">
<figure class="thumb thumb-sm thumb-circle">
<img
src="{import.meta.env.BASE_URL}images/avatars/avatar{admin.avatar ||
0}.svg"
alt="Admin avatar"
/>
</figure>
</td>
<td class="col-type-text col-field-id">
<div class="label">
<CopyIcon value={admin.id} />
<span class="txt">{admin.id}</span>
</div>
{#if admin.id === $loggedAdmin.id}
<span class="label label-warning m-l-5">You</span>
{/if}
</td>
<td class="col-type-email col-field-email">
<span class="txt txt-ellipsis" title={admin.email}>
{admin.email}
</span>
</td>
<td class="col-type-date col-field-created">
<FormattedDate date={admin.created} />
</td>
<td class="col-type-date col-field-updated">
<FormattedDate date={admin.updated} />
</td>
<td class="col-type-action min-width">
<i class="ri-arrow-right-line" />
</td>
</tr>
{:else}
{#if isLoading}
<tr>
<td colspan="99" class="p-xs">
<span class="skeleton-loader m-0" />
</td>
</tr>
{:else}
<tr>
<td colspan="99" class="txt-center txt-hint p-xs">
<h6>No admins found.</h6>
{#if filter?.length}
<button
type="button"
class="btn btn-hint btn-expanded m-t-sm"
on:click={() => (filter = "")}
>
<span class="txt">Clear filters</span>
</button>
{/if}
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</Scroller>
<svelte:fragment slot="footer">
<div class="m-r-auto txt-sm txt-hint">Total found: {admins.length}</div>
</svelte:fragment>
</PageWrapper>
<AdminUpsertPanel bind:this={adminUpsertPanel} on:save={() => loadAdmins()} on:delete={() => loadAdmins()} />
+1 -1
View File
@@ -107,7 +107,7 @@
</button>
{#if active}
<div class="accordion-content" transition:slide={{ duration: 150 }}>
<div class="accordion-content" transition:slide={{ delay: 10, duration: 150 }}>
<slot />
</div>
{/if}
@@ -0,0 +1,24 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
export let value = "";
export let options = []; // [{label: "Option 1", value: "opt1"}, {label: "Option 2", value: "opt2"}, ...]
const uniqueId = "list_" + CommonHelper.randomString(5);
</script>
<input
type={$$restProps.type || "text"}
list={uniqueId}
{value}
on:input={(e) => {
value = e.target.value;
}}
{...$$restProps}
/>
<datalist id={uniqueId}>
{#each options as opt}
<option value={opt.value}>{opt.label || ""}</option>
{/each}
</datalist>
+3 -2
View File
@@ -34,7 +34,7 @@
JSON.stringify({
index: i,
group: group,
})
}),
);
dispatch("drag", e);
@@ -79,6 +79,7 @@
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
draggable={!disabled}
class="draggable"
@@ -102,7 +103,7 @@
<style>
.draggable {
user-select: none;
user-select: text;
outline: 0;
min-width: 0;
}
@@ -109,7 +109,7 @@
addLabelListeners();
}
$: if (editor && baseCollection?.schema) {
$: if (editor && baseCollection?.fields) {
editor.dispatch({
effects: [langCompartment.reconfigure(ruleLang())],
});
@@ -172,7 +172,7 @@
// Return a collection keys hash string that can be used to compare with previous states.
function getCollectionKeysChangeHash(collection) {
return JSON.stringify([collection?.name, collection?.type, collection?.schema]);
return JSON.stringify([collection?.name, collection?.type, collection?.fields]);
}
// Merge the base collection in a new list with the provided collections.
+88 -7
View File
@@ -1,6 +1,8 @@
<script>
import { createEventDispatcher } from "svelte";
import ApiClient from "@/utils/ApiClient";
import { addInfoToast } from "@/stores/toasts";
import { confirm } from "@/stores/confirmation";
import Field from "@/components/base/Field.svelte";
const dispatch = createEventDispatcher();
@@ -9,22 +11,27 @@
let password = "";
let passwordConfirm = "";
let isLoading = false;
let isUploading = false;
let backupFileInput;
$: isBusy = isLoading || isUploading;
async function submit() {
if (isLoading) {
if (isBusy) {
return;
}
isLoading = true;
try {
await ApiClient.admins.create({
await ApiClient.collection("_superusers").create({
email,
password,
passwordConfirm,
});
await ApiClient.admins.authWithPassword(email, password);
await ApiClient.collection("_superusers").authWithPassword(email, password);
dispatch("submit");
} catch (err) {
@@ -33,11 +40,61 @@
isLoading = false;
}
function resetSelectedBackupFile() {
if (backupFileInput) {
backupFileInput.value = "";
}
}
function uploadBackupConfirm(file) {
if (!file) {
return;
}
confirm(
`Note that we don't perform validations for the uploaded backup files. Proceed with caution and only if you trust the file source.\n\n` +
`Do you really want to upload and initialize "${file.name}"?`,
() => {
uploadBackup(file);
},
() => {
resetSelectedBackupFile();
},
);
}
async function uploadBackup(file) {
if (!file || isBusy) {
return;
}
isUploading = true;
try {
await ApiClient.backups.upload({ file: file });
await ApiClient.backups.restore(file.name);
addInfoToast("Please wait while extracting the uploaded archive!");
// optimistic restore completion
await new Promise((r) => setTimeout(r, 2000));
dispatch("submit");
} catch (err) {
ApiClient.error(err);
}
resetSelectedBackupFile();
isUploading = false;
}
</script>
<form class="block" autocomplete="off" on:submit|preventDefault={submit}>
<div class="content txt-center m-b-base">
<h4>Create your first admin account in order to continue</h4>
<h4>Create your first superuser account in order to continue</h4>
</div>
<Field class="form-field required" name="email" let:uniqueId>
@@ -56,7 +113,7 @@
bind:value={password}
required
/>
<div class="help-block">Minimum 10 characters.</div>
<div class="help-block">Recommended at least 10 characters.</div>
</Field>
<Field class="form-field required" name="passwordConfirm" let:uniqueId>
@@ -67,10 +124,34 @@
<button
type="submit"
class="btn btn-lg btn-block btn-next"
class:btn-disabled={isLoading}
class:btn-disabled={isBusy}
class:btn-loading={isLoading}
>
<span class="txt">Create and login</span>
<span class="txt">Create superuser and login</span>
<i class="ri-arrow-right-line" />
</button>
</form>
<hr />
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<label
for="backupFileInput"
class="btn btn-lg btn-hint btn-transparent btn-block"
class:btn-disabled={isBusy}
class:btn-loading={isUploading}
>
<i class="ri-upload-cloud-line" />
<span class="txt">Or initialize from backup</span>
</label>
<input
bind:this={backupFileInput}
id="backupFileInput"
type="file"
class="hidden"
accept=".zip"
on:change={(e) => {
uploadBackupConfirm(e.target?.files?.[0]);
}}
/>
@@ -1,38 +0,0 @@
<script>
import tooltip from "@/actions/tooltip";
import CommonHelper from "@/utils/CommonHelper";
const detailedDateFormat = "yyyy-MM-dd HH:mm:ss.SSS";
export let model;
let tooltipDates = [];
$: if (model) {
refreshTooltipDates();
}
function refreshTooltipDates() {
tooltipDates = [];
if (model.created) {
tooltipDates.push(
"Created: " + CommonHelper.formatToLocalDate(model.created, detailedDateFormat) + " Local"
);
}
if (model.updated) {
tooltipDates.push(
"Updated: " + CommonHelper.formatToLocalDate(model.updated, detailedDateFormat) + " Local"
);
}
}
</script>
<i
class="ri-calendar-event-line txt-disabled"
use:tooltip={{
text: tooltipDates.join("\n"),
position: "left",
}}
/>
+2 -2
View File
@@ -1,7 +1,7 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import Select from "@/components/base/Select.svelte";
import BaseSelectOption from "@/components/base/BaseSelectOption.svelte";
import Select from "@/components/base/Select.svelte";
import CommonHelper from "@/utils/CommonHelper";
// original select props
export let items = [];
+1 -1
View File
@@ -2,7 +2,7 @@
import CommonHelper from "@/utils/CommonHelper";
import Dragline from "@/components/base/Dragline.svelte";
const widthStorageKey = "@adminSidebarWidth";
const widthStorageKey = "@superuserSidebarWidth";
let classes = "";
export { classes as class }; // export reserved keyword
+12 -8
View File
@@ -1,4 +1,6 @@
<script>
import { superuser } from "@/stores/superuser";
export let center = false;
let classes = "";
@@ -13,13 +15,15 @@
<footer class="page-footer">
<slot name="footer" />
<a href={import.meta.env.PB_DOCS_URL} target="_blank" rel="noopener noreferrer">
<i class="ri-book-open-line txt-sm" />
<span class="txt">Docs</span>
</a>
<span class="delimiter">|</span>
<a href={import.meta.env.PB_RELEASES} target="_blank" rel="noopener noreferrer" title="Releases">
<span class="txt">PocketBase {import.meta.env.PB_VERSION}</span>
</a>
{#if $superuser?.id}
<a href={import.meta.env.PB_DOCS_URL} target="_blank" rel="noopener noreferrer">
<i class="ri-book-open-line txt-sm" />
<span class="txt">Docs</span>
</a>
<span class="delimiter">|</span>
<a href={import.meta.env.PB_RELEASES} target="_blank" rel="noopener noreferrer" title="Releases">
<span class="txt">PocketBase {import.meta.env.PB_VERSION}</span>
</a>
{/if}
</footer>
</div>
@@ -2,35 +2,32 @@
import { tick } from "svelte";
import tooltip from "@/actions/tooltip";
export let mask = false;
export let value = "";
export let mask = "******";
let inputElem;
let locked = false;
$: locked = value === mask;
async function unlock() {
value = "";
locked = false;
mask = false;
await tick();
inputElem?.focus();
}
</script>
{#if locked}
{#if mask}
<div class="form-field-addon">
<button
type="button"
class="btn btn-transparent btn-circle"
use:tooltip={{ position: "left", text: "Set new value" }}
on:click={() => unlock()}
on:click|preventDefault={unlock}
>
<i class="ri-key-line" />
</button>
</div>
<input readonly type="text" placeholder={mask} {...$$restProps} />
<input readonly type="text" placeholder="******" {...$$restProps} />
{:else}
<input bind:this={inputElem} bind:value type="password" autocomplete="new-password" {...$$restProps} />
{/if}
+2 -2
View File
@@ -1,7 +1,7 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import { createEventDispatcher, onMount } from "svelte";
import { fly } from "svelte/transition";
import CommonHelper from "@/utils/CommonHelper";
const dispatch = createEventDispatcher();
const uniqueId = "search_" + CommonHelper.randomString(7);
@@ -10,7 +10,7 @@
export let placeholder = 'Search term or filter like created > "2022-01-01"...';
// autocomplete filter component fields
export let autocompleteCollection = CommonHelper.initCollection();
export let autocompleteCollection = null;
export let extraAutocompleteKeys = [];
let filterComponent;
+16 -4
View File
@@ -1,8 +1,8 @@
<script>
import { onMount, createEventDispatcher } from "svelte";
import tooltip from "@/actions/tooltip";
import Toggler from "@/components/base/Toggler.svelte";
import CommonHelper from "@/utils/CommonHelper";
import { onMount } from "svelte";
export let id = "";
export let noOptionsText = "No options found";
@@ -13,7 +13,8 @@
export let disabled = false;
export let readonly = false;
export let upside = false;
export let selected = multiple ? [] : undefined;
export let zeroFunc = () => (multiple ? [] : undefined);
export let selected = zeroFunc();
export let toggle = multiple; // toggle option on click
export let closable = true; // close the dropdown on option select/deselect
export let labelComponent = undefined; // custom component to use for each selected option label
@@ -23,6 +24,8 @@
export let searchable = false; // whether to show the dropdown options search input
export let searchFunc = undefined; // custom search option filter: `function(item, searchTerm):boolean`
const dispatch = createEventDispatcher();
let classes = "";
export { classes as class }; // export reserved keyword
@@ -54,9 +57,11 @@
let normalized = CommonHelper.toArray(selected);
if (CommonHelper.inArray(normalized, item)) {
CommonHelper.removeByValue(normalized, item);
selected = normalized;
selected = multiple ? normalized : normalized?.[0] || zeroFunc();
}
dispatch("change", { selected });
// emulate native change event
container?.dispatchEvent(new CustomEvent("change", { detail: selected, bubbles: true }));
}
@@ -71,6 +76,8 @@
selected = item;
}
dispatch("change", { selected });
// emulate native change event
container?.dispatchEvent(new CustomEvent("change", { detail: selected, bubbles: true }));
}
@@ -80,7 +87,12 @@
}
export function reset() {
selected = multiple ? [] : undefined;
selected = zeroFunc();
dispatch("change", { selected });
// emulate native change event
container?.dispatchEvent(new CustomEvent("change", { detail: selected, bubbles: true }));
}
export function showDropdown() {
@@ -1,237 +1,127 @@
<script>
import { scale, slide } from "svelte/transition";
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";
import EmailTemplateAccordion from "@/components/collections/EmailTemplateAccordion.svelte";
import TokenOptionsAccordion from "@/components/collections/TokenOptionsAccordion.svelte";
import MFAAccordion from "@/components/collections/MFAAccordion.svelte";
import OAuth2Accordion from "@/components/collections/OAuth2Accordion.svelte";
import OTPAccordion from "@/components/collections/OTPAccordion.svelte";
import PasswordAuthAccordion from "@/components/collections/PasswordAuthAccordion.svelte";
import EmailTestPopup from "@/components/settings/EmailTestPopup.svelte";
export let collection;
$: if (collection.type === "auth" && CommonHelper.isEmpty(collection.options)) {
collection.options = {
allowEmailAuth: true,
allowUsernameAuth: true,
allowOAuth2Auth: true,
minPasswordLength: 8,
};
let emailTemplatesList = [];
let emailTestPopup;
$: isSuperusers = collection.system && collection.name === "_superusers";
// nested email template normalizations
$: if (typeof collection.otp?.emailTemplate == "undefined") {
collection.otp = collection.otp || {};
collection.otp.emailTemplate = {};
}
$: if (typeof collection.authAlert?.emailTemplate == "undefined") {
collection.authAlert = collection.authAlert || {};
collection.authAlert.emailTemplate = {};
}
$: hasUsernameErrors = false;
$: hasEmailErrors =
!CommonHelper.isEmpty($errors?.options?.allowEmailAuth) ||
!CommonHelper.isEmpty($errors?.options?.onlyEmailDomains) ||
!CommonHelper.isEmpty($errors?.options?.exceptEmailDomains);
$: hasOAuth2Errors = !CommonHelper.isEmpty($errors?.options?.allowOAuth2Auth);
// predefined email template configs
$: resetPasswordTemplate = {
key: "resetPasswordTemplate",
label: "Default Password reset email template",
placeholders: ["APP_NAME", "APP_URL", "RECORD:*", "TOKEN"],
config: collection.resetPasswordTemplate,
};
$: verificationTemplate = {
key: "verificationTemplate",
label: "Default Verification email template",
placeholders: ["APP_NAME", "APP_URL", "RECORD:*", "TOKEN"],
config: collection.verificationTemplate,
};
$: confirmEmailChangeTemplate = {
key: "confirmEmailChangeTemplate",
label: "Default Confirm email change email template",
placeholders: ["APP_NAME", "APP_URL", "RECORD:*", "TOKEN"],
config: collection.confirmEmailChangeTemplate,
};
$: otpTemplate = {
key: "otp.emailTemplate",
label: "Default OTP email template",
placeholders: ["APP_NAME", "APP_URL", "RECORD:*", "OTP", "OTP_ID"],
config: collection.otp.emailTemplate,
};
$: authAlertTemplate = {
key: "authAlert.emailTemplate",
label: "Default Login alert email template",
placeholders: ["APP_NAME", "APP_URL", "RECORD:*"],
config: collection.authAlert.emailTemplate,
};
$: emailTemplatesList = isSuperusers
? [resetPasswordTemplate, otpTemplate, authAlertTemplate]
: [
verificationTemplate,
resetPasswordTemplate,
confirmEmailChangeTemplate,
otpTemplate,
authAlertTemplate,
];
</script>
<h4 class="section-title">Auth methods</h4>
<h4 class="section-title">
<div class="flex">
<span class="txt">Auth methods</span>
<div class="m-l-auto handle">
<Field
class="form-field form-field-sm form-field-toggle m-0"
name="authAlert.enabled"
inlineError={true}
let:uniqueId
>
<input type="checkbox" id={uniqueId} bind:checked={collection.authAlert.enabled} />
<label for={uniqueId}>Send email alert for new logins</label>
</Field>
</div>
</div>
</h4>
<div class="accordions m-b-35">
<OTPAccordion bind:collection />
<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>
<PasswordAuthAccordion bind:collection />
<div class="flex-fill" />
{#if !isSuperusers}
<OAuth2Accordion bind:collection />
{/if}
{#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={{ 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={{ 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>
<MFAAccordion bind:collection />
</div>
<hr />
<h4 class="section-title">
<span class="txt">Mail templates</span>
<button
type="button"
class="btn btn-xs m-l-auto btn-secondary"
on:click={() => emailTestPopup?.show(collection.id)}
>
Send test email
</button>
</h4>
<div class="accordions m-b-35">
<div class="accordions">
{#each emailTemplatesList as template (template.key)}
<EmailTemplateAccordion
single
key={template.key}
title={template.label}
placeholders={template?.placeholders}
bind:config={template.config}
/>
{/each}
</div>
</div>
<h4 class="section-title">General</h4>
<h4 class="section-title">Other</h4>
<div class="accordions m-b-base">
<TokenOptionsAccordion bind:collection />
</div>
<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>
<Field class="form-field form-field-toggle m-b-sm" name="options.onlyVerified" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={collection.options.onlyVerified} />
<label for={uniqueId}>
<span class="txt">Forbid authentication for unverified users</span>
<i
class="ri-information-line txt-sm link-hint"
use:tooltip={{
text: [
"If enabled, it returns 403 for new unverified user authentication requests.",
"If you need more granular control, don't enable this option and instead use the `@request.auth.verified = true` rule in the specific collection(s) you are targeting.",
].join("\n"),
position: "right",
}}
/>
</label>
</Field>
<EmailTestPopup bind:this={emailTestPopup} />
@@ -29,6 +29,14 @@
};
const authTabs = {
"list-auth-methods": {
label: "List auth methods",
component: import("@/components/collections/docs/AuthMethodsDocs.svelte"),
},
refresh: {
label: "Auth refresh",
component: import("@/components/collections/docs/AuthRefreshDocs.svelte"),
},
"auth-with-password": {
label: "Auth with password",
component: import("@/components/collections/docs/AuthWithPasswordDocs.svelte"),
@@ -37,45 +45,21 @@
label: "Auth with OAuth2",
component: import("@/components/collections/docs/AuthWithOAuth2Docs.svelte"),
},
refresh: {
label: "Auth refresh",
component: import("@/components/collections/docs/AuthRefreshDocs.svelte"),
"auth-with-otp": {
label: "Auth with OTP",
component: import("@/components/collections/docs/AuthWithOtpDocs.svelte"),
},
"request-verification": {
label: "Request verification",
component: import("@/components/collections/docs/RequestVerificationDocs.svelte"),
verification: {
label: "Verification",
component: import("@/components/collections/docs/VerificationDocs.svelte"),
},
"confirm-verification": {
label: "Confirm verification",
component: import("@/components/collections/docs/ConfirmVerificationDocs.svelte"),
"password-reset": {
label: "Password reset",
component: import("@/components/collections/docs/PasswordResetDocs.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"),
"email-change": {
label: "Email change",
component: import("@/components/collections/docs/EmailChangeDocs.svelte"),
},
};
@@ -86,12 +70,15 @@
$: if (collection.type === "auth") {
tabs = Object.assign({}, baseTabs, authTabs);
if (!collection.options.allowUsernameAuth && !collection.options.allowEmailAuth) {
if (!collection.passwordAuth.enabled) {
delete tabs["auth-with-password"];
}
if (!collection.options.allowOAuth2Auth) {
if (!collection.oauth2.enabled) {
delete tabs["auth-with-oauth2"];
}
if (!collection.otp.enabled) {
delete tabs["auth-with-otp"];
}
} else if (collection.type === "view") {
tabs = Object.assign({}, baseTabs);
delete tabs.create;
@@ -1,23 +1,28 @@
<script>
import { setErrors } from "@/stores/errors";
import CommonHelper from "@/utils/CommonHelper";
import Draggable from "@/components/base/Draggable.svelte";
import IndexesList from "@/components/collections/IndexesList.svelte";
import NewField from "@/components/collections/schema/NewField.svelte";
import SchemaFieldText from "@/components/collections/schema/SchemaFieldText.svelte";
import SchemaFieldNumber from "@/components/collections/schema/SchemaFieldNumber.svelte";
import SchemaFieldAutodate from "@/components/collections/schema/SchemaFieldAutodate.svelte";
import SchemaFieldBool from "@/components/collections/schema/SchemaFieldBool.svelte";
import SchemaFieldEmail from "@/components/collections/schema/SchemaFieldEmail.svelte";
import SchemaFieldUrl from "@/components/collections/schema/SchemaFieldUrl.svelte";
import SchemaFieldEditor from "@/components/collections/schema/SchemaFieldEditor.svelte";
import SchemaFieldDate from "@/components/collections/schema/SchemaFieldDate.svelte";
import SchemaFieldSelect from "@/components/collections/schema/SchemaFieldSelect.svelte";
import SchemaFieldJson from "@/components/collections/schema/SchemaFieldJson.svelte";
import SchemaFieldEditor from "@/components/collections/schema/SchemaFieldEditor.svelte";
import SchemaFieldEmail from "@/components/collections/schema/SchemaFieldEmail.svelte";
import SchemaFieldFile from "@/components/collections/schema/SchemaFieldFile.svelte";
import SchemaFieldJson from "@/components/collections/schema/SchemaFieldJson.svelte";
import SchemaFieldNumber from "@/components/collections/schema/SchemaFieldNumber.svelte";
import SchemaFieldPassword from "@/components/collections/schema/SchemaFieldPassword.svelte";
import SchemaFieldRelation from "@/components/collections/schema/SchemaFieldRelation.svelte";
import Draggable from "@/components/base/Draggable.svelte";
import SchemaFieldSelect from "@/components/collections/schema/SchemaFieldSelect.svelte";
import SchemaFieldText from "@/components/collections/schema/SchemaFieldText.svelte";
import SchemaFieldUrl from "@/components/collections/schema/SchemaFieldUrl.svelte";
import { scaffolds } from "@/stores/collections";
import { setErrors } from "@/stores/errors";
import CommonHelper from "@/utils/CommonHelper";
export let collection;
let oldCollectionType;
const fieldComponents = {
text: SchemaFieldText,
number: SchemaFieldNumber,
@@ -30,23 +35,30 @@
json: SchemaFieldJson,
file: SchemaFieldFile,
relation: SchemaFieldRelation,
password: SchemaFieldPassword,
autodate: SchemaFieldAutodate,
};
$: if (typeof collection.schema === "undefined") {
collection.schema = [];
$: if (!collection.id && oldCollectionType != collection.type) {
oldCollectionType = collection.type;
onTypeCange();
}
$: nonDeletedFields = collection.schema.filter((f) => !f.toDelete) || [];
$: if (typeof collection.fields === "undefined") {
collection.fields = [];
}
$: nonDeletedFields = collection.fields.filter((f) => !f._toDelete);
function removeField(fieldIndex) {
if (collection.schema[fieldIndex]) {
collection.schema.splice(fieldIndex, 1);
collection.schema = collection.schema;
if (collection.fields[fieldIndex]) {
collection.fields.splice(fieldIndex, 1);
collection.fields = collection.fields;
}
}
function duplicateField(fieldIndex) {
const field = collection.schema[fieldIndex];
const field = collection.fields[fieldIndex];
if (!field) {
return; // nothing to duplicate
}
@@ -55,11 +67,12 @@
const clone = structuredClone(field);
clone.id = "";
clone.system = false;
clone.name = getUniqueFieldName(clone.name + "_copy");
clone.onMountSelect = true;
collection.schema.splice(fieldIndex + 1, 0, clone);
collection.schema = collection.schema;
collection.fields.splice(fieldIndex + 1, 0, clone);
collection.fields = collection.fields;
}
function newField(fieldType = "text") {
@@ -70,8 +83,16 @@
field.onMountSelect = true;
collection.schema.push(field);
collection.schema = collection.schema;
// if the collection has created/updated last fields,
// insert before the first autodate field, otherwise - append
const idx = collection.fields.findLastIndex((f) => f.type != "autodate");
if (field.type != "autodate" && idx >= 0) {
collection.fields.splice(idx + 1, 0, field);
} else {
collection.fields.push(field);
}
collection.fields = collection.fields;
}
function getUniqueFieldName(name = "field") {
@@ -92,7 +113,7 @@
}
function hasFieldWithName(name) {
return !!collection?.schema?.find((field) => field.name === name);
return !!collection?.fields?.find((field) => field.name === name);
}
function getSchemaFieldIndex(field) {
@@ -100,12 +121,12 @@
}
function replaceIndexesColumn(oldName, newName) {
if (!collection?.schema?.length || oldName === newName || !newName) {
if (!collection?.fields?.length || oldName === newName || !newName) {
return;
}
// field with the old name exists so there is no need to rename index columns
if (!!collection?.schema?.find((f) => f.name == oldName && !f.toDelete)) {
if (!!collection?.fields?.find((f) => f.name == oldName && !f._toDelete)) {
return;
}
@@ -114,31 +135,40 @@
CommonHelper.replaceIndexColumn(idx, oldName, newName),
);
}
function onTypeCange() {
const oldFields = collection.fields || [];
const nonSystemFields = oldFields.filter((f) => !f.system);
const blank = structuredClone($scaffolds[collection.type]);
collection.fields = blank.fields;
for (let oldField of oldFields) {
if (!oldField.system) {
continue;
}
const idx = collection.fields.findIndex((f) => f.name == oldField.name);
if (idx < 0) {
continue;
}
// merge the default field with the existing one
collection.fields[idx] = Object.assign(collection.fields[idx], oldField);
}
for (let field of nonSystemFields) {
collection.fields.push(field);
}
}
</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.type === "auth"}
,
<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="schema-fields">
{#each collection.schema as field, i (field)}
<div class="schema-fields total-{collection.fields.length}">
{#each collection.fields as field, i (field)}
<Draggable
bind:list={collection.schema}
bind:list={collection.fields}
index={i}
disabled={field.toDelete || (field.id && field.system)}
disabled={field._toDelete}
dragHandleClass="drag-handle-wrapper"
on:drag={(e) => {
// blank drag placeholder
@@ -160,6 +190,7 @@
<svelte:component
this={fieldComponents[field.type]}
key={getSchemaFieldIndex(field)}
{collection}
bind:field
on:remove={() => removeField(i)}
on:duplicate={() => duplicateField(i)}
@@ -1,36 +1,36 @@
<script>
import { onMount } from "svelte";
import Field from "@/components/base/Field.svelte";
import { errors, removeError } from "@/stores/errors";
import CommonHelper from "@/utils/CommonHelper";
import Field from "@/components/base/Field.svelte";
import { onMount } from "svelte";
export let collection;
let codeEditorComponent;
let isCodeEditorComponentLoading = false;
let schemaErrors = [];
let fieldsErrors = [];
$: checkSchemaErrors($errors);
$: checkFieldsErrors($errors);
function checkSchemaErrors(errs) {
schemaErrors = [];
function checkFieldsErrors(errs) {
fieldsErrors = [];
const raw = CommonHelper.getNestedVal(errs, "schema", null);
const raw = CommonHelper.getNestedVal(errs, "fields", null);
if (CommonHelper.isEmpty(raw)) {
return;
}
// generic schema error
// generic fields list error
// ---
if (raw?.message) {
schemaErrors.push(raw?.message);
fieldsErrors.push(raw?.message);
return;
}
// schema fields error
// individual field error
// ---
const columns = CommonHelper.extractColumnsFromQuery(collection?.options?.query);
const columns = CommonHelper.extractColumnsFromQuery(collection?.viewQuery);
// remove base system fields
CommonHelper.removeByValue(columns, "id");
CommonHelper.removeByValue(columns, "created");
@@ -41,7 +41,7 @@
const message = raw[idx][key].message;
const fieldName = columns[idx] || idx;
schemaErrors.push(CommonHelper.sentenize(fieldName + ": " + message));
fieldsErrors.push(CommonHelper.sentenize(fieldName + ": " + message));
}
}
}
@@ -59,7 +59,7 @@
});
</script>
<Field class="form-field required {schemaErrors.length ? 'error' : ''}" name="options.query" let:uniqueId>
<Field class="form-field required {fieldsErrors.length ? 'error' : ''}" name="viewQuery" let:uniqueId>
<label for={uniqueId}>
<span class="txt">Select query</span>
</label>
@@ -74,11 +74,11 @@
language="sql-select"
minHeight="150"
on:change={() => {
if (schemaErrors.length) {
removeError("schema");
if (fieldsErrors.length) {
removeError("fields");
}
}}
bind:value={collection.options.query}
bind:value={collection.viewQuery}
/>
{/if}
@@ -98,10 +98,10 @@
</ul>
</div>
{#if schemaErrors.length}
{#if fieldsErrors.length}
<div class="help-block help-block-error">
<div class="content">
{#each schemaErrors as err}
{#each fieldsErrors as err}
<p>{err}</p>
{/each}
</div>
@@ -1,14 +1,18 @@
<script>
import { slide } from "svelte/transition";
import tooltip from "@/actions/tooltip";
import CommonHelper from "@/utils/CommonHelper";
import RuleField from "@/components/collections/RuleField.svelte";
import CommonHelper from "@/utils/CommonHelper";
import { slide } from "svelte/transition";
export let collection;
$: fields = CommonHelper.getAllCollectionIdentifiers(collection);
$: fieldNames = CommonHelper.getAllCollectionIdentifiers(collection);
$: hiddenFieldNames = collection.fields?.filter((f) => f.hidden).map((f) => f.name);
let showFiltersInfo = false;
let showExtraRules = collection.manageRule !== null || collection.authRule !== "";
</script>
<div class="block m-b-sm handle">
@@ -34,8 +38,10 @@
<div class="content">
<p class="m-b-0">The following record fields are available:</p>
<div class="inline-flex flex-gap-5">
{#each fields as name}
<code>{name}</code>
{#each fieldNames as name}
{#if !hiddenFieldNames.includes(name)}
<code>{name}</code>
{/if}
{/each}
</div>
@@ -47,16 +53,15 @@
<div class="inline-flex flex-gap-5">
<code>@request.headers.*</code>
<code>@request.query.*</code>
<code>@request.data.*</code>
<code>@request.body.*</code>
<code>@request.auth.*</code>
</div>
<hr class="m-t-10 m-b-5" />
<p class="m-b-0">
You could also add constraints and query other collections using the <em
>@collection</em
> filter:
You could also add constraints and query other collections using the
<em>@collection</em> filter:
</p>
<div class="inline-flex flex-gap-5">
<code>@collection.ANY_COLLECTION_NAME.*</code>
@@ -81,8 +86,8 @@
{#if collection?.type !== "view"}
<RuleField label="Create rule" formKey="createRule" {collection} bind:rule={collection.createRule}>
<svelte:fragment slot="afterLabel" let:isAdminOnly>
{#if !isAdminOnly}
<svelte:fragment slot="afterLabel" let:isSuperuserOnly>
{#if !isSuperuserOnly}
<i
class="ri-information-line link-hint"
use:tooltip={{
@@ -100,23 +105,66 @@
{/if}
{#if collection?.type === "auth"}
<RuleField
label="Manage rule"
formKey="options.manageRule"
placeholder=""
required={collection.options.manageRule !== null}
{collection}
bind:rule={collection.options.manageRule}
<hr />
<button
type="button"
class="btn btn-sm m-b-sm {showExtraRules ? 'btn-secondary' : 'btn-hint btn-transparent'}"
on:click={() => {
showExtraRules = !showExtraRules;
}}
>
<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>
<strong class="txt">Additional auth collection rules</strong>
{#if showExtraRules}
<i class="ri-arrow-up-s-line txt-sm" />
{:else}
<i class="ri-arrow-down-s-line txt-sm" />
{/if}
</button>
{#if showExtraRules}
<div class="block" transition:slide={{ duration: 150 }}>
<RuleField
label="Authentication rule"
formKey="authRule"
placeholder=""
{collection}
bind:rule={collection.authRule}
>
<svelte:fragment>
<p>
This rule is executed every time before authentication allowing you to restrict who
can authenticate.
</p>
<p>
For example, to allow only verified users you can set it to
<code>verified = true</code>.
</p>
<p>Leave it empty to allow anyone with an account to authenticate.</p>
<p>To disable authentication entirely you can change it to "Set superusers only".</p>
</svelte:fragment>
</RuleField>
<RuleField
label="Manage rule"
formKey="manageRule"
placeholder=""
required={collection.manageRule !== null}
{collection}
bind:rule={collection.manageRule}
>
<svelte:fragment>
<p>
This rule is executed in addition to the <code>create</code> and <code>update</code> API
rules.
</p>
<p>
It enables superuser-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>
</svelte:fragment>
</RuleField>
</div>
{/if}
{/if}
@@ -1,8 +1,8 @@
<script>
import { link } from "svelte-spa-router";
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import { activeCollection } from "@/stores/collections";
import CommonHelper from "@/utils/CommonHelper";
import { link } from "svelte-spa-router";
export let collection;
export let pinnedIds;
@@ -28,11 +28,22 @@
use:link
>
<i class={CommonHelper.getCollectionTypeIcon(collection.type)} aria-hidden="true" />
<span class="txt m-r-auto">{collection.name}</span>
<span class="txt">{collection.name}</span>
{#if collection.type == "auth" && collection.oauth2?.enabled && !collection.oauth2.providers?.length}
<i
class="ri-alert-line txt-sm link-hint"
title=""
aria-hidden="true"
use:tooltip={"OAuth2 auth is enabled but the collection doesn't have any registered providers"}
/>
{/if}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<span
class="btn btn-xs btn-circle btn-hint btn-transparent pin-collection"
class="btn btn-xs btn-circle btn-hint btn-transparent btn-pin-collection m-l-auto"
aria-label={"Pin collection"}
aria-hidden="true"
use:tooltip={{ position: "right", text: (isPinned ? "Unpin" : "Pin") + " collection" }}
@@ -47,15 +58,15 @@
</a>
<style lang="scss">
.pin-collection {
margin: 0 -5px 0 -15px;
.btn-pin-collection {
margin: 0 -7px 0 -15px;
opacity: 0;
transition: opacity var(--baseAnimationSpeed);
i {
font-size: inherit;
}
}
a:hover .pin-collection {
a:hover .btn-pin-collection {
opacity: 0.4;
&:hover {
opacity: 1;
@@ -1,38 +1,40 @@
<script>
import { tick, createEventDispatcher } from "svelte";
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
import { createEventDispatcher, tick } from "svelte";
const dispatch = createEventDispatcher();
let panel;
let oldCollection;
let newCollection;
let hideAfterSave;
$: isCollectionRenamed = oldCollection?.name != newCollection?.name;
$: isNewCollectionView = newCollection?.type === "view";
$: renamedFields =
newCollection?.schema?.filter(
(field) => field.id && !field.toDelete && field.originalName != field.name
newCollection?.fields?.filter(
(field) => field.id && !field._toDelete && field._originalName != field.name,
) || [];
$: deletedFields = newCollection?.schema?.filter((field) => field.id && field.toDelete) || [];
$: deletedFields = newCollection?.fields?.filter((field) => field.id && field._toDelete) || [];
$: multipleToSingleFields =
newCollection?.schema?.filter((field) => {
const old = oldCollection?.schema?.find((f) => f.id == field.id);
newCollection?.fields?.filter((field) => {
const old = oldCollection?.fields?.find((f) => f.id == field.id);
if (!old) {
return false;
}
return old.options?.maxSelect != 1 && field.options?.maxSelect == 1;
return old.maxSelect != 1 && field.maxSelect == 1;
}) || [];
$: showChanges = !isNewCollectionView || isCollectionRenamed;
export async function show(original, changed) {
export async function show(original, changed, hideAfterSaveArg = true) {
oldCollection = original;
newCollection = changed;
hideAfterSave = hideAfterSaveArg;
await tick();
@@ -55,7 +57,7 @@
function confirm() {
hide();
dispatch("confirm");
dispatch("confirm", hideAfterSave);
}
</script>
@@ -106,7 +108,7 @@
<li>
<div class="inline-flex">
Renamed field
<strong class="txt-strikethrough txt-hint">{field.originalName}</strong>
<strong class="txt-strikethrough txt-hint">{field._originalName}</strong>
<i class="ri-arrow-right-line txt-sm" />
<strong class="txt"> {field.name}</strong>
</div>
@@ -1,21 +1,21 @@
<script>
import { createEventDispatcher, tick } from "svelte";
import { scale } from "svelte/transition";
import CommonHelper from "@/utils/CommonHelper";
import ApiClient from "@/utils/ApiClient";
import { errors, setErrors, removeError } from "@/stores/errors";
import { confirm } from "@/stores/confirmation";
import { removeAllToasts, addSuccessToast } from "@/stores/toasts";
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 CollectionQueryTab from "@/components/collections/CollectionQueryTab.svelte";
import Toggler from "@/components/base/Toggler.svelte";
import CollectionAuthOptionsTab from "@/components/collections/CollectionAuthOptionsTab.svelte";
import CollectionFieldsTab from "@/components/collections/CollectionFieldsTab.svelte";
import CollectionQueryTab from "@/components/collections/CollectionQueryTab.svelte";
import CollectionRulesTab from "@/components/collections/CollectionRulesTab.svelte";
import CollectionUpdateConfirm from "@/components/collections/CollectionUpdateConfirm.svelte";
import { addCollection, removeCollection, scaffolds, activeCollection } from "@/stores/collections";
import { confirm } from "@/stores/confirmation";
import { errors, removeError, setErrors } from "@/stores/errors";
import { addSuccessToast, removeAllToasts } from "@/stores/toasts";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import { createEventDispatcher, tick } from "svelte";
import { scale } from "svelte/transition";
const TAB_SCHEMA = "schema";
const TAB_RULES = "api_rules";
@@ -35,26 +35,31 @@
let collectionPanel;
let confirmChangesPanel;
let original = null;
let collection = CommonHelper.initCollection();
let collection = {};
let isSaving = false;
let confirmClose = false; // prevent close recursion
let activeTab = TAB_SCHEMA;
let initialFormHash = calculateFormHash(collection);
let schemaTabError = "";
let fieldsTabError = "";
let baseCollectionKeys = [];
$: baseCollectionKeys = Object.keys($scaffolds["base"] || {});
$: isAuth = collection.type === TYPE_AUTH;
$: isView = collection.type === TYPE_VIEW;
$: if ($errors.schema || $errors.options?.query) {
// extract the direct schema field error, otherwise - return a generic message
schemaTabError = CommonHelper.getNestedVal($errors, "schema.message") || "Has errors";
$: if ($errors.fields || $errors.viewQuery) {
// extract the direct fields list error, otherwise - return a generic message
fieldsTabError = CommonHelper.getNestedVal($errors, "fields.message") || "Has errors";
} else {
schemaTabError = "";
fieldsTabError = "";
}
$: isSystemUpdate = !!collection.id && collection.system;
$: isSuperusers = !!collection.id && collection.system && collection.name == "_superusers";
$: hasChanges = initialFormHash != calculateFormHash(collection);
$: canSave = !collection.id || hasChanges;
@@ -110,27 +115,40 @@
collection = structuredClone(model);
} else {
original = null;
collection = CommonHelper.initCollection();
collection = structuredClone($scaffolds["base"]);
// add default timestamp fields
collection.fields.push({
type: "autodate",
name: "created",
onCreate: true,
});
collection.fields.push({
type: "autodate",
name: "updated",
onCreate: true,
onUpdate: true,
});
}
// normalize
collection.schema = collection.schema || [];
collection.originalName = collection.name || "";
collection.fields = collection.fields || [];
collection._originalName = collection.name || "";
await tick();
initialFormHash = calculateFormHash(collection);
}
function saveConfirm() {
function saveConfirm(hideAfterSave = true) {
if (!collection.id) {
save();
save(hideAfterSave);
} else {
confirmChangesPanel?.show(original, collection);
confirmChangesPanel?.show(original, collection, hideAfterSave);
}
}
function save() {
function save(hideAfterSave = true) {
if (isSaving) {
return;
}
@@ -138,9 +156,10 @@
isSaving = true;
const data = exportFormData();
const isNew = !collection.id;
let request;
if (!collection.id) {
if (isNew) {
request = ApiClient.collections.create(data);
} else {
request = ApiClient.collections.update(collection.id, data);
@@ -152,17 +171,25 @@
addCollection(result);
confirmClose = false;
hide();
if (hideAfterSave) {
confirmClose = false;
hide();
} else {
load(result);
}
addSuccessToast(
!collection.id ? "Successfully created collection." : "Successfully updated collection.",
);
dispatch("save", {
isNew: !collection.id,
isNew: isNew,
collection: result,
});
if (isNew) {
$activeCollection = result;
}
})
.catch((err) => {
ApiClient.error(err);
@@ -174,19 +201,41 @@
function exportFormData() {
const data = Object.assign({}, collection);
data.schema = data.schema.slice(0);
data.fields = data.fields.slice(0);
// remove deleted fields
for (let i = data.schema.length - 1; i >= 0; i--) {
const field = data.schema[i];
if (field.toDelete) {
data.schema.splice(i, 1);
for (let i = data.fields.length - 1; i >= 0; i--) {
const field = data.fields[i];
if (field._toDelete) {
data.fields.splice(i, 1);
}
}
return data;
}
function truncateConfirm() {
if (!original?.id) {
return; // nothing to truncate
}
confirm(
`Do you really want to delete all "${original.name}" records, including their cascade delete references and files?`,
() => {
return ApiClient.collections
.truncate(original.id)
.then(() => {
forceHide();
addSuccessToast(`Successfully truncated collection "${original.name}".`);
dispatch("truncate");
})
.catch((err) => {
ApiClient.error(err);
});
},
);
}
function deleteConfirm() {
if (!original?.id) {
return; // nothing to delete
@@ -196,7 +245,7 @@
return ApiClient.collections
.delete(original.id)
.then(() => {
hide();
forceHide();
addSuccessToast(`Successfully deleted collection "${original.name}".`);
dispatch("delete", original);
removeCollection(original);
@@ -214,8 +263,11 @@
function setCollectionType(t) {
collection.type = t;
// reset schema errors on type change
removeError("schema");
// merge with the scaffold to ensure that the minimal props are set
collection = Object.assign(structuredClone($scaffolds[t]), collection);
// reset fields list errors on type change
removeError("fields");
}
function duplicateConfirm() {
@@ -237,9 +289,9 @@
clone.updated = "";
clone.name += "_duplicate";
// reset the schema
if (!CommonHelper.isEmpty(clone.schema)) {
for (const field of clone.schema) {
// reset the fields list
if (!CommonHelper.isEmpty(clone.fields)) {
for (const field of clone.fields) {
field.id = "";
}
}
@@ -248,7 +300,7 @@
if (!CommonHelper.isEmpty(clone.indexes)) {
for (let i = 0; i < clone.indexes.length; i++) {
const parsed = CommonHelper.parseIndex(clone.indexes[i]);
parsed.indexName = "idx_" + CommonHelper.randomString(7);
parsed.indexName = "idx_" + CommonHelper.randomString(10);
parsed.tableName = clone.name;
clone.indexes[i] = CommonHelper.buildIndex(parsed);
}
@@ -261,9 +313,25 @@
initialFormHash = "";
}
function hasOtherKeys(obj, excludes = []) {
if (CommonHelper.isEmpty(obj)) {
return false;
}
const errorKeys = Object.keys(obj);
for (let key of errorKeys) {
if (!excludes.includes(key)) {
return true;
}
}
return false;
}
</script>
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<OverlayPanel
bind:this={collectionPanel}
class="overlay-panel-lg colored-header collection-panel"
@@ -306,6 +374,16 @@
<i class="ri-file-copy-line" aria-hidden="true" />
<span class="txt">Duplicate</span>
</button>
<hr />
<button
type="button"
class="dropdown-item txt-danger"
role="menuitem"
on:click={() => truncateConfirm()}
>
<i class="ri-eraser-line" aria-hidden="true"></i>
<span class="txt">Truncate</span>
</button>
<button
type="button"
class="dropdown-item txt-danger"
@@ -325,11 +403,7 @@
canSave && saveConfirm();
}}
>
<Field
class="form-field collection-field-name required m-b-0 {isSystemUpdate ? 'disabled' : ''}"
name="name"
let:uniqueId
>
<Field class="form-field collection-field-name required m-b-0" name="name" let:uniqueId>
<label for={uniqueId}>Name</label>
<!-- svelte-ignore a11y-autofocus -->
@@ -339,6 +413,7 @@
required
disabled={isSystemUpdate}
spellcheck="false"
class:txt-bold={collection.system}
autofocus={!collection.id}
placeholder={isAuth ? `eg. "users"` : `eg. "posts"`}
value={collection.name}
@@ -398,30 +473,32 @@
on:click={() => changeTab(TAB_SCHEMA)}
>
<span class="txt">{isView ? "Query" : "Fields"}</span>
{#if !CommonHelper.isEmpty(schemaTabError)}
{#if !CommonHelper.isEmpty(fieldsTabError)}
<i
class="ri-error-warning-fill txt-danger"
transition:scale={{ duration: 150, start: 0.7 }}
use:tooltip={schemaTabError}
use:tooltip={fieldsTabError}
/>
{/if}
</button>
<button
type="button"
class="tab-item"
class:active={activeTab === TAB_RULES}
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) || !CommonHelper.isEmpty($errors?.options?.manageRule)}
<i
class="ri-error-warning-fill txt-danger"
transition:scale={{ duration: 150, start: 0.7 }}
use:tooltip={"Has errors"}
/>
{/if}
</button>
{#if !isSuperusers}
<button
type="button"
class="tab-item"
class:active={activeTab === TAB_RULES}
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) || !CommonHelper.isEmpty($errors?.authRule) || !CommonHelper.isEmpty($errors?.manageRule)}
<i
class="ri-error-warning-fill txt-danger"
transition:scale={{ duration: 150, start: 0.7 }}
use:tooltip={"Has errors"}
/>
{/if}
</button>
{/if}
{#if isAuth}
<button
@@ -431,7 +508,7 @@
on:click={() => changeTab(TAB_OPTIONS)}
>
<span class="txt">Options</span>
{#if !CommonHelper.isEmpty($errors?.options) && !$errors?.options?.manageRule}
{#if $errors && hasOtherKeys($errors, baseCollectionKeys.concat( ["manageRule", "authRule"], ))}
<i
class="ri-error-warning-fill txt-danger"
transition:scale={{ duration: 150, start: 0.7 }}
@@ -453,7 +530,7 @@
{/if}
</div>
{#if activeTab === TAB_RULES}
{#if !isSuperusers && activeTab === TAB_RULES}
<div class="tab-item active">
<CollectionRulesTab bind:collection />
</div>
@@ -470,19 +547,40 @@
<button type="button" class="btn btn-transparent" disabled={isSaving} on:click={() => hide()}>
<span class="txt">Cancel</span>
</button>
<button
type="button"
class="btn btn-expanded"
class:btn-loading={isSaving}
disabled={!canSave || isSaving}
on:click={() => saveConfirm()}
>
<span class="txt">{!collection.id ? "Create" : "Save changes"}</span>
</button>
<div class="btns-group no-gap">
<button
type="button"
class="btn btn-expanded"
title="Save and close"
class:btn-loading={isSaving}
disabled={!canSave || isSaving}
on:click={() => saveConfirm()}
>
<span class="txt">{!collection.id ? "Create" : "Save changes"}</span>
</button>
{#if collection.id}
<button type="button" class="btn p-l-5 p-r-5 flex-gap-0" disabled={!canSave || isSaving}>
<i class="ri-arrow-down-s-line" aria-hidden="true"></i>
<Toggler class="dropdown dropdown-upside dropdown-right dropdown-nowrap m-b-5">
<button
type="button"
class="dropdown-item closable"
role="menuitem"
on:click={() => saveConfirm(false)}
>
<span class="txt">Save and continue</span>
</button>
</Toggler>
</button>
{/if}
</div>
</svelte:fragment>
</OverlayPanel>
<CollectionUpdateConfirm bind:this={confirmChangesPanel} on:confirm={() => save()} />
<CollectionUpdateConfirm bind:this={confirmChangesPanel} on:confirm={(e) => save(e.detail)} />
<style>
.upsert-panel-title {
@@ -493,4 +591,8 @@
.tabs-content:focus-within {
z-index: 9; /* autocomplete dropdown overlay fix */
}
:global(.collection-panel .panel-content) {
scrollbar-gutter: stable;
padding-right: calc(var(--baseSpacing) - 5px);
}
</style>
@@ -1,11 +1,11 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
export let collectionA = CommonHelper.initCollection();
export let collectionB = CommonHelper.initCollection();
export let collectionA = {};
export let collectionB = {};
export let deleteMissing = false;
let schemaA = [];
let schemaB = [];
let fieldsListA = [];
let fieldsListB = [];
let removedFields = [];
let sharedFields = [];
let addedFields = [];
@@ -14,50 +14,53 @@
$: isCreateDiff = !isDeleteDiff && !collectionA?.id;
$: schemaA = Array.isArray(collectionA?.schema) ? collectionA?.schema.concat() : [];
$: fieldsListA = Array.isArray(collectionA?.fields) ? collectionA?.fields.concat() : [];
$: if (
typeof collectionA?.schema !== "undefined" ||
typeof collectionB?.schema !== "undefined" ||
typeof collectionA?.fields !== "undefined" ||
typeof collectionB?.fields !== "undefined" ||
typeof deleteMissing !== "undefined"
) {
setSchemaB();
setFieldsListB();
}
$: removedFields = schemaA.filter((fieldA) => {
return !schemaB.find((fieldB) => fieldA.id == fieldB.id);
$: removedFields = fieldsListA.filter((fieldA) => {
return !fieldsListB.find((fieldB) => fieldA.id == fieldB.id);
});
$: sharedFields = schemaB.filter((fieldB) => {
return schemaA.find((fieldA) => fieldA.id == fieldB.id);
$: sharedFields = fieldsListB.filter((fieldB) => {
return fieldsListA.find((fieldA) => fieldA.id == fieldB.id);
});
$: addedFields = schemaB.filter((fieldB) => {
return !schemaA.find((fieldA) => fieldA.id == fieldB.id);
$: addedFields = fieldsListB.filter((fieldB) => {
return !fieldsListA.find((fieldA) => fieldA.id == fieldB.id);
});
$: hasAnyChange = CommonHelper.hasCollectionChanges(collectionA, collectionB, deleteMissing);
const mainModelProps = Object.keys(CommonHelper.initCollection()).filter(
(key) => !["schema", "created", "updated"].includes(key)
);
$: mainModelProps = CommonHelper.mergeUnique(
Object.keys(collectionA || {}),
Object.keys(collectionB || {}),
).filter((key) => {
return !["fields", "created", "updated"].includes(key);
});
function setSchemaB() {
schemaB = Array.isArray(collectionB?.schema) ? collectionB?.schema.concat() : [];
function setFieldsListB() {
fieldsListB = Array.isArray(collectionB?.fields) ? collectionB?.fields.concat() : [];
if (!deleteMissing) {
schemaB = schemaB.concat(
schemaA.filter((fieldA) => {
return !schemaB.find((fieldB) => fieldA.id == fieldB.id);
})
fieldsListB = fieldsListB.concat(
fieldsListA.filter((fieldA) => {
return !fieldsListB.find((fieldB) => fieldA.id == fieldB.id);
}),
);
}
}
function getFieldById(schema, id) {
schema = schema || [];
function getFieldById(fields, id) {
fields = fields || [];
for (let field of schema) {
for (let field of fields) {
if (field.id == id) {
return field;
}
@@ -124,14 +127,14 @@
hasChanges(collectionA?.[prop], collectionB?.[prop])}
class:changed-none-col={isCreateDiff}
>
<pre class="txt">{displayValue(collectionA?.[prop])}</pre>
<pre class="txt diff-value">{displayValue(collectionA?.[prop])}</pre>
</td>
<td
class:changed-new-col={!isDeleteDiff &&
hasChanges(collectionA?.[prop], collectionB?.[prop])}
class:changed-none-col={isDeleteDiff}
>
<pre class="txt">{displayValue(collectionB?.[prop])}</pre>
<pre class="txt diff-value">{displayValue(collectionB?.[prop])}</pre>
</td>
</tr>
{/each}
@@ -165,19 +168,29 @@
<tr>
<th class="min-width" colspan="3">
<span class="txt">field: {field.name}</span>
{#if hasChanges(getFieldById(schemaA, field.id), getFieldById(schemaB, field.id))}
{#if hasChanges(getFieldById(fieldsListA, field.id), getFieldById(fieldsListB, field.id))}
<span class="label label-warning m-l-5">Changed</span>
{/if}
</th>
</tr>
{#each Object.entries(field) as [key, newValue]}
<tr class:txt-primary={hasChanges(getFieldById(schemaA, field.id)?.[key], newValue)}>
<tr class:txt-primary={hasChanges(getFieldById(fieldsListA, field.id)?.[key], newValue)}>
<td class="min-width field-key-col">{key}</td>
<td class:changed-old-col={hasChanges(getFieldById(schemaA, field.id)?.[key], newValue)}>
<pre class="txt">{displayValue(getFieldById(schemaA, field.id)?.[key])}</pre>
<td
class:changed-old-col={hasChanges(
getFieldById(fieldsListA, field.id)?.[key],
newValue,
)}
>
<pre class="txt">{displayValue(getFieldById(fieldsListA, field.id)?.[key])}</pre>
</td>
<td class:changed-new-col={hasChanges(getFieldById(schemaA, field.id)?.[key], newValue)}>
<td
class:changed-new-col={hasChanges(
getFieldById(fieldsListA, field.id)?.[key],
newValue,
)}
>
<pre class="txt">{displayValue(newValue)}</pre>
</td>
</tr>
@@ -248,5 +261,8 @@
.field-key-col {
padding-left: 30px;
}
.diff-value {
white-space: break-spaces;
}
}
</style>
@@ -1,15 +1,17 @@
<script>
import { hideControls } from "@/stores/app";
import { collections, activeCollection, isCollectionsLoading } from "@/stores/collections";
import PageSidebar from "@/components/base/PageSidebar.svelte";
import CollectionUpsertPanel from "@/components/collections/CollectionUpsertPanel.svelte";
import CollectionSidebarItem from "@/components/collections/CollectionSidebarItem.svelte";
import CollectionUpsertPanel from "@/components/collections/CollectionUpsertPanel.svelte";
import { hideControls } from "@/stores/app";
import { activeCollection, collections, isCollectionsLoading } from "@/stores/collections";
const pinnedStorageKey = "@pinnedCollections";
let collectionPanel;
let searchTerm = "";
let pinnedIds = [];
let showSystemSection = false;
let oldCollectionId;
loadPinned();
@@ -32,10 +34,17 @@
$: pinnedCollections = filtered.filter((c) => pinnedIds.includes(c.id));
$: unpinnedCollections = filtered.filter((c) => !pinnedIds.includes(c.id));
$: unpinnedRegularCollections = filtered.filter((c) => !c.system && !pinnedIds.includes(c.id));
function selectCollection(collection) {
$activeCollection = collection;
$: unpinnedSystemCollections = filtered.filter((c) => c.system && !pinnedIds.includes(c.id));
$: if ($activeCollection?.id && oldCollectionId != $activeCollection.id) {
oldCollectionId = $activeCollection.id;
if ($activeCollection.system && !pinnedCollections.find((c) => c.id == $activeCollection.id)) {
showSystemSection = true;
} else {
showSystemSection = false;
}
}
function scrollIntoView() {
@@ -99,15 +108,41 @@
{/each}
{/if}
{#if unpinnedCollections.length}
{#if unpinnedRegularCollections.length}
{#if pinnedCollections.length}
<div class="sidebar-title">Others</div>
{/if}
{#each unpinnedCollections as collection (collection.id)}
{#each unpinnedRegularCollections as collection (collection.id)}
<CollectionSidebarItem {collection} bind:pinnedIds />
{/each}
{/if}
{#if unpinnedSystemCollections.length}
<button
type="button"
class="sidebar-title m-b-xs"
class:link-hint={!normalizedSearch.length}
aria-label={showSystemSection ? "Expand system collections" : "Collapse system collections"}
aria-expanded={showSystemSection || normalizedSearch.length}
disabled={normalizedSearch.length}
on:click={() => {
if (!normalizedSearch.length) {
showSystemSection = !showSystemSection;
}
}}
>
<span class="txt">System</span>
{#if !normalizedSearch.length}
<i class="ri-arrow-{showSystemSection ? 'up' : 'down'}-s-line" aria-hidden="true" />
{/if}
</button>
{#if showSystemSection || normalizedSearch.length}
{#each unpinnedSystemCollections as collection (collection.id)}
<CollectionSidebarItem {collection} bind:pinnedIds />
{/each}
{/if}
{/if}
{#if normalizedSearch.length && !filtered.length}
<p class="txt-hint m-t-10 m-b-10 txt-center">No collections found.</p>
{/if}
@@ -123,11 +158,4 @@
{/if}
</PageSidebar>
<CollectionUpsertPanel
bind:this={collectionPanel}
on:save={(e) => {
if (e.detail?.isNew && e.detail.collection) {
selectCollection(e.detail.collection);
}
}}
/>
<CollectionUpsertPanel bind:this={collectionPanel} />
@@ -3,17 +3,18 @@
</script>
<script>
import { scale } from "svelte/transition";
import tooltip from "@/actions/tooltip";
import Accordion from "@/components/base/Accordion.svelte";
import Field from "@/components/base/Field.svelte";
import { errors, removeError } from "@/stores/errors";
import { addInfoToast } from "@/stores/toasts";
import CommonHelper from "@/utils/CommonHelper";
import Field from "@/components/base/Field.svelte";
import Accordion from "@/components/base/Accordion.svelte";
import { scale } from "svelte/transition";
export let key;
export let title;
export let config = {};
export let placeholders = [];
let accordion;
let editorComponent = cachedEditorComponent;
@@ -52,6 +53,7 @@
}
function copy(param) {
param = param.replace("*", ""); // strip wildcard
CommonHelper.copyToClipboard(param);
addInfoToast(`Copied ${param} to clipboard`, 2000);
}
@@ -80,53 +82,20 @@
<Field class="form-field required" name="{key}.subject" let:uniqueId>
<label for={uniqueId}>Subject</label>
<input type="text" id={uniqueId} bind:value={config.subject} spellcheck="false" required />
<div class="help-block">
Available placeholder parameters:
<button
type="button"
class="label label-sm link-primary txt-mono"
on:click={() => copy("{APP_NAME}")}
>
{"{APP_NAME}"}
</button>,
<button
type="button"
class="label label-sm link-primary txt-mono"
on:click={() => copy("{APP_URL}")}
>
{"{APP_URL}"}
</button>.
</div>
</Field>
<Field class="form-field required" name="{key}.actionUrl" let:uniqueId>
<label for={uniqueId}>Action URL</label>
<input type="text" id={uniqueId} bind:value={config.actionUrl} spellcheck="false" required />
<div class="help-block">
Available placeholder parameters:
<button
type="button"
class="label label-sm link-primary txt-mono"
on:click={() => copy("{APP_NAME}")}
>
{"{APP_NAME}"}
</button>,
<button
type="button"
class="label label-sm link-primary txt-mono"
on:click={() => copy("{APP_URL}")}
>
{"{APP_URL}"}
</button>,
<button
type="button"
class="label label-sm link-primary txt-mono"
title="Required parameter"
on:click={() => copy("{TOKEN}")}
>
{"{TOKEN}"}
</button>.
</div>
{#if placeholders?.length > 0}
<div class="help-block">
Available placeholder parameters:
{#each placeholders as placeholder}
<button
type="button"
class="label label-sm link-primary txt-mono"
on:click={() => copy("{" + placeholder + "}")}
>
{"{" + placeholder + "}"}
</button>&nbsp;
{/each}
</div>
{/if}
</Field>
<Field class="form-field m-0 required" name="{key}.body" let:uniqueId>
@@ -145,37 +114,19 @@
/>
{/if}
<div class="help-block">
Available placeholder parameters:
<button
type="button"
class="label label-sm link-primary txt-mono"
on:click={() => copy("{APP_NAME}")}
>
{"{APP_NAME}"}
</button>,
<button
type="button"
class="label label-sm link-primary txt-mono"
on:click={() => copy("{APP_URL}")}
>
{"{APP_URL}"}
</button>,
<button
type="button"
class="label label-sm link-primary txt-mono"
on:click={() => copy("{TOKEN}")}
>
{"{TOKEN}"}
</button>,
<button
type="button"
class="label label-sm link-primary txt-mono"
title="Required parameter"
on:click={() => copy("{ACTION_URL}")}
>
{"{ACTION_URL}"}
</button>.
</div>
{#if placeholders?.length > 0}
<div class="help-block">
Available placeholder parameters:
{#each placeholders as placeholder}
<button
type="button"
class="label label-sm link-primary txt-mono"
on:click={() => copy("{" + placeholder + "}")}
>
{"{" + placeholder + "}"}
</button>&nbsp;
{/each}
</div>
{/if}
</Field>
</Accordion>
@@ -1,9 +1,9 @@
<script>
import { onMount, createEventDispatcher } from "svelte";
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
import Field from "@/components/base/Field.svelte";
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
import CommonHelper from "@/utils/CommonHelper";
import { createEventDispatcher, onMount } from "svelte";
const dispatch = createEventDispatcher();
@@ -16,10 +16,8 @@
let codeEditorComponent;
let isCodeEditorComponentLoading = false;
$: presetColumns = (collection?.schema?.filter((f) => !f.toDelete)?.map((f) => f.name) || []).concat([
"created",
"updated",
]);
$: presetColumns =
collection?.fields?.filter((f) => !f.toDelete && f.name != "id")?.map((f) => f.name) || [];
$: indexParts = CommonHelper.parseIndex(index);
@@ -0,0 +1,85 @@
<script>
import { scale } from "svelte/transition";
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import { errors } from "@/stores/errors";
import Accordion from "@/components/base/Accordion.svelte";
import Field from "@/components/base/Field.svelte";
import RuleField from "@/components/collections/RuleField.svelte";
export let collection;
$: hasErrors = !CommonHelper.isEmpty($errors?.mfa);
</script>
<Accordion single>
<svelte:fragment slot="header">
<div class="inline-flex">
<i class="ri-shield-check-line"></i>
<span class="txt"> Multi-factor authentication (MFA) </span>
</div>
<div class="flex-fill" />
{#if collection.mfa.enabled}
<span class="label label-success">Enabled</span>
{:else}
<span class="label">Disabled</span>
{/if}
{#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>
<div class="content m-b-sm">
<p class="txt-bold">This feature is experimental and may change in the future.</p>
<p>
Multi-factor authentication (MFA) requires the user to authenticate with any 2 different auth
methods (otp, identity/password, oauth2) before issuing an auth token.
<a
href={import.meta.env.PB_MFA_DOCS}
target="_blank"
rel="noopener noreferrer"
class="txt-sm link-hint"
title="Learn more"
>
<em>(Learn more)</em>
</a>.
</p>
</div>
<div class="grid">
<Field class="form-field form-field-toggle" name="mfa.enabled" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={collection.mfa.enabled} />
<label for={uniqueId}>
<span class="txt">Enable</span>
</label>
</Field>
<div class="content" class:fade={!collection.mfa.enabled}>
<RuleField
label="MFA rule"
formKey="mfa.rule"
superuserToggle={false}
disabled={!collection.mfa.enabled}
placeholder="Leave empty to require MFA for everyone"
{collection}
bind:rule={collection.mfa.rule}
>
<svelte:fragment>
<p>This optional rule could be used to enable/disable MFA per account basis.</p>
<p>
For example, to require MFA only for accounts with non-empty email you can set it to
<code>email != ''</code>.
</p>
<p>Leave the rule empty to require MFA for everyone.</p>
</svelte:fragment>
</RuleField>
</div>
</div>
</Accordion>
@@ -0,0 +1,254 @@
<script>
import tooltip from "@/actions/tooltip";
import Accordion from "@/components/base/Accordion.svelte";
import Field from "@/components/base/Field.svelte";
import Select from "@/components/base/Select.svelte";
import OAuth2ProviderPanel from "@/components/collections/OAuth2ProviderPanel.svelte";
import OAuth2ProvidersListPanel from "@/components/collections/OAuth2ProvidersListPanel.svelte";
import providersUIList from "@/providers.js";
import { errors } from "@/stores/errors";
import CommonHelper from "@/utils/CommonHelper";
import { scale, slide } from "svelte/transition";
export let collection;
const excludedFieldNames = ["id", "email", "emailVisibility", "verified", "tokenKey", "password"];
const allowedRegularTypes = ["text", "editor", "url", "email", "json"];
const allowedRegularAndFileTypes = allowedRegularTypes.concat("file");
let providersListPanel;
let providerPanel;
let showMappedFields = false;
let regularFieldOptions = [];
let regularAndFileFieldOptions = [];
$: refreshFieldOptions(collection.fields);
$: if (CommonHelper.isEmpty(collection.oauth2)) {
collection.oauth2 = {
enabled: false,
mappedFields: {},
providers: [],
};
}
$: hasErrors = !CommonHelper.isEmpty($errors?.oauth2);
$: totalProviders = collection.oauth2?.providers?.length || 0;
function refreshFieldOptions(fields = []) {
regularFieldOptions =
fields
?.filter((f) => allowedRegularTypes.includes(f.type) && !excludedFieldNames.includes(f.name))
?.map((f) => f.name) || [];
regularAndFileFieldOptions =
fields
?.filter(
(f) =>
allowedRegularAndFileTypes.includes(f.type) && !excludedFieldNames.includes(f.name),
)
?.map((f) => f.name) || [];
}
function getProviderUIOptions(key) {
for (let item of providersUIList) {
if (item.key == key) {
return item;
}
}
return null;
}
</script>
<Accordion single>
<svelte:fragment slot="header">
<div class="inline-flex">
<i class="ri-pass-expired-line"></i>
<span class="txt">OAuth2</span>
</div>
<div class="flex-fill" />
{#if collection.oauth2.enabled}
<span class="label" class:label-warning={!totalProviders} class:label-info={totalProviders > 0}>
{totalProviders}
{totalProviders == 1 ? "provider" : "providers"}
</span>
<span class="label label-success">Enabled</span>
{:else}
<span class="label">Disabled</span>
{/if}
{#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" name="oauth2.enabled" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={collection.oauth2.enabled} />
<label for={uniqueId}>Enable</label>
</Field>
<div class="grid grid-sm">
{#each collection.oauth2.providers as providerConfig, i (providerConfig.name)}
{@const uiOptions = getProviderUIOptions(providerConfig.name)}
<div class="col-lg-6">
<div
class="provider-card"
class:error={!CommonHelper.isEmpty($errors?.oauth2?.providers?.[i])}
>
<figure class="provider-logo">
{#if uiOptions?.logo}
<img
src="{import.meta.env.BASE_URL}images/oauth2/{uiOptions.logo}"
alt="{uiOptions.title} logo"
/>
{:else}
<i class="ri-puzzle-line txt-sm txt-hint"></i>
{/if}
</figure>
<div class="content">
<div class="title">{providerConfig.displayName || uiOptions?.title || "Custom"}</div>
<em class="txt-hint txt-sm m-r-auto">{providerConfig.name}</em>
</div>
{#if uiOptions}
<button
type="button"
class="btn btn-circle btn-hint btn-transparent"
aria-label="Provider settings"
use:tooltip={{ text: "Edit config", position: "left" }}
on:click={() => {
providerPanel?.show(uiOptions, providerConfig, i);
}}
>
<i class="ri-settings-4-line" />
</button>
{/if}
</div>
</div>
{/each}
<div class="col-lg-6">
<button
class="btn btn-block btn-lg btn-secondary txt-base"
on:click={() => providersListPanel?.show()}
>
<i class="ri-add-line"></i>
<span class="txt">Add provider</span>
</button>
</div>
</div>
<button
type="button"
class="m-t-25 btn btn-sm {showMappedFields ? 'btn-secondary' : 'btn-hint btn-transparent'}"
on:click={() => (showMappedFields = !showMappedFields)}
>
<strong class="txt">Optional {collection.name} create fields map</strong>
{#if showMappedFields}
<i class="ri-arrow-up-s-line txt-sm" />
{:else}
<i class="ri-arrow-down-s-line txt-sm" />
{/if}
</button>
{#if showMappedFields}
<div class="block" transition:slide={{ duration: 150 }}>
<div class="grid grid-sm p-t-xs">
<div class="col-sm-6">
<Field class="form-field form-field-toggle" name="oauth2.mappedFields.name" let:uniqueId>
<label for={uniqueId}>OAuth2 full name</label>
<Select
id={uniqueId}
items={regularFieldOptions}
toggle={true}
zeroFunc={() => ""}
selectPlaceholder={"Select field"}
bind:selected={collection.oauth2.mappedFields.name}
/>
</Field>
</div>
<div class="col-sm-6">
<Field
class="form-field form-field-toggle"
name="oauth2.mappedFields.avatarURL"
let:uniqueId
>
<label for={uniqueId}>OAuth2 avatar</label>
<Select
id={uniqueId}
items={regularAndFileFieldOptions}
toggle={true}
zeroFunc={() => ""}
selectPlaceholder={"Select field"}
bind:selected={collection.oauth2.mappedFields.avatarURL}
/>
</Field>
</div>
<div class="col-sm-6">
<Field class="form-field form-field-toggle" name="oauth2.mappedFields.id" let:uniqueId>
<label for={uniqueId}>OAuth2 id</label>
<Select
id={uniqueId}
items={regularFieldOptions}
toggle={true}
zeroFunc={() => ""}
selectPlaceholder={"Select field"}
bind:selected={collection.oauth2.mappedFields.id}
/>
</Field>
</div>
<div class="col-sm-6">
<Field
class="form-field form-field-toggle"
name="oauth2.mappedFields.username"
let:uniqueId
>
<label for={uniqueId}>OAuth2 username</label>
<Select
id={uniqueId}
items={regularFieldOptions}
toggle={true}
zeroFunc={() => ""}
selectPlaceholder={"Select field"}
bind:selected={collection.oauth2.mappedFields.username}
/>
</Field>
</div>
</div>
</div>
{/if}
</Accordion>
<OAuth2ProvidersListPanel
bind:this={providersListPanel}
disabled={collection.oauth2?.providers?.map((p) => p.name) || []}
on:select={(e) => {
providerPanel.show(e.detail, {}, collection.oauth2?.providers?.length || 0);
}}
/>
<OAuth2ProviderPanel
bind:this={providerPanel}
on:remove={(e) => {
const uiOptions = e.detail.uiOptions;
CommonHelper.removeByKey(collection.oauth2.providers, "name", uiOptions.key);
collection.oauth2.providers = collection.oauth2.providers;
}}
on:submit={(e) => {
const uiOptions = e.detail.uiOptions;
const config = e.detail.config;
collection.oauth2.providers = collection.oauth2.providers || [];
CommonHelper.pushOrReplaceByKey(
collection.oauth2.providers,
Object.assign({ name: uiOptions.key }, config),
"name",
);
collection.oauth2.providers = collection.oauth2.providers;
}}
/>
@@ -0,0 +1,115 @@
<script>
import { createEventDispatcher } from "svelte";
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";
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
import RedactedPasswordInput from "@/components/base/RedactedPasswordInput.svelte";
const dispatch = createEventDispatcher();
const formId = "provider_popup_" + CommonHelper.randomString(5);
let panel;
let uiOptions = {};
let config = {};
let isNew = false;
let initialHash = "";
let maskSecret = false;
let providerIndex = 0;
$: hasChanges = JSON.stringify(config) != initialHash;
$: errPrefix = "oauth2.providers." + providerIndex;
export function show(showOptions, showConfig, showIndex) {
providerIndex = showIndex || 0;
isNew = CommonHelper.isEmpty(showConfig);
uiOptions = Object.assign({}, showOptions);
config = Object.assign({}, showConfig);
maskSecret = !!config.clientId;
initialHash = JSON.stringify(config);
panel?.show();
}
export function hide() {
removeError(errPrefix);
panel?.hide();
}
async function submit() {
dispatch("submit", { uiOptions, config });
hide();
}
async function remove() {
confirm(
`Do you really want to remove the "${uiOptions.title}" OAuth2 provider from the collection?`,
() => {
dispatch("remove", { uiOptions });
hide();
},
);
}
</script>
<OverlayPanel bind:this={panel} btnClose={false} on:show on:hide>
<svelte:fragment slot="header">
<figure class="provider-logo">
{#if uiOptions.logo}
<img
src="{import.meta.env.BASE_URL}images/oauth2/{uiOptions.logo}"
alt="{uiOptions.title} logo"
/>
{:else}
<i class="ri-puzzle-line txt-sm txt-hint"></i>
{/if}
</figure>
<h4 class="center txt-break">{uiOptions.title} <small class="txt-hint">({uiOptions.key})</small></h4>
</svelte:fragment>
<form id={formId} autocomplete="off" on:submit|preventDefault={() => submit()}>
<Field class="form-field required" name="{errPrefix}.clientId" let:uniqueId>
<label for={uniqueId}>Client ID</label>
<input type="text" id={uniqueId} bind:value={config.clientId} />
</Field>
<Field class="form-field required" name="{errPrefix}.clientSecret" let:uniqueId>
<label for={uniqueId}>Client secret</label>
<RedactedPasswordInput id={uniqueId} bind:mask={maskSecret} bind:value={config.clientSecret} />
</Field>
{#if uiOptions.optionsComponent}
<div class="col-lg-12">
<svelte:component
this={uiOptions.optionsComponent}
key={errPrefix}
bind:config
{...uiOptions.optionsComponentProps || {}}
/>
</div>
{/if}
</form>
<svelte:fragment slot="footer">
{#if !isNew}
<button
type="button"
class="btn btn-transparent btn-circle btn-hint btn-sm"
aria-label="Remove provider"
use:tooltip={{ text: "Remove provider", position: "right" }}
on:click={remove}
>
<i class="ri-delete-bin-7-line" aria-hidden="true" />
</button>
<div class="flex-fill"></div>
{/if}
<button type="button" class="btn btn-transparent" on:click={hide}>Cancel</button>
<button type="submit" form={formId} class="btn btn-expanded" disabled={!hasChanges}>
<span class="txt">Set provider config</span>
</button>
</svelte:fragment>
</OverlayPanel>
@@ -0,0 +1,106 @@
<script>
import { fly } from "svelte/transition";
import Field from "@/components/base/Field.svelte";
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
import providersList from "@/providers.js";
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
export let disabled = [];
let panel;
let searchTerm = "";
let filteredProviders = [];
$: if (searchTerm !== -1 || disabled !== -1) {
filteredProviders = filterProviders();
}
export function show() {
panel?.show();
}
export function hide() {
return panel?.hide();
}
function select(provider) {
dispatch("select", provider);
hide();
}
function filterProviders() {
const search = (searchTerm || "").toLowerCase();
return providersList.filter(
(p) =>
!disabled.includes(p.key) &&
(search == "" ||
p.key.toLowerCase().includes(search) ||
p.title.toLowerCase().includes(search)),
);
}
function clearSearch() {
searchTerm = "";
}
</script>
<OverlayPanel bind:this={panel} on:show on:hide btnClose={false}>
<svelte:fragment slot="header">
<h4 class="center txt-break">Add OAuth2 provider</h4>
</svelte:fragment>
<Field class="searchbar m-b-sm" let:uniqueId>
<label for={uniqueId} class="m-l-10 txt-xl">
<i class="ri-search-line" />
</label>
<input id={uniqueId} type="text" placeholder="Search provider" bind:value={searchTerm} />
{#if searchTerm != ""}
<button
type="button"
class="btn btn-transparent btn-sm btn-hint p-l-xs p-r-xs m-l-10"
transition:fly={{ duration: 150, x: 5 }}
on:click={() => (searchTerm = "")}
>
<span class="txt">Clear</span>
</button>
{/if}
</Field>
<div class="grid grid-sm">
{#each filteredProviders as provider (provider.key)}
<div class="col-lg-6">
<button type="button" class="provider-card handle" on:click={() => select(provider)}>
<figure class="provider-logo">
{#if provider.logo}
<img
src="{import.meta.env.BASE_URL}images/oauth2/{provider.logo}"
alt="{provider.title} logo"
/>
{/if}
</figure>
<div class="content">
<div class="title">{provider.title}</div>
<em class="txt-hint txt-sm m-r-auto">{provider.key}</em>
</div>
</button>
</div>
{:else}
<div class="flex inline-flex">
<span class="txt-hint">No providers to select.</span>
{#if searchTerm != ""}
<button type="button" class="btn btn-sm btn-secondary" on:click={clearSearch}>
Clear filter
</button>
{/if}
</div>
{/each}
</div>
<svelte:fragment slot="footer">
<button type="button" class="btn btn-transparent" on:click={hide}>Cancel</button>
</svelte:fragment>
</OverlayPanel>
@@ -0,0 +1,92 @@
<script>
import tooltip from "@/actions/tooltip";
import Accordion from "@/components/base/Accordion.svelte";
import Field from "@/components/base/Field.svelte";
import { errors } from "@/stores/errors";
import CommonHelper from "@/utils/CommonHelper";
import { scale } from "svelte/transition";
export let collection;
$: isSuperusers = collection?.system && collection?.name === "_superusers";
$: if (CommonHelper.isEmpty(collection.otp)) {
collection.otp = {
enabled: true,
duration: 300,
length: 8,
};
}
$: hasErrors = !CommonHelper.isEmpty($errors?.otp);
</script>
<Accordion single>
<svelte:fragment slot="header">
<div class="inline-flex">
<i class="ri-time-line"></i>
<span class="txt">One-time password (OTP)</span>
</div>
<div class="flex-fill" />
{#if collection.otp.enabled}
<span class="label label-success">Enabled</span>
{:else}
<span class="label">Disabled</span>
{/if}
{#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" name="otp.enabled" let:uniqueId>
<input
type="checkbox"
id={uniqueId}
bind:checked={collection.otp.enabled}
on:change={(e) => {
if (isSuperusers) {
collection.mfa.enabled = e.target.checked;
}
}}
/>
<label for={uniqueId}>Enable</label>
{#if isSuperusers}
<i
class="ri-information-line link-hint"
use:tooltip={{
text: "Superusers can have OTP only as part of Two-factor authentication.",
position: "right",
}}
/>
{/if}
</Field>
<div class="grid grid-sm">
<div class="col-sm-6">
<Field class="form-field form-field-toggle required" name="otp.duration" let:uniqueId>
<label for={uniqueId}>Duration (in seconds)</label>
<input
type="number"
min="0"
step="1"
id={uniqueId}
bind:value={collection.otp.duration}
required
/>
</Field>
</div>
<div class="col-sm-6">
<Field class="form-field form-field-toggle required" name="otp.length" let:uniqueId>
<label for={uniqueId}>Generated password length</label>
<input type="text" id={uniqueId} bind:value={collection.otp.length} required />
</Field>
</div>
</div>
</Accordion>
@@ -0,0 +1,108 @@
<script>
import tooltip from "@/actions/tooltip";
import Accordion from "@/components/base/Accordion.svelte";
import Field from "@/components/base/Field.svelte";
import ObjectSelect from "@/components/base/ObjectSelect.svelte";
import { errors } from "@/stores/errors";
import CommonHelper from "@/utils/CommonHelper";
import { onMount } from "svelte";
import { scale } from "svelte/transition";
export let collection;
let identityFieldsOptions = [];
$: isSuperusers = collection?.system && collection?.name === "_superusers";
$: if (CommonHelper.isEmpty(collection.passwordAuth)) {
collection.passwordAuth = {
enabled: true,
identityFields: ["email"],
};
}
$: hasErrors = !CommonHelper.isEmpty($errors?.passwordAuth);
function extractUniqueFields(collection) {
// email is always available in auth collections
const result = [{ value: "email" }];
const fields = collection?.fields || [];
const indexes = collection?.indexes || [];
for (let idx of indexes) {
const parsed = CommonHelper.parseIndex(idx);
if (!parsed.unique || parsed.columns.length != 1 || parsed.columns[0].name == "email") {
continue;
}
const field = fields.find((f) => {
return !f.hidden && f.name.toLowerCase() == parsed.columns[0].name.toLowerCase();
});
if (field) {
result.push({ value: field.name });
}
}
return result;
}
onMount(() => {
identityFieldsOptions = extractUniqueFields(collection);
});
</script>
<Accordion single>
<svelte:fragment slot="header">
<div class="inline-flex">
<i class="ri-lock-password-line"></i>
<span class="txt">Identity/Password</span>
</div>
<div class="flex-fill" />
{#if collection.passwordAuth.enabled}
<span class="label label-success">Enabled</span>
{:else}
<span class="label">Disabled</span>
{/if}
{#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" name="passwordAuth.enabled" let:uniqueId>
<input
type="checkbox"
id={uniqueId}
bind:checked={collection.passwordAuth.enabled}
disabled={isSuperusers}
/>
<label for={uniqueId}>Enable</label>
{#if isSuperusers}
<i
class="ri-information-line link-hint"
use:tooltip={{
text: "Superusers are required to have password auth enabled.",
position: "right",
}}
/>
{/if}
</Field>
<Field class="form-field required m-0" name="passwordAuth.identityFields" let:uniqueId>
<label for={uniqueId}>
<span class="txt">Unique identity fields</span>
</label>
<ObjectSelect
items={identityFieldsOptions}
multiple
bind:keyOfSelected={collection.passwordAuth.identityFields}
/>
</Field>
</Accordion>
+38 -19
View File
@@ -6,12 +6,15 @@
import { tick } from "svelte";
import { scale } from "svelte/transition";
import Field from "@/components/base/Field.svelte";
import tooltip from "@/actions/tooltip";
export let collection = null;
export let rule = null;
export let label = "Rule";
export let formKey = "rule";
export let required = false;
export let disabled = false;
export let superuserToggle = true;
export let placeholder = "Leave empty to grant everyone access...";
let editorRef = null;
@@ -19,7 +22,9 @@
let ruleInputComponent = cachedRuleComponent;
let isRuleComponentLoading = false;
$: isAdminOnly = rule === null;
$: isSuperuserOnly = superuserToggle && rule === null;
$: isDisabled = disabled || collection.system;
loadEditorComponent();
@@ -55,29 +60,36 @@
</div>
{:else}
<Field
class="form-field rule-field {required ? 'requied' : ''} {isAdminOnly ? 'disabled' : ''}"
class="form-field rule-field {required ? 'requied' : ''} {isSuperuserOnly ? 'disabled' : ''}"
name={formKey}
let:uniqueId
>
<div class="input-wrapper">
<div
class="input-wrapper"
use:tooltip={collection.system
? { text: "System collection rule cannot be changed.", position: "top" }
: undefined}
>
<label for={uniqueId}>
<slot name="beforeLabel" {isAdminOnly} />
<slot name="beforeLabel" {isSuperuserOnly} />
<span class="txt" class:txt-hint={isAdminOnly}>
<span class="txt" class:txt-hint={isSuperuserOnly}>
{label}
{isAdminOnly ? "- Admins only" : ""}
{isSuperuserOnly ? "- Superusers only" : ""}
</span>
<slot name="afterLabel" {isAdminOnly} />
<slot name="afterLabel" {isSuperuserOnly} />
{#if !isAdminOnly}
{#if superuserToggle && !isSuperuserOnly}
<button
type="button"
class="btn btn-sm btn-transparent btn-hint lock-toggle"
aria-hidden={isDisabled}
disabled={isDisabled}
on:click={lock}
>
<i class="ri-lock-line" />
<span class="txt">Set Admins only</span>
<i class="ri-lock-line" aria-hidden="true" />
<span class="txt">Set Superusers only</span>
</button>
{/if}
</label>
@@ -88,20 +100,23 @@
bind:this={editorRef}
bind:value={rule}
baseCollection={collection}
disabled={isAdminOnly}
placeholder={!isAdminOnly ? placeholder : ""}
disabled={isDisabled}
placeholder={!isSuperuserOnly ? placeholder : ""}
/>
{#if isAdminOnly}
{#if superuserToggle && isSuperuserOnly}
<button
type="button"
class="unlock-overlay"
aria-label="Unlock and set custom rule"
disabled={isDisabled}
aria-hidden={isDisabled}
transition:scale={{ duration: 150, start: 0.98 }}
on:click={unlock}
>
<small class="txt">Unlock and set custom rule</small>
<div class="icon">
{#if !isDisabled}
<small class="txt">Unlock and set custom rule</small>
{/if}
<div class="icon" aria-hidden="true">
<i class="ri-lock-unlock-line" />
</div>
</button>
@@ -109,7 +124,7 @@
</div>
<div class="help-block">
<slot {isAdminOnly} />
<slot {isSuperuserOnly} />
</div>
</Field>
{/if}
@@ -146,7 +161,6 @@
justify-content: end;
text-align: center;
border-radius: var(--baseRadius);
background: rgba(255, 255, 255, 0.2);
outline: 0;
cursor: pointer;
text-decoration: none;
@@ -169,7 +183,9 @@
font-weight: 600;
line-height: var(--smLineHeight);
transform: translateX(5px);
transition: transform var(--hoverAnimationSpeed), opacity var(--hoverAnimationSpeed);
transition:
transform var(--hoverAnimationSpeed),
opacity var(--hoverAnimationSpeed);
}
&:hover,
&:focus-visible,
@@ -187,5 +203,8 @@
transition-duration: var(--activeAnimationSpeed);
border-color: var(--baseAlt3Color);
}
&[disabled] {
cursor: not-allowed;
}
}
</style>
@@ -1,6 +1,6 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import Field from "@/components/base/Field.svelte";
import CommonHelper from "@/utils/CommonHelper";
export let key;
export let label;
@@ -10,9 +10,10 @@
<Field class="form-field required" name="{key}.duration" let:uniqueId>
<label for={uniqueId}>{label} duration (in seconds)</label>
<input type="number" id={uniqueId} required bind:value={duration} />
<input type="number" id={uniqueId} required bind:value={duration} placeholder="No change" />
<div class="help-block">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<span
class="link-primary"
class:txt-success={!!secret}
@@ -0,0 +1,76 @@
<script>
import { scale } from "svelte/transition";
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import { errors } from "@/stores/errors";
import Accordion from "@/components/base/Accordion.svelte";
import TokenField from "@/components/collections/TokenField.svelte";
export let collection;
let tokensList = [];
$: isSuperusers = collection?.system && collection?.name === "_superusers";
$: tokensList = isSuperusers
? [
{ key: "authToken", label: "Auth" },
{ key: "passwordResetToken", label: "Password reset" },
{ key: "fileToken", label: "Protected file access" },
]
: [
{ key: "authToken", label: "Auth" },
{ key: "verificationToken", label: "Email verification" },
{ key: "passwordResetToken", label: "Password reset" },
{ key: "emailChangeToken", label: "Email change" },
{ key: "fileToken", label: "Protected file access" },
];
$: hasErrors = hasTokenError($errors);
function hasTokenError(errors) {
if (CommonHelper.isEmpty(errors)) {
return false;
}
for (let token of tokensList) {
if (errors[token.key]) {
return true;
}
}
return false;
}
</script>
<Accordion single>
<svelte:fragment slot="header">
<div class="inline-flex">
<i class="ri-key-2-line"></i>
<span class="txt">Tokens options (invalidate, duration)</span>
</div>
<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>
<div class="grid">
{#each tokensList as token (token.key)}
<div class="col-sm-6">
<TokenField
key={token.key}
label={token.label}
bind:duration={collection[token.key].duration}
bind:secret={collection[token.key].secret}
/>
</div>
{/each}
</div>
</Accordion>
@@ -1,54 +1,37 @@
<script>
import CodeBlock from "@/components/base/CodeBlock.svelte";
import FieldsQueryParam from "@/components/collections/docs/FieldsQueryParam.svelte";
import SdkTabs from "@/components/base/SdkTabs.svelte";
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";
import FieldsQueryParam from "@/components/collections/docs/FieldsQueryParam.svelte";
export let collection;
let responseTab = 200;
let responses = [];
let authMethods = {};
let isLoading = false;
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseUrl);
$: 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="
}
]
}
`,
body: isLoading ? "..." : JSON.stringify(authMethods, null, 2),
},
];
listAuthMethods();
async function listAuthMethods() {
isLoading = true;
try {
authMethods = await ApiClient.collection(collection.name).listAuthMethods();
} catch (err) {
ApiClient.error(err);
}
isLoading = false;
}
</script>
<h3 class="m-b-sm">List auth methods ({collection.name})</h3>
@@ -1,16 +1,16 @@
<script>
import CodeBlock from "@/components/base/CodeBlock.svelte";
import FieldsQueryParam from "@/components/collections/docs/FieldsQueryParam.svelte";
import SdkTabs from "@/components/base/SdkTabs.svelte";
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";
import FieldsQueryParam from "@/components/collections/docs/FieldsQueryParam.svelte";
export let collection;
let responseTab = 200;
let responses = [];
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseUrl);
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseURL);
$: responses = [
{
@@ -21,7 +21,7 @@
record: CommonHelper.dummyCollectionRecord(collection),
},
null,
2
2,
),
},
{
@@ -64,10 +64,8 @@
<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>
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.
</p>
</div>
@@ -84,7 +82,7 @@
// 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);
console.log(pb.authStore.record.id);
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
@@ -98,7 +96,7 @@
// 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);
print(pb.authStore.record.id);
`}
/>
@@ -110,7 +108,7 @@
/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>
<p class="txt-hint txt-sm txt-right">Requires <code>Authorization:TOKEN</code> header</p>
</div>
<div class="section-title">Query parameters</div>
@@ -1,16 +1,16 @@
<script>
import CodeBlock from "@/components/base/CodeBlock.svelte";
import FieldsQueryParam from "@/components/collections/docs/FieldsQueryParam.svelte";
import SdkTabs from "@/components/base/SdkTabs.svelte";
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";
import FieldsQueryParam from "@/components/collections/docs/FieldsQueryParam.svelte";
export let collection;
let responseTab = 200;
let responses = [];
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseUrl);
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseURL);
$: responses = [
{
@@ -24,14 +24,14 @@
name: "John Doe",
username: "john.doe",
email: "test@example.com",
avatarUrl: "https://example.com/avatar.png",
avatarURL: "https://example.com/avatar.png",
accessToken: "...",
refreshToken: "...",
rawUser: {},
},
},
null,
2
2,
),
},
{
@@ -82,9 +82,9 @@
// 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);
console.log(pb.authStore.record.id);
// "logout" the last authenticated model
// "logout"
pb.authStore.clear();
`}
dart={`
@@ -108,9 +108,9 @@
// 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);
print(pb.authStore.record.id);
// "logout" the last authenticated model
// "logout"
pb.authStore.clear();
`}
/>
@@ -175,7 +175,7 @@
<td>
<div class="inline-flex">
<span class="label label-success">Required</span>
<span>redirectUrl</span>
<span>redirectURL</span>
</div>
</td>
<td>
@@ -0,0 +1,106 @@
<script>
import CodeBlock from "@/components/base/CodeBlock.svelte";
import CommonHelper from "@/utils/CommonHelper";
export let collection;
let responseTab = 200;
let responses = [];
$: 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": {
"otpId": {
"code": "validation_required",
"message": "Missing required value."
}
}
}
`,
},
];
</script>
<div class="alert alert-success">
<strong class="label label-primary">POST</strong>
<div class="content">
<p>
/api/collections/<strong>{collection.name}</strong>/auth-with-otp
</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>otpId</span>
</div>
</td>
<td>
<span class="label">String</span>
</td>
<td>The id of the OTP request.</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 one-time password.</td>
</tr>
</tbody>
</table>
<div class="section-title">Responses</div>
<div class="tabs">
<div class="tabs-header compact combined 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,103 @@
<script>
import CodeBlock from "@/components/base/CodeBlock.svelte";
import CommonHelper from "@/utils/CommonHelper";
export let collection;
let responseTab = 200;
let responses = [];
$: responses = [
{
code: 200,
body: JSON.stringify(
{
otpId: CommonHelper.randomString(15),
},
null,
2,
),
},
{
code: 400,
body: `
{
"code": 400,
"message": "An error occurred while validating the submitted data.",
"data": {
"email": {
"code": "validation_is_email",
"message": "Must be a valid email address."
}
}
}
`,
},
{
code: 429,
body: `
{
"code": 429,
"message": "You've send too many OTP requests, please try again later.",
"data": {}
}
`,
},
];
</script>
<div class="alert alert-success">
<strong class="label label-primary">POST</strong>
<div class="content">
<p>
/api/collections/<strong>{collection.name}</strong>/request-otp
</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 OTP request (if exists).</td>
</tr>
</tbody>
</table>
<div class="section-title">Responses</div>
<div class="tabs">
<div class="tabs-header compact combined 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,96 @@
<script>
import AuthWithOtpApiAuthDocs from "@/components/collections/docs/AuthWithOtpApiAuthDocs.svelte";
import AuthWithOtpApiRequestDocs from "@/components/collections/docs/AuthWithOtpApiRequestDocs.svelte";
import SdkTabs from "@/components/base/SdkTabs.svelte";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
export let collection;
const apiTabs = [
{ title: "OTP Request", component: AuthWithOtpApiRequestDocs },
{ title: "OTP Auth", component: AuthWithOtpApiAuthDocs },
];
let activeApiTab = 0;
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseURL);
</script>
<h3 class="m-b-sm">Auth with OTP ({collection.name})</h3>
<div class="content txt-lg m-b-sm">
<p>Authenticate with an one-time password (OTP).</p>
</div>
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${backendAbsUrl}');
...
// send OTP email to the provided auth record
const req = await pb.collection('${collection?.name}').requestOtp('test@example.com');
// ... show a screen/popup to enter the password from the email ...
// authenticate with the requested OTP id and the email password
const authData = await pb.collection('${collection?.name}').authWithOtp(
req.otpId,
"YOUR_OTP",
);
// 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.record.id);
// "logout"
pb.authStore.clear();
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${backendAbsUrl}');
...
// send OTP email to the provided auth record
final req = await pb.collection('${collection?.name}').requestOtp('test@example.com');
// ... show a screen/popup to enter the password from the email ...
// authenticate with the requested OTP id and the email password
final authData = await pb.collection('${collection?.name}').authWithOtp(
req.otpId,
"YOUR_OTP",
);
// after the above you can also access the auth data from the authStore
print(pb.authStore.isValid);
print(pb.authStore.token);
print(pb.authStore.record.id);
// "logout"
pb.authStore.clear();
`}
/>
<h6 class="m-b-xs">API details</h6>
<div class="tabs">
<div class="tabs-header compact">
{#each apiTabs as tab, i}
<button class="tab-item" class:active={activeApiTab == i} on:click={() => (activeApiTab = i)}>
<div class="txt">{tab.title}</div>
</button>
{/each}
</div>
<div class="tabs-content">
{#each apiTabs as tab, i}
<div class="tab-item" class:active={activeApiTab == i}>
<svelte:component this={tab.component} {collection} />
</div>
{/each}
</div>
</div>
@@ -1,27 +1,21 @@
<script>
import CodeBlock from "@/components/base/CodeBlock.svelte";
import FieldsQueryParam from "@/components/collections/docs/FieldsQueryParam.svelte";
import SdkTabs from "@/components/base/SdkTabs.svelte";
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";
import FieldsQueryParam from "@/components/collections/docs/FieldsQueryParam.svelte";
export let collection;
let responseTab = 200;
let responses = [];
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseUrl);
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseURL);
$: allowEmail = collection?.options?.allowEmailAuth;
$: allowUsername = collection?.options?.allowUsernameAuth;
$: identityFields = collection?.passwordAuth?.identityFields || [];
$: exampleIdentityLabel =
allowUsername && allowEmail
? "YOUR_USERNAME_OR_EMAIL"
: allowUsername
? "YOUR_USERNAME"
: "YOUR_EMAIL";
identityFields.length == 0 ? "NONE" : "YOUR_" + identityFields.join("_OR_").toUpperCase();
$: responses = [
{
@@ -32,7 +26,7 @@
record: CommonHelper.dummyCollectionRecord(collection),
},
null,
2
2,
),
},
{
@@ -56,17 +50,8 @@
<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>.
Authenticate with combination of
<strong>{identityFields.join("/")}</strong> and <strong>password</strong>.
</p>
</div>
@@ -86,9 +71,9 @@
// 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);
console.log(pb.authStore.record.id);
// "logout" the last authenticated account
// "logout"
pb.authStore.clear();
`}
dart={`
@@ -106,9 +91,9 @@
// 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);
print(pb.authStore.record.id);
// "logout" the last authenticated account
// "logout"
pb.authStore.clear();
`}
/>
@@ -144,16 +129,10 @@
<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}
{#each identityFields as name, i}
{#if i > 0}or{/if}
<strong>{name}</strong>
{/each}
of the record to authenticate.
</td>
</tr>
@@ -1,9 +1,9 @@
<script>
import CodeBlock from "@/components/base/CodeBlock.svelte";
import FieldsQueryParam from "@/components/collections/docs/FieldsQueryParam.svelte";
import SdkTabs from "@/components/base/SdkTabs.svelte";
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";
import FieldsQueryParam from "@/components/collections/docs/FieldsQueryParam.svelte";
export let collection;
@@ -13,9 +13,16 @@
$: isAuth = collection.type === "auth";
$: adminsOnly = collection?.createRule === null;
$: superusersOnly = collection?.createRule === null;
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseUrl);
$: excludedTableFields = isAuth ? ["password", "verified", "email", "emailVisibility"] : [];
$: tableFields =
collection?.fields?.filter((f) => {
return !f.hidden && f.type != "autodate" && !excludedTableFields.includes(f.name);
}) || [];
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseURL);
$: responses = [
{
@@ -29,7 +36,7 @@
"code": 400,
"message": "Failed to create record.",
"data": {
"${collection?.schema?.[0]?.name}": {
"${collection?.fields?.[0]?.name}": {
"code": "validation_required",
"message": "Missing required value."
}
@@ -51,9 +58,6 @@
$: if (isAuth) {
baseData = {
username: "test_username",
email: "test@example.com",
emailVisibility: true,
password: "12345678",
passwordConfirm: "12345678",
};
@@ -89,7 +93,7 @@ const pb = new PocketBase('${backendAbsUrl}');
...
// example create data
const data = ${JSON.stringify(Object.assign({}, baseData, CommonHelper.dummyCollectionSchemaData(collection)), null, 4)};
const data = ${JSON.stringify(Object.assign({}, baseData, CommonHelper.dummyCollectionSchemaData(collection, true)), null, 4)};
const record = await pb.collection('${collection?.name}').create(data);
` + (isAuth ?
@@ -106,7 +110,7 @@ final pb = PocketBase('${backendAbsUrl}');
...
// example create body
final body = <String, dynamic>${JSON.stringify(Object.assign({}, baseData, CommonHelper.dummyCollectionSchemaData(collection)), null, 2)};
final body = <String, dynamic>${JSON.stringify(Object.assign({}, baseData, CommonHelper.dummyCollectionSchemaData(collection, true)), null, 2)};
final record = await pb.collection('${collection?.name}').create(body: body);
` + (isAuth ?
@@ -125,8 +129,8 @@ await pb.collection('${collection?.name}').requestVerification('test@example.com
/api/collections/<strong>{collection.name}</strong>/records
</p>
</div>
{#if adminsOnly}
<p class="txt-hint txt-sm txt-right">Requires admin <code>Authorization:TOKEN</code> header</p>
{#if superusersOnly}
<p class="txt-hint txt-sm txt-right">Requires superuser <code>Authorization:TOKEN</code> header</p>
{/if}
</div>
@@ -140,47 +144,14 @@ await pb.collection('${collection?.name}').requestVerification('test@example.com
</tr>
</thead>
<tbody>
<tr>
<td>
<div class="inline-flex">
<span class="label label-warning">Optional</span>
<span>id</span>
</div>
</td>
<td>
<span class="label">String</span>
</td>
<td>
<strong>15 characters string</strong> to store as record ID.
<br />
If not set, it will be auto generated.
</td>
</tr>
{#if isAuth}
<tr>
<td colspan="3" class="txt-hint">Auth fields</td>
<td colspan="3" class="txt-hint txt-bold">Auth specific 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}
{#if collection?.fields?.find((f) => f.name == "email")?.required}
<span class="label label-success">Required</span>
{:else}
<span class="label label-warning">Optional</span>
@@ -196,7 +167,11 @@ await pb.collection('${collection?.name}').requestVerification('test@example.com
<tr>
<td>
<div class="inline-flex">
<span class="label label-warning">Optional</span>
{#if collection?.fields?.find((f) => f.name == "emailVisibility")?.required}
<span class="label label-success">Required</span>
{:else}
<span class="label label-warning">Optional</span>
{/if}
<span>emailVisibility</span>
</div>
</td>
@@ -242,22 +217,22 @@ await pb.collection('${collection?.name}').requestVerification('test@example.com
<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.
This field can be set only by superusers or auth records with "Manage" access.
</td>
</tr>
<tr>
<td colspan="3" class="txt-hint">Schema fields</td>
<td colspan="3" class="txt-hint txt-bold">Other fields</td>
</tr>
{/if}
{#each collection?.schema as field (field.name)}
{#each tableFields as field (field.name)}
<tr>
<td>
<div class="inline-flex">
{#if field.required}
<span class="label label-success">Required</span>
{:else}
{#if !field.required || (field.type == "text" && field.autogeneratePattern)}
<span class="label label-warning">Optional</span>
{:else}
<span class="label label-success">Required</span>
{/if}
<span>{field.name}</span>
</div>
@@ -268,6 +243,9 @@ await pb.collection('${collection?.name}').requestVerification('test@example.com
<td>
{#if field.type === "text"}
Plain text value.
{#if field.autogeneratePattern}
It is autogenerated if not set.
{/if}
{:else if field.type === "number"}
Number value.
{:else if field.type === "json"}
@@ -278,9 +256,10 @@ await pb.collection('${collection?.name}').requestVerification('test@example.com
URL address.
{:else if field.type === "file"}
File object.<br />
Set to <code>null</code> to delete already uploaded file(s).
Set to empty value (<code>null</code>, <code>""</code> or <code>[]</code>) to delete
already uploaded file(s).
{:else if field.type === "relation"}
Relation record {field.options?.maxSelect === 1 ? "id" : "ids"}.
Relation record {field.maxSelect === 1 ? "id" : "ids"}.
{/if}
</td>
</tr>
@@ -2,16 +2,16 @@
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";
import SdkTabs from "@/components/base/SdkTabs.svelte";
export let collection;
let responseTab = 204;
let responses = [];
$: adminsOnly = collection?.deleteRule === null;
$: superusersOnly = collection?.deleteRule === null;
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseUrl);
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseURL);
$: if (collection?.id) {
responses.push({
@@ -32,13 +32,13 @@
`,
});
if (adminsOnly) {
if (superusersOnly) {
responses.push({
code: 403,
body: `
{
"code": 403,
"message": "Only admins can access this action.",
"message": "Only superusers can access this action.",
"data": {}
}
`,
@@ -92,8 +92,8 @@
/api/collections/<strong>{collection.name}</strong>/records/<strong>:id</strong>
</p>
</div>
{#if adminsOnly}
<p class="txt-hint txt-sm txt-right">Requires admin <code>Authorization:TOKEN</code> header</p>
{#if superusersOnly}
<p class="txt-hint txt-sm txt-right">Requires superuser <code>Authorization:TOKEN</code> header</p>
{/if}
</div>
@@ -1,16 +1,11 @@
<script>
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;
let responseTab = 204;
let responses = [];
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseUrl);
$: responses = [
{
code: 204,
@@ -34,43 +29,6 @@
];
</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>
After this request all previously issued tokens for the specific record will be automatically
invalidated.
</p>
</div>
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${backendAbsUrl}');
...
await pb.collection('${collection?.name}').confirmEmailChange(
'TOKEN',
'YOUR_PASSWORD',
);
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${backendAbsUrl}');
...
await pb.collection('${collection?.name}').confirmEmailChange(
'TOKEN',
'YOUR_PASSWORD',
);
`}
/>
<h6 class="m-b-xs">API details</h6>
<div class="alert alert-success">
<strong class="label label-primary">POST</strong>
<div class="content">
@@ -1,16 +1,11 @@
<script>
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;
let responseTab = 204;
let responses = [];
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseUrl);
$: responses = [
{
code: 204,
@@ -54,37 +49,6 @@
];
</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}').authWithPassword('test@example.com', '1234567890');
await pb.collection('${collection?.name}').requestEmailChange('new@example.com');
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${backendAbsUrl}');
...
await pb.collection('${collection?.name}').authWithPassword('test@example.com', '1234567890');
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">
@@ -92,7 +56,7 @@
/api/collections/<strong>{collection.name}</strong>/request-email-change
</p>
</div>
<p class="txt-hint txt-sm txt-right">Requires record <code>Authorization:TOKEN</code> header</p>
<p class="txt-hint txt-sm txt-right">Requires <code>Authorization:TOKEN</code> header</p>
</div>
<div class="section-title">Body Parameters</div>
@@ -0,0 +1,92 @@
<script>
import EmailChangeApiConfirmDocs from "@/components/collections/docs/EmailChangeApiConfirmDocs.svelte";
import EmailChangeApiRequestDocs from "@/components/collections/docs/EmailChangeApiRequestDocs.svelte";
import SdkTabs from "@/components/base/SdkTabs.svelte";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
export let collection;
const apiTabs = [
{ title: "Request email change", component: EmailChangeApiRequestDocs },
{ title: "Confirm email change", component: EmailChangeApiConfirmDocs },
];
let activeApiTab = 0;
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseURL);
</script>
<h3 class="m-b-sm">Email change ({collection.name})</h3>
<div class="content txt-lg m-b-sm">
<p>Sends <strong>{collection.name}</strong> email change request.</p>
<p>
On successful email change all previously issued auth tokens for the specific record will be
automatically invalidated.
</p>
</div>
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${backendAbsUrl}');
...
await pb.collection('${collection?.name}').authWithPassword('test@example.com', '1234567890');
await pb.collection('${collection?.name}').requestEmailChange('new@example.com');
// ---
// (optional) in your custom confirmation page:
// ---
// note: after this call all previously issued auth tokens are invalidated
await pb.collection('${collection?.name}').confirmEmailChange(
'EMAIL_CHANGE_TOKEN',
'YOUR_PASSWORD',
);
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${backendAbsUrl}');
...
await pb.collection('${collection?.name}').authWithPassword('test@example.com', '1234567890');
await pb.collection('${collection?.name}').requestEmailChange('new@example.com');
...
// ---
// (optional) in your custom confirmation page:
// ---
// note: after this call all previously issued auth tokens are invalidated
await pb.collection('${collection?.name}').confirmEmailChange(
'EMAIL_CHANGE_TOKEN',
'YOUR_PASSWORD',
);
`}
/>
<h6 class="m-b-xs">API details</h6>
<div class="tabs">
<div class="tabs-header compact">
{#each apiTabs as tab, i}
<button class="tab-item" class:active={activeApiTab == i} on:click={() => (activeApiTab = i)}>
<div class="txt">{tab.title}</div>
</button>
{/each}
</div>
<div class="tabs-content">
{#each apiTabs as tab, i}
<div class="tab-item" class:active={activeApiTab == i}>
<svelte:component this={tab.component} {collection} />
</div>
{/each}
</div>
</div>
@@ -3,7 +3,7 @@
import CommonHelper from "@/utils/CommonHelper";
import CodeBlock from "@/components/base/CodeBlock.svelte";
import FilterSyntax from "@/components/collections/docs/FilterSyntax.svelte";
import SdkTabs from "@/components/collections/docs/SdkTabs.svelte";
import SdkTabs from "@/components/base/SdkTabs.svelte";
import FieldsQueryParam from "@/components/collections/docs/FieldsQueryParam.svelte";
export let collection;
@@ -13,9 +13,9 @@
$: fieldNames = CommonHelper.getAllCollectionIdentifiers(collection);
$: adminsOnly = collection?.listRule === null;
$: superusersOnly = collection?.listRule === null;
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseUrl);
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseURL);
$: if (collection?.id) {
responses.push({
@@ -47,13 +47,13 @@
`,
});
if (adminsOnly) {
if (superusersOnly) {
responses.push({
code: 403,
body: `
{
"code": 403,
"message": "Only admins can access this action.",
"message": "Only superusers can access this action.",
"data": {}
}
`,
@@ -127,8 +127,8 @@
/api/collections/<strong>{collection.name}</strong>/records
</p>
</div>
{#if adminsOnly}
<p class="txt-hint txt-sm txt-right">Requires admin <code>Authorization:TOKEN</code> header</p>
{#if superusersOnly}
<p class="txt-hint txt-sm txt-right">Requires superuser <code>Authorization:TOKEN</code> header</p>
{/if}
</div>
@@ -1,176 +0,0 @@
<script>
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";
import FieldsQueryParam from "@/components/collections/docs/FieldsQueryParam.svelte";
export let 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}').authWithPassword('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}').authWithPassword('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">Query 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>
<FieldsQueryParam />
</tbody>
</table>
<div class="section-title">Responses</div>
<div class="tabs">
<div class="tabs-header compact combined 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,16 +1,11 @@
<script>
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;
let responseTab = 204;
let responses = [];
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseUrl);
$: responses = [
{
code: 204,
@@ -34,57 +29,6 @@
];
</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 and sets a new password.</p>
<p>
After this request all previously issued tokens for the specific record will be automatically
invalidated.
</p>
</div>
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${backendAbsUrl}');
...
let oldAuth = pb.authStore.model;
await pb.collection('${collection?.name}').confirmPasswordReset(
'TOKEN',
'NEW_PASSWORD',
'NEW_PASSWORD_CONFIRM',
);
// reauthenticate if needed
// (after the above call all previously issued tokens are invalidated)
await pb.collection('${collection?.name}').authWithPassword(oldAuth.email, 'NEW_PASSWORD');
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${backendAbsUrl}');
...
final oldAuth = pb.authStore.model;
await pb.collection('${collection?.name}').confirmPasswordReset(
'TOKEN',
'NEW_PASSWORD',
'NEW_PASSWORD_CONFIRM',
);
// reauthenticate if needed
// (after the above call all previously issued tokens are invalidated)
await pb.collection('${collection?.name}').authWithPassword(oldAuth.email, 'NEW_PASSWORD');
`}
/>
<h6 class="m-b-xs">API details</h6>
<div class="alert alert-success">
<strong class="label label-primary">POST</strong>
<div class="content">
@@ -1,16 +1,11 @@
<script>
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;
let responseTab = 204;
let responses = [];
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseUrl);
$: responses = [
{
code: 204,
@@ -34,33 +29,6 @@
];
</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">
@@ -0,0 +1,88 @@
<script>
import PasswordResetApiConfirmDocs from "@/components/collections/docs/PasswordResetApiConfirmDocs.svelte";
import PasswordResetApiRequestDocs from "@/components/collections/docs/PasswordResetApiRequestDocs.svelte";
import SdkTabs from "@/components/base/SdkTabs.svelte";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
export let collection;
const apiTabs = [
{ title: "Request password reset", component: PasswordResetApiRequestDocs },
{ title: "Confirm password reset", component: PasswordResetApiConfirmDocs },
];
let activeApiTab = 0;
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseURL);
</script>
<h3 class="m-b-sm">Password reset ({collection.name})</h3>
<div class="content txt-lg m-b-sm">
<p>Sends <strong>{collection.name}</strong> password reset email request.</p>
<p>
On successful password reset all previously issued auth tokens for the specific record will be
automatically invalidated.
</p>
</div>
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${backendAbsUrl}');
...
await pb.collection('${collection?.name}').requestPasswordReset('test@example.com');
// ---
// (optional) in your custom confirmation page:
// ---
// note: after this call all previously issued auth tokens are invalidated
await pb.collection('${collection?.name}').confirmPasswordReset(
'RESET_TOKEN',
'NEW_PASSWORD',
'NEW_PASSWORD_CONFIRM',
);
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${backendAbsUrl}');
...
await pb.collection('${collection?.name}').requestPasswordReset('test@example.com');
// ---
// (optional) in your custom confirmation page:
// ---
// note: after this call all previously issued auth tokens are invalidated
await pb.collection('${collection?.name}').confirmPasswordReset(
'RESET_TOKEN',
'NEW_PASSWORD',
'NEW_PASSWORD_CONFIRM',
);
`}
/>
<h6 class="m-b-xs">API details</h6>
<div class="tabs">
<div class="tabs-header compact">
{#each apiTabs as tab, i}
<button class="tab-item" class:active={activeApiTab == i} on:click={() => (activeApiTab = i)}>
<div class="txt">{tab.title}</div>
</button>
{/each}
</div>
<div class="tabs-content">
{#each apiTabs as tab, i}
<div class="tab-item" class:active={activeApiTab == i}>
<svelte:component this={tab.component} {collection} />
</div>
{/each}
</div>
</div>
@@ -1,12 +1,12 @@
<script>
import CodeBlock from "@/components/base/CodeBlock.svelte";
import SdkTabs from "@/components/base/SdkTabs.svelte";
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;
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseUrl);
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseURL);
</script>
<h3 class="m-b-sm">Realtime ({collection.name})</h3>
@@ -53,13 +53,13 @@
pb.collection('${collection?.name}').subscribe('*', function (e) {
console.log(e.action);
console.log(e.record);
}, { /* other options like expand, custom headers, etc. */ });
}, { /* other options like: filter, expand, custom headers, etc. */ });
// Subscribe to changes only in the specified record
pb.collection('${collection?.name}').subscribe('RECORD_ID', function (e) {
console.log(e.action);
console.log(e.record);
}, { /* other options like expand, custom headers, etc. */ });
}, { /* other options like: filter, expand, custom headers, etc. */ });
// Unsubscribe
pb.collection('${collection?.name}').unsubscribe('RECORD_ID'); // remove all 'RECORD_ID' subscriptions
@@ -80,13 +80,13 @@
pb.collection('${collection?.name}').subscribe('*', (e) {
print(e.action);
print(e.record);
}, /* other options like expand, custom headers, etc. */);
}, /* other options like: filter, expand, custom headers, etc. */);
// Subscribe to changes only in the specified record
pb.collection('${collection?.name}').subscribe('RECORD_ID', (e) {
print(e.action);
print(e.record);
}, /* other options like expand, custom headers, etc. */);
}, /* other options like: filter, expand, custom headers, etc. */);
// Unsubscribe
pb.collection('${collection?.name}').unsubscribe('RECORD_ID'); // remove all 'RECORD_ID' subscriptions
@@ -111,6 +111,6 @@
record: CommonHelper.dummyCollectionRecord(collection),
},
null,
2
2,
).replace('"action": "create"', '"action": "create" // create, update or delete')}
/>
@@ -1,153 +0,0 @@
<script>
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;
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}').authWithPassword('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}').authWithPassword('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 combined 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,9 +1,9 @@
<script>
import CodeBlock from "@/components/base/CodeBlock.svelte";
import FieldsQueryParam from "@/components/collections/docs/FieldsQueryParam.svelte";
import SdkTabs from "@/components/base/SdkTabs.svelte";
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";
import FieldsQueryParam from "@/components/collections/docs/FieldsQueryParam.svelte";
export let collection;
@@ -13,9 +13,16 @@
$: isAuth = collection?.type === "auth";
$: adminsOnly = collection?.updateRule === null;
$: superusersOnly = collection?.updateRule === null;
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseUrl);
$: excludedTableFields = isAuth ? ["id", "password", "verified", "email", "emailVisibility"] : ["id"];
$: tableFields =
collection?.fields?.filter((f) => {
return !f.hidden && f.type != "autodate" && !excludedTableFields.includes(f.name);
}) || [];
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseURL);
$: responses = [
{
@@ -29,7 +36,7 @@
"code": 400,
"message": "Failed to update record.",
"data": {
"${collection?.schema?.[0]?.name}": {
"${collection?.fields?.[0]?.name}": {
"code": "validation_required",
"message": "Missing required value."
}
@@ -59,10 +66,8 @@
},
];
$: if (collection.type === "auth") {
$: if (isAuth) {
baseData = {
username: "test_username_update",
emailVisibility: false,
password: "87654321",
passwordConfirm: "87654321",
oldPassword: "12345678",
@@ -108,7 +113,7 @@ const pb = new PocketBase('${backendAbsUrl}');
...
// example update data
const data = ${JSON.stringify(Object.assign({}, baseData, CommonHelper.dummyCollectionSchemaData(collection)), null, 4)};
const data = ${JSON.stringify(Object.assign({}, baseData, CommonHelper.dummyCollectionSchemaData(collection, true)), null, 4)};
const record = await pb.collection('${collection?.name}').update('RECORD_ID', data);
`}
@@ -120,7 +125,7 @@ final pb = PocketBase('${backendAbsUrl}');
...
// example update body
final body = <String, dynamic>${JSON.stringify(Object.assign({}, baseData, CommonHelper.dummyCollectionSchemaData(collection)), null, 2)};
final body = <String, dynamic>${JSON.stringify(Object.assign({}, baseData, CommonHelper.dummyCollectionSchemaData(collection, true)), null, 2)};
final record = await pb.collection('${collection?.name}').update('RECORD_ID', body: body);
`}
@@ -134,8 +139,8 @@ final record = await pb.collection('${collection?.name}').update('RECORD_ID', bo
/api/collections/<strong>{collection.name}</strong>/records/<strong>:id</strong>
</p>
</div>
{#if adminsOnly}
<p class="txt-hint txt-sm txt-right">Requires admin <code>Authorization:TOKEN</code> header</p>
{#if superusersOnly}
<p class="txt-hint txt-sm txt-right">Requires superuser <code>Authorization:TOKEN</code> header</p>
{/if}
</div>
@@ -171,19 +176,7 @@ final record = await pb.collection('${collection?.name}').update('RECORD_ID', bo
<tbody>
{#if 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>
<td colspan="3" class="txt-hint txt-bold">Auth specific fields</td>
</tr>
<tr>
<td>
@@ -198,7 +191,7 @@ final record = await pb.collection('${collection?.name}').update('RECORD_ID', bo
<td>
The auth record email address.
<br />
This field can be updated only by admins or auth records with "Manage" access.
This field can be updated only by superusers or auth records with "Manage" access.
<br />
Regular accounts can update their email by calling "Request email change".
</td>
@@ -206,7 +199,11 @@ final record = await pb.collection('${collection?.name}').update('RECORD_ID', bo
<tr>
<td>
<div class="inline-flex">
<span class="label label-warning">Optional</span>
{#if collection?.fields?.find((f) => f.name == "emailVisibility")?.required}
<span class="label label-success">Required</span>
{:else}
<span class="label label-warning">Optional</span>
{/if}
<span>emailVisibility</span>
</div>
</td>
@@ -228,8 +225,8 @@ final record = await pb.collection('${collection?.name}').update('RECORD_ID', bo
<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.
This field is required only when changing the record password. Superusers and auth records
with "Manage" access can skip this field.
</td>
</tr>
<tr>
@@ -269,15 +266,15 @@ final record = await pb.collection('${collection?.name}').update('RECORD_ID', bo
<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.
This field can be set only by superusers or auth records with "Manage" access.
</td>
</tr>
<tr>
<td colspan="3" class="txt-hint">Schema fields</td>
<td colspan="3" class="txt-hint txt-bold">Other fields</td>
</tr>
{/if}
{#each collection?.schema as field (field.name)}
{#each tableFields as field (field.name)}
<tr>
<td>
<div class="inline-flex">
@@ -307,9 +304,7 @@ final record = await pb.collection('${collection?.name}').update('RECORD_ID', bo
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.maxSelect == 1 ? "id" : "ids"}.
{/if}
</td>
</tr>
@@ -1,16 +1,11 @@
<script>
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;
let responseTab = 204;
let responses = [];
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseUrl);
$: responses = [
{
code: 204,
@@ -34,39 +29,6 @@
];
</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>
</div>
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${backendAbsUrl}');
...
await pb.collection('${collection?.name}').confirmVerification('TOKEN');
// optionally refresh the previous authStore state with the latest record changes
await pb.collection('${collection?.name}').authRefresh();
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${backendAbsUrl}');
...
await pb.collection('${collection?.name}').confirmVerification('TOKEN');
// optionally refresh the previous authStore state with the latest record changes
await pb.collection('${collection?.name}').authRefresh();
`}
/>
<h6 class="m-b-xs">API details</h6>
<div class="alert alert-success">
<strong class="label label-primary">POST</strong>
<div class="content">
@@ -1,16 +1,11 @@
<script>
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;
let responseTab = 204;
let responses = [];
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseUrl);
$: responses = [
{
code: 204,
@@ -34,33 +29,6 @@
];
</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">
@@ -0,0 +1,74 @@
<script>
import SdkTabs from "@/components/base/SdkTabs.svelte";
import VerificationApiConfirmDocs from "@/components/collections/docs/VerificationApiConfirmDocs.svelte";
import VerificationApiRequestDocs from "@/components/collections/docs/VerificationApiRequestDocs.svelte";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
export let collection;
const apiTabs = [
{ title: "Request verification", component: VerificationApiRequestDocs },
{ title: "Confirm verification", component: VerificationApiConfirmDocs },
];
let activeApiTab = 0;
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseURL);
</script>
<h3 class="m-b-sm">Account verification ({collection.name})</h3>
<div class="content txt-lg m-b-sm">
<p>Sends <strong>{collection.name}</strong> account verification request.</p>
</div>
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${backendAbsUrl}');
...
await pb.collection('${collection?.name}').requestVerification('test@example.com');
// ---
// (optional) in your custom confirmation page:
// ---
await pb.collection('${collection?.name}').confirmVerification('VERIFICATION_TOKEN');
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${backendAbsUrl}');
...
await pb.collection('${collection?.name}').requestVerification('test@example.com');
// ---
// (optional) in your custom confirmation page:
// ---
await pb.collection('${collection?.name}').confirmVerification('VERIFICATION_TOKEN');
`}
/>
<h6 class="m-b-xs">API details</h6>
<div class="tabs">
<div class="tabs-header compact">
{#each apiTabs as tab, i}
<button class="tab-item" class:active={activeApiTab == i} on:click={() => (activeApiTab = i)}>
<div class="txt">{tab.title}</div>
</button>
{/each}
</div>
<div class="tabs-content">
{#each apiTabs as tab, i}
<div class="tab-item" class:active={activeApiTab == i}>
<svelte:component this={tab.component} {collection} />
</div>
{/each}
</div>
</div>
@@ -2,7 +2,7 @@
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";
import SdkTabs from "@/components/base/SdkTabs.svelte";
import FieldsQueryParam from "@/components/collections/docs/FieldsQueryParam.svelte";
export let collection;
@@ -10,9 +10,9 @@
let responseTab = 200;
let responses = [];
$: adminsOnly = collection?.viewRule === null;
$: superusersOnly = collection?.viewRule === null;
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseUrl);
$: backendAbsUrl = CommonHelper.getApiExampleUrl(ApiClient.baseURL);
$: if (collection?.id) {
responses.push({
@@ -20,13 +20,13 @@
body: JSON.stringify(CommonHelper.dummyCollectionRecord(collection), null, 2),
});
if (adminsOnly) {
if (superusersOnly) {
responses.push({
code: 403,
body: `
{
"code": 403,
"message": "Only admins can access this action.",
"message": "Only superusers can access this action.",
"data": {}
}
`,
@@ -84,8 +84,8 @@
/api/collections/<strong>{collection.name}</strong>/records/<strong>:id</strong>
</p>
</div>
{#if adminsOnly}
<p class="txt-hint txt-sm txt-right">Requires admin <code>Authorization:TOKEN</code> header</p>
{#if superusersOnly}
<p class="txt-hint txt-sm txt-right">Requires superuser <code>Authorization:TOKEN</code> header</p>
{/if}
</div>
@@ -1,5 +1,5 @@
<script>
import AppleSecretPopup from "@/components/settings/providers/AppleSecretPopup.svelte";
import AppleSecretPopup from "@/components/collections/providers/AppleSecretPopup.svelte";
export let key = "";
export let config = {};
@@ -0,0 +1,22 @@
<script>
import Field from "@/components/base/Field.svelte";
export let key = "";
export let config = {};
</script>
<div class="section-title">Azure AD endpoints</div>
<Field class="form-field required" name="{key}.authURL" let:uniqueId>
<label for={uniqueId}>Auth URL</label>
<input type="url" id={uniqueId} bind:value={config.authURL} required />
<div class="help-block">
Ex. {`https://login.microsoftonline.com/YOUR_DIRECTORY_TENANT_ID/oauth2/v2.0/authorize`}
</div>
</Field>
<Field class="form-field required" name="{key}.tokenURL" let:uniqueId>
<label for={uniqueId}>Token URL</label>
<input type="url" id={uniqueId} bind:value={config.tokenURL} required />
<div class="help-block">
Ex. {`https://login.microsoftonline.com/YOUR_DIRECTORY_TENANT_ID/oauth2/v2.0/token`}
</div>
</Field>
@@ -6,8 +6,6 @@
export let key = "";
export let config = {};
$: isRequired = !!config.enabled;
if (CommonHelper.isEmpty(config.pkce)) {
config.pkce = true;
}
@@ -17,26 +15,26 @@
}
</script>
<Field class="form-field {isRequired ? 'required' : ''}" name="{key}.displayName" let:uniqueId>
<Field class="form-field required" name="{key}.displayName" let:uniqueId>
<label for={uniqueId}>Display name</label>
<input type="text" id={uniqueId} bind:value={config.displayName} required={isRequired} />
<input type="text" id={uniqueId} bind:value={config.displayName} required />
</Field>
<div class="section-title">Endpoints</div>
<Field class="form-field {isRequired ? 'required' : ''}" name="{key}.authUrl" let:uniqueId>
<Field class="form-field required" name="{key}.authURL" let:uniqueId>
<label for={uniqueId}>Auth URL</label>
<input type="url" id={uniqueId} bind:value={config.authUrl} required={isRequired} />
<input type="url" id={uniqueId} bind:value={config.authURL} required />
</Field>
<Field class="form-field {isRequired ? 'required' : ''}" name="{key}.tokenUrl" let:uniqueId>
<Field class="form-field required" name="{key}.tokenURL" let:uniqueId>
<label for={uniqueId}>Token URL</label>
<input type="url" id={uniqueId} bind:value={config.tokenUrl} required={isRequired} />
<input type="url" id={uniqueId} bind:value={config.tokenURL} required />
</Field>
<Field class="form-field {isRequired ? 'required' : ''}" name="{key}.userApiUrl" let:uniqueId>
<label for={uniqueId}>User API URL</label>
<input type="url" id={uniqueId} bind:value={config.userApiUrl} required={isRequired} />
<Field class="form-field required" name="{key}.userInfoURL" let:uniqueId>
<label for={uniqueId}>User info URL</label>
<input type="url" id={uniqueId} bind:value={config.userInfoURL} required />
</Field>
<Field class="form-field" name="{key}.pkce" let:uniqueId>
@@ -10,15 +10,15 @@
</script>
<div class="section-title">{title}</div>
<Field class="form-field {isRequired ? 'required' : ''}" name="{key}.authUrl" let:uniqueId>
<Field class="form-field {isRequired ? 'required' : ''}" name="{key}.authURL" let:uniqueId>
<label for={uniqueId}>Auth URL</label>
<input type="url" id={uniqueId} bind:value={config.authUrl} required={isRequired} />
<input type="url" id={uniqueId} bind:value={config.authURL} required={isRequired} />
</Field>
<Field class="form-field {isRequired ? 'required' : ''}" name="{key}.tokenUrl" let:uniqueId>
<Field class="form-field {isRequired ? 'required' : ''}" name="{key}.tokenURL" let:uniqueId>
<label for={uniqueId}>Token URL</label>
<input type="url" id={uniqueId} bind:value={config.tokenUrl} required={isRequired} />
<input type="url" id={uniqueId} bind:value={config.tokenURL} required={isRequired} />
</Field>
<Field class="form-field {isRequired ? 'required' : ''}" name="{key}.userApiUrl" let:uniqueId>
<label for={uniqueId}>User API URL</label>
<input type="url" id={uniqueId} bind:value={config.userApiUrl} required={isRequired} />
<Field class="form-field {isRequired ? 'required' : ''}" name="{key}.userInfoURL" let:uniqueId>
<label for={uniqueId}>User info URL</label>
<input type="url" id={uniqueId} bind:value={config.userInfoURL} required={isRequired} />
</Field>
@@ -1,7 +1,7 @@
<script>
import { createEventDispatcher } from "svelte";
import CommonHelper from "@/utils/CommonHelper";
import Toggler from "@/components/base/Toggler.svelte";
import CommonHelper from "@/utils/CommonHelper";
import { createEventDispatcher } from "svelte";
let classes = "";
export { classes as class }; // export reserved keyword
@@ -35,7 +35,7 @@
icon: CommonHelper.getFieldTypeIcon("email"),
},
{
label: "Url",
label: "URL",
value: "url",
icon: CommonHelper.getFieldTypeIcon("url"),
},
@@ -44,6 +44,11 @@
value: "date",
icon: CommonHelper.getFieldTypeIcon("date"),
},
{
label: "Autodate",
value: "autodate",
icon: CommonHelper.getFieldTypeIcon("autodate"),
},
{
label: "Select",
value: "select",
@@ -64,6 +69,11 @@
value: "json",
icon: CommonHelper.getFieldTypeIcon("json"),
},
// {
// label: "Password",
// value: "password",
// icon: CommonHelper.getFieldTypeIcon("password"),
// },
];
function select(fieldType) {
@@ -21,34 +21,43 @@
number: "Nonzero",
};
// @todo refactor once the UI is dynamic
const authHideNonemptyToggle = ["password", "tokenKey", "id", "autodate"];
const authHideHiddenToggle = ["password", "tokenKey", "id", "email"];
const authHidePresentableToggle = ["password", "tokenKey"];
export let key = "";
export let field = CommonHelper.initSchemaField();
export let draggable = true;
export let collection = {};
let nameInput;
let showOptions = false;
$: if (field.toDelete) {
$: isAuthCollection = collection?.type == "auth";
$: if (field._toDelete) {
// reset the name if it was previously deleted
if (field.originalName && field.name !== field.originalName) {
field.name = field.originalName;
if (field._originalName && field.name !== field._originalName) {
field.name = field._originalName;
}
}
$: if (!field.originalName && field.name) {
field.originalName = field.name;
$: if (!field._originalName && field.name) {
field._originalName = field.name;
}
$: if (typeof field.toDelete === "undefined") {
field.toDelete = false; // normalize
$: if (typeof field._toDelete === "undefined") {
field._toDelete = false; // normalize
}
$: if (field.required) {
field.nullable = false;
}
$: interactive = !field.toDelete && !(field.id && field.system);
$: interactive = !field._toDelete;
$: hasErrors = !CommonHelper.isEmpty(CommonHelper.getNestedVal($errors, `schema.${key}`));
$: hasErrors = !CommonHelper.isEmpty(CommonHelper.getNestedVal($errors, `fields.${key}`));
$: requiredLabel = customRequiredLabels[field?.type] || "Nonempty";
@@ -57,19 +66,19 @@
collapse();
dispatch("remove");
} else {
field.toDelete = true;
field._toDelete = true;
}
}
function restore() {
field.toDelete = false;
field._toDelete = false;
// reset all errors since the error index key would have been changed
setErrors({});
}
function duplicate() {
if (!field.toDelete) {
if (!field._toDelete) {
collapse();
dispatch("duplicate");
}
@@ -126,44 +135,49 @@
class="schema-field"
class:required={field.required}
class:expanded={interactive && showOptions}
class:deleted={field.toDelete}
class:deleted={field._toDelete}
transition:slide={{ duration: 150 }}
>
<div class="schema-field-header">
{#if interactive}
{#if interactive && draggable}
<div class="drag-handle-wrapper" draggable={true} aria-label="Sort">
<span class="drag-handle" />
</div>
{/if}
<Field
class="form-field required m-0 {!interactive ? 'disabled' : ''}"
name="schema.{key}.name"
name="fields.{key}.name"
inlineError
>
{#if field.required}
<div class="field-labels">
<div class="field-labels">
{#if field.required}
<span class="label label-success">{requiredLabel}</span>
</div>
{/if}
{/if}
{#if field.hidden}
<span class="label label-danger">Hidden</span>
{/if}
</div>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="form-field-addon prefix no-pointer-events field-type-icon"
class:txt-disabled={!interactive}
class="form-field-addon prefix field-type-icon"
class:txt-disabled={!interactive || field.system}
use:tooltip={field.type + (field.system ? " (system)" : "")}
on:click={() => nameInput?.focus()}
>
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
</div>
<!-- svelte-ignore a11y-autofocus -->
<input
bind:this={nameInput}
type="text"
required
disabled={!interactive}
readonly={field.id && field.system}
disabled={!interactive || field.system}
spellcheck="false"
autofocus={!field.id}
placeholder="Field name"
value={field.name}
title="System field"
on:input={(e) => {
const oldName = field.name;
field.name = normalizeFieldName(e.target.value);
@@ -178,10 +192,10 @@
<span class="separator" />
</slot>
{#if field.toDelete}
{#if field._toDelete}
<button
type="button"
class="btn btn-sm btn-circle btn-warning btn-transparent options-trigger"
class="btn btn-sm btn-circle btn-success btn-transparent options-trigger"
aria-label="Restore"
use:tooltip={"Restore"}
on:click={restore}
@@ -191,7 +205,7 @@
{:else if interactive}
<button
type="button"
aria-label="Toggle field options"
aria-label="Toggle {field.name} field options"
class="btn btn-sm btn-circle options-trigger {showOptions
? 'btn-secondary'
: 'btn-transparent'}"
@@ -206,49 +220,83 @@
</div>
{#if interactive && showOptions}
<div class="schema-field-options" transition:slide={{ duration: 150 }}>
<div class="schema-field-options" transition:slide={{ delay: 10, duration: 150 }}>
<div class="hidden-empty m-b-sm">
<slot name="options" {interactive} {hasErrors} />
</div>
<div class="schema-field-options-footer">
<Field class="form-field form-field-toggle" name="requried" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={field.required} />
<label for={uniqueId}>
<span class="txt">{requiredLabel}</span>
<i
class="ri-information-line link-hint"
use:tooltip={{
text: `Requires the field value NOT to be ${CommonHelper.zeroDefaultStr(
field,
)}.`,
}}
/>
</label>
</Field>
<!-- @todo move to each field after the refactoring -->
{#if !field.primaryKey && field.type != "autodate" && (!isAuthCollection || !authHideNonemptyToggle.includes(field.name))}
<Field class="form-field form-field-toggle" name="requried" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={field.required} />
<label for={uniqueId}>
<span class="txt">{requiredLabel}</span>
<i
class="ri-information-line link-hint"
use:tooltip={{
text: `Requires the field value NOT to be ${CommonHelper.zeroDefaultStr(
field,
)}.`,
}}
/>
</label>
</Field>
{/if}
<Field class="form-field form-field-toggle" name="presentable" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={field.presentable} />
<label for={uniqueId}>
<span class="txt">Presentable</span>
<i
class="ri-information-line link-hint"
use:tooltip={{
text: `Whether the field should be preferred in the Admin UI relation listings (default to auto).`,
{#if !field.primaryKey && (!isAuthCollection || !authHideHiddenToggle.includes(field.name))}
<Field class="form-field form-field-toggle" name="hidden" let:uniqueId>
<input
type="checkbox"
id={uniqueId}
bind:checked={field.hidden}
on:change={(e) => {
if (e.target.checked) {
field.presentable = false;
}
}}
/>
</label>
</Field>
<label for={uniqueId}>
<span class="txt">Hidden</span>
<i
class="ri-information-line link-hint"
use:tooltip={{
text: `Hide from the JSON API response and filters.`,
}}
/>
</label>
</Field>
{/if}
{#if !isAuthCollection || !authHidePresentableToggle.includes(field.name)}
<Field class="form-field form-field-toggle m-0" name="presentable" let:uniqueId>
<input
type="checkbox"
id={uniqueId}
bind:checked={field.presentable}
disabled={field.hidden}
/>
<label for={uniqueId}>
<span class="txt">Presentable</span>
<i
class="ri-information-line {field.hidden ? 'txt-disabled' : 'link-hint'}"
use:tooltip={{
text: `Whether the field should be preferred in the Superuser UI relation listings (default to auto).`,
}}
/>
</label>
</Field>
{/if}
<slot name="optionsFooter" {interactive} {hasErrors} />
{#if !field.toDelete}
{#if !field._toDelete && !field.primaryKey}
<div class="m-l-auto txt-right">
<div class="inline-flex flex-gap-sm flex-nowrap">
<div
tabindex="0"
role="button"
aria-label="More"
title="More field options"
class="btn btn-circle btn-sm btn-transparent"
>
<i class="ri-more-line" aria-hidden="true" />
@@ -263,14 +311,16 @@
>
<span class="txt">Duplicate</span>
</button>
<button
type="button"
class="dropdown-item"
role="menuitem"
on:click|preventDefault={remove}
>
<span class="txt">Remove</span>
</button>
{#if !field.system}
<button
type="button"
class="dropdown-item"
role="menuitem"
on:click|preventDefault={remove}
>
<span class="txt">Remove</span>
</button>
{/if}
</Toggler>
</div>
</div>
@@ -0,0 +1,78 @@
<script>
import tooltip from "@/actions/tooltip";
import Field from "@/components/base/Field.svelte";
import ObjectSelect from "@/components/base/ObjectSelect.svelte";
import SchemaField from "@/components/collections/schema/SchemaField.svelte";
const ON_CREATE = 1;
const ON_UPDATE = 2;
const ON_CREATE_UPDATE = 3;
const options = [
{ label: "Create", value: ON_CREATE },
{ label: "Update", value: ON_UPDATE },
{ label: "Create/Update", value: ON_CREATE_UPDATE },
];
export let field;
export let key = "";
let selectedOption = optionFromField();
$: updateField(selectedOption);
function optionFromField() {
if (field.onCreate && field.onUpdate) {
return ON_CREATE_UPDATE;
}
if (field.onUpdate) {
return ON_UPDATE;
}
return ON_CREATE;
}
function updateField(option) {
switch (option) {
case ON_CREATE:
field.onCreate = true;
field.onUpdate = false;
break;
case ON_UPDATE:
field.onCreate = false;
field.onUpdate = true;
break;
case ON_CREATE_UPDATE:
field.onCreate = true;
field.onUpdate = true;
break;
}
}
</script>
<SchemaField bind:field {key} on:rename on:remove on:duplicate {...$$restProps}>
<svelte:fragment let:interactive>
<div class="separator" />
<Field
class="form-field form-field-single-multiple-select form-field-autodate-select {!interactive
? 'readonly'
: ''}"
inlineError
let:uniqueId
>
<div use:tooltip={{ text: "Auto set on:", position: "top" }}>
<ObjectSelect
id={uniqueId}
items={options}
disabled={field.system}
readonly={!interactive}
bind:keyOfSelected={selectedOption}
/>
</div>
</Field>
<div class="separator" />
</svelte:fragment>
</SchemaField>
@@ -1,27 +1,27 @@
<script>
import Flatpickr from "svelte-flatpickr";
import CommonHelper from "@/utils/CommonHelper";
import Field from "@/components/base/Field.svelte";
import SchemaField from "@/components/collections/schema/SchemaField.svelte";
import CommonHelper from "@/utils/CommonHelper";
import Flatpickr from "svelte-flatpickr";
export let field;
export let key = "";
let pickerMinValue = field?.options?.min;
let pickerMaxValue = field?.options?.max;
let pickerMinValue = field?.min;
let pickerMaxValue = field?.max;
$: if (pickerMinValue != field?.options?.min) {
pickerMinValue = field?.options?.min;
$: if (pickerMinValue != field?.min) {
pickerMinValue = field?.min;
}
$: if (pickerMaxValue != field?.options?.max) {
pickerMaxValue = field?.options?.max;
$: if (pickerMaxValue != field?.max) {
pickerMaxValue = field?.max;
}
// ensure that value is set even on manual input edit
function onClose(e, key) {
if (e.detail && e.detail.length == 3) {
field.options[key] = e.detail[1];
field[key] = e.detail[1];
}
}
</script>
@@ -30,26 +30,26 @@
<svelte:fragment slot="options">
<div class="grid grid-sm">
<div class="col-sm-6">
<Field class="form-field" name="schema.{key}.options.min" let:uniqueId>
<Field class="form-field" name="fields.{key}.min" let:uniqueId>
<label for={uniqueId}>Min date (UTC)</label>
<Flatpickr
id={uniqueId}
options={CommonHelper.defaultFlatpickrOptions()}
bind:value={pickerMinValue}
bind:formattedValue={field.options.min}
bind:formattedValue={field.min}
on:close={(e) => onClose(e, "min")}
/>
</Field>
</div>
<div class="col-sm-6">
<Field class="form-field" name="schema.{key}.options.max" let:uniqueId>
<Field class="form-field" name="fields.{key}.max" let:uniqueId>
<label for={uniqueId}>Max date (UTC)</label>
<Flatpickr
id={uniqueId}
options={CommonHelper.defaultFlatpickrOptions()}
bind:value={pickerMaxValue}
bind:formattedValue={field.options.max}
bind:formattedValue={field.max}
on:close={(e) => onClose(e, "max")}
/>
</Field>
@@ -1,27 +1,29 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import Field from "@/components/base/Field.svelte";
import SchemaField from "@/components/collections/schema/SchemaField.svelte";
export let field;
export let key = "";
$: if (CommonHelper.isEmpty(field.options)) {
loadDefaults();
}
function loadDefaults() {
field.options = {
convertUrls: false,
};
}
</script>
<SchemaField bind:field {key} on:rename on:remove on:duplicate {...$$restProps}>
<svelte:fragment slot="optionsFooter">
<Field class="form-field form-field-toggle" name="schema.{key}.options.convertUrls" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={field.options.convertUrls} />
<svelte:fragment slot="options">
<Field class="form-field m-b-sm" name="fields.{key}.maxSize" let:uniqueId>
<label for={uniqueId}>Max size <small>(bytes)</small></label>
<input
type="number"
id={uniqueId}
step="1"
min="0"
value={field.maxSize || ""}
on:input={(e) => (field.maxSize = e.target.value << 0)}
placeholder="Default to max ~5MB"
/>
</Field>
<Field class="form-field form-field-toggle" name="fields.{key}.convertURLs" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={field.convertURLs} />
<label for={uniqueId}>
<span class="txt">Strip urls domain</span>
<i
@@ -1,9 +1,9 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import Field from "@/components/base/Field.svelte";
import MultipleValueInput from "@/components/base/MultipleValueInput.svelte";
import SchemaField from "@/components/collections/schema/SchemaField.svelte";
import CommonHelper from "@/utils/CommonHelper";
export let field;
export let key = "";
@@ -13,7 +13,7 @@
<svelte:fragment slot="options">
<div class="grid grid-sm">
<div class="col-sm-6">
<Field class="form-field" name="schema.{key}.options.exceptDomains" let:uniqueId>
<Field class="form-field" name="fields.{key}.exceptDomains" let:uniqueId>
<label for={uniqueId}>
<span class="txt">Except domains</span>
<i
@@ -26,16 +26,16 @@
</label>
<MultipleValueInput
id={uniqueId}
disabled={!CommonHelper.isEmpty(field.options.onlyDomains)}
bind:value={field.options.exceptDomains}
disabled={!CommonHelper.isEmpty(field.onlyDomains)}
bind:value={field.exceptDomains}
/>
<div class="help-block">Use comma as separator.</div>
</Field>
</div>
<div class="col-sm-6">
<Field class="form-field" name="schema.{key}.options.onlyDomains" let:uniqueId>
<label for="{uniqueId}.options.onlyDomains">
<Field class="form-field" name="fields.{key}.onlyDomains" let:uniqueId>
<label for="{uniqueId}.onlyDomains">
<span class="txt">Only domains</span>
<i
class="ri-information-line link-hint"
@@ -46,9 +46,9 @@
/>
</label>
<MultipleValueInput
id="{uniqueId}.options.onlyDomains"
disabled={!CommonHelper.isEmpty(field.options.exceptDomains)}
bind:value={field.options.onlyDomains}
id="{uniqueId}.onlyDomains"
disabled={!CommonHelper.isEmpty(field.exceptDomains)}
bind:value={field.onlyDomains}
/>
<div class="help-block">Use comma as separator.</div>
</Field>
@@ -18,10 +18,10 @@
];
let mimeTypesList = baseMimeTypesList.slice();
let isSingle = field.options?.maxSelect <= 1;
let isSingle = field.maxSelect <= 1;
let oldIsSingle = isSingle;
$: if (CommonHelper.isEmpty(field.options)) {
$: if (typeof field.maxSelect == "undefined") {
loadDefaults();
} else {
appendMissingMimeTypes();
@@ -30,19 +30,17 @@
$: if (oldIsSingle != isSingle) {
oldIsSingle = isSingle;
if (isSingle) {
field.options.maxSelect = 1;
field.maxSelect = 1;
} else {
field.options.maxSelect = field.options?.values?.length || 99;
field.maxSelect = 99;
}
}
function loadDefaults() {
field.options = {
maxSelect: 1,
maxSize: 5242880,
thumbs: [],
mimeTypes: [],
};
field.maxSelect = 1;
field.thumbs = [];
field.mimeTypes = [];
isSingle = true;
oldIsSingle = isSingle;
}
@@ -50,13 +48,13 @@
// append any previously set custom mime types to the predefined
// list for backward compatibility
function appendMissingMimeTypes() {
if (CommonHelper.isEmpty(field.options.mimeTypes)) {
if (CommonHelper.isEmpty(field.mimeTypes)) {
return;
}
const missing = [];
for (const v of field.options.mimeTypes) {
for (const v of field.mimeTypes) {
if (!!mimeTypesList.find((item) => item.mimeType === v)) {
continue; // exist
}
@@ -93,7 +91,7 @@
<svelte:fragment slot="options">
<div class="grid grid-sm">
<div class="col-sm-12">
<Field class="form-field" name="schema.{key}.options.mimeTypes" let:uniqueId>
<Field class="form-field" name="fields.{key}.mimeTypes" let:uniqueId>
<label for={uniqueId}>
<span class="txt">Allowed mime types</span>
<i
@@ -114,7 +112,7 @@
items={mimeTypesList}
labelComponent={MimeTypeSelectOption}
optionComponent={MimeTypeSelectOption}
bind:keyOfSelected={field.options.mimeTypes}
bind:keyOfSelected={field.mimeTypes}
/>
<div class="help-block">
<div tabindex="0" role="button" class="inline-flex flex-gap-0">
@@ -126,7 +124,7 @@
class="dropdown-item closable"
role="menuitem"
on:click={() => {
field.options.mimeTypes = [
field.mimeTypes = [
"image/jpeg",
"image/png",
"image/svg+xml",
@@ -142,7 +140,7 @@
class="dropdown-item closable"
role="menuitem"
on:click={() => {
field.options.mimeTypes = [
field.mimeTypes = [
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
@@ -158,7 +156,7 @@
class="dropdown-item closable"
role="menuitem"
on:click={() => {
field.options.mimeTypes = [
field.mimeTypes = [
"video/mp4",
"video/x-ms-wmv",
"video/quicktime",
@@ -173,7 +171,7 @@
class="dropdown-item closable"
role="menuitem"
on:click={() => {
field.options.mimeTypes = [
field.mimeTypes = [
"application/zip",
"application/x-7z-compressed",
"application/x-rar-compressed",
@@ -189,7 +187,7 @@
</div>
<div class={!isSingle ? "col-sm-6" : "col-sm-8"}>
<Field class="form-field" name="schema.{key}.options.thumbs" let:uniqueId>
<Field class="form-field" name="fields.{key}.thumbs" let:uniqueId>
<label for={uniqueId}>
<span class="txt">Thumb sizes</span>
<i
@@ -202,8 +200,8 @@
</label>
<MultipleValueInput
id={uniqueId}
placeholder="eg. 50x50, 480x720"
bind:value={field.options.thumbs}
placeholder="e.g. 50x50, 480x720"
bind:value={field.thumbs}
/>
<div class="help-block">
<span class="txt">Use comma as separator.</span>
@@ -214,27 +212,27 @@
<ul class="m-0">
<li>
<strong>WxH</strong>
(eg. 100x50) - crop to WxH viewbox (from center)
(e.g. 100x50) - crop to WxH viewbox (from center)
</li>
<li>
<strong>WxHt</strong>
(eg. 100x50t) - crop to WxH viewbox (from top)
(e.g. 100x50t) - crop to WxH viewbox (from top)
</li>
<li>
<strong>WxHb</strong>
(eg. 100x50b) - crop to WxH viewbox (from bottom)
(e.g. 100x50b) - crop to WxH viewbox (from bottom)
</li>
<li>
<strong>WxHf</strong>
(eg. 100x50f) - fit inside a WxH viewbox (without cropping)
(e.g. 100x50f) - fit inside a WxH viewbox (without cropping)
</li>
<li>
<strong>0xH</strong>
(eg. 0x50) - resize to H height preserving the aspect ratio
(e.g. 0x50) - resize to H height preserving the aspect ratio
</li>
<li>
<strong>Wx0</strong>
(eg. 100x0) - resize to W width preserving the aspect ratio
(e.g. 100x0) - resize to W width preserving the aspect ratio
</li>
</ul>
</Toggler>
@@ -244,16 +242,24 @@
</div>
<div class={!isSingle ? "col-sm-3" : "col-sm-4"}>
<Field class="form-field required" name="schema.{key}.options.maxSize" let:uniqueId>
<Field class="form-field" name="fields.{key}.maxSize" let:uniqueId>
<label for={uniqueId}>Max file size</label>
<input type="number" id={uniqueId} step="1" min="0" bind:value={field.options.maxSize} />
<input
type="number"
id={uniqueId}
step="1"
min="0"
value={field.maxSize || ""}
on:input={(e) => (field.maxSize = e.target.value << 0)}
placeholder="Default to max ~5MB"
/>
<div class="help-block">Must be in bytes.</div>
</Field>
</div>
{#if !isSingle}
<div class="col-sm-3">
<Field class="form-field required" name="schema.{key}.options.maxSelect" let:uniqueId>
<Field class="form-field" name="fields.{key}.maxSelect" let:uniqueId>
<label for={uniqueId}>Max select</label>
<input
id={uniqueId}
@@ -261,29 +267,31 @@
step="1"
min="2"
required
bind:value={field.options.maxSelect}
placeholder="Default to single"
bind:value={field.maxSelect}
/>
</Field>
</div>
{/if}
</div>
</svelte:fragment>
<svelte:fragment slot="optionsFooter">
<Field class="form-field form-field-toggle" name="schema.{key}.options.protected" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={field.options.protected} />
<label for={uniqueId}>
<span class="txt">Protected</span>
</label>
<a
href={import.meta.env.PB_PROTECTED_FILE_DOCS}
class="toggle-info txt-sm txt-hint m-l-5"
target="_blank"
rel="noopener"
>
(Learn more)
</a>
</Field>
<Field class="form-field form-field-toggle" name="fields.{key}.protected" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={field.protected} />
<label for={uniqueId}>
<span class="txt">Protected</span>
</label>
<small class="txt-hint">
it will require View API rule permissions and file token to be accessible
<a
href={import.meta.env.PB_PROTECTED_FILE_DOCS}
class="toggle-info"
target="_blank"
rel="noopener"
>
(Learn more)
</a>
</small>
</Field>
</div>
</svelte:fragment>
</SchemaField>
@@ -1,30 +1,27 @@
<script>
import { slide } from "svelte/transition";
import CommonHelper from "@/utils/CommonHelper";
import Field from "@/components/base/Field.svelte";
import SchemaField from "@/components/collections/schema/SchemaField.svelte";
import { slide } from "svelte/transition";
export let field;
export let key = "";
let showInfo = false;
$: if (CommonHelper.isEmpty(field.options)) {
loadDefaults();
}
function loadDefaults() {
field.options = {
maxSize: 2000000,
};
}
</script>
<SchemaField bind:field {key} on:rename on:remove on:duplicate {...$$restProps}>
<svelte:fragment slot="options">
<Field class="form-field required m-b-sm" name="schema.{key}.options.maxSize" let:uniqueId>
<Field class="form-field m-b-sm" name="fields.{key}.maxSize" let:uniqueId>
<label for={uniqueId}>Max size <small>(bytes)</small></label>
<input type="number" id={uniqueId} step="1" min="0" bind:value={field.options.maxSize} />
<input
type="number"
id={uniqueId}
step="1"
min="0"
value={field.maxSize || ""}
on:input={(e) => (field.maxSize = e.target.value << 0)}
placeholder="Default to max ~5MB"
/>
</Field>
<button
@@ -11,29 +11,24 @@
<svelte:fragment slot="options">
<div class="grid grid-sm">
<div class="col-sm-6">
<Field class="form-field" name="schema.{key}.options.min" let:uniqueId>
<Field class="form-field" name="fields.{key}.min" let:uniqueId>
<label for={uniqueId}>Min</label>
<input type="number" id={uniqueId} bind:value={field.options.min} />
<input type="number" id={uniqueId} bind:value={field.min} />
</Field>
</div>
<div class="col-sm-6">
<Field class="form-field" name="schema.{key}.options.max" let:uniqueId>
<Field class="form-field" name="fields.{key}.max" let:uniqueId>
<label for={uniqueId}>Max</label>
<input
type="number"
id={uniqueId}
min={field.options.min}
bind:value={field.options.max}
/>
<input type="number" id={uniqueId} min={field.min} bind:value={field.max} />
</Field>
</div>
</div>
</svelte:fragment>
<svelte:fragment slot="optionsFooter">
<Field class="form-field form-field-toggle" name="schema.{key}.options.noDecimal" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={field.options.noDecimal} />
<Field class="form-field form-field-toggle" name="fields.{key}.onlyInt" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={field.onlyInt} />
<label for={uniqueId}>
<span class="txt">No decimals</span>
<i
@@ -0,0 +1,76 @@
<script>
import Field from "@/components/base/Field.svelte";
import SchemaField from "@/components/collections/schema/SchemaField.svelte";
import CommonHelper from "@/utils/CommonHelper";
export let field;
export let key = "";
$: if (CommonHelper.isEmpty(field.id)) {
loadDefaults();
}
function loadDefaults() {
field.cost = 11;
}
</script>
<SchemaField bind:field {key} on:rename on:remove on:duplicate {...$$restProps}>
<svelte:fragment slot="options">
<div class="grid grid-sm">
<div class="col-sm-6">
<Field class="form-field" name="fields.{key}.min" let:uniqueId>
<label for={uniqueId}>Min length</label>
<input
type="number"
id={uniqueId}
step="1"
min="0"
placeholder="No min limit"
value={field.min || ""}
on:input={(e) => (field.min = e.target.value << 0)}
/>
</Field>
</div>
<div class="col-sm-6">
<Field class="form-field" name="fields.{key}.max" let:uniqueId>
<label for={uniqueId}>Max length</label>
<input
type="number"
id={uniqueId}
step="1"
placeholder="Up to 71 chars"
min={field.min || 0}
max="71"
value={field.max || ""}
on:input={(e) => (field.max = e.target.value << 0)}
/>
</Field>
</div>
<div class="col-sm-6">
<Field class="form-field" name="fields.{key}.cost" let:uniqueId>
<label for={uniqueId}>Bcrypt cost</label>
<input
type="number"
id={uniqueId}
placeholder="Default to 10"
step="1"
min="6"
max="31"
value={field.cost || ""}
on:input={(e) => (field.cost = e.target.value << 0)}
/>
</Field>
</div>
<div class="col-sm-6">
<Field class="form-field" name="fields.{key}.pattern" let:uniqueId>
<label for={uniqueId}>Validation pattern</label>
<input type="text" id={uniqueId} placeholder="ex. ^\w+$" bind:value={field.pattern} />
</Field>
</div>
</div>
</svelte:fragment>
</SchemaField>
@@ -1,8 +1,6 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import Field from "@/components/base/Field.svelte";
import Select from "@/components/base/Select.svelte";
import ObjectSelect from "@/components/base/ObjectSelect.svelte";
import CollectionUpsertPanel from "@/components/collections/CollectionUpsertPanel.svelte";
import SchemaField from "@/components/collections/schema/SchemaField.svelte";
@@ -22,34 +20,32 @@
];
let upsertPanel = null;
let isSingle = field.options?.maxSelect == 1;
let isSingle = field.maxSelect <= 1;
let oldIsSingle = isSingle;
$: selectCollections = $collections.filter((c) => c.type != "view");
$: selectCollections = $collections.filter((c) => !c.system && c.type != "view");
// load defaults
$: if (CommonHelper.isEmpty(field.options)) {
$: if (typeof field.maxSelect == "undefined") {
loadDefaults();
}
$: if (oldIsSingle != isSingle) {
oldIsSingle = isSingle;
if (isSingle) {
field.options.minSelect = null;
field.options.maxSelect = 1;
field.minSelect = 0;
field.maxSelect = 1;
} else {
field.options.maxSelect = null;
field.maxSelect = 999;
}
}
$: selectedColection = $collections.find((c) => c.id == field.options.collectionId) || null;
$: selectedColection = $collections.find((c) => c.id == field.collectionId) || null;
function loadDefaults() {
field.options = {
maxSelect: 1,
collectionId: null,
cascadeDelete: false,
};
field.maxSelect = 1;
field.collectionId = null;
field.cascadeDelete = false;
isSingle = true;
oldIsSingle = isSingle;
}
@@ -62,7 +58,7 @@
<Field
class="form-field required {!interactive ? 'readonly' : ''}"
inlineError
name="schema.{key}.options.collectionId"
name="fields.{key}.collectionId"
let:uniqueId
>
<ObjectSelect
@@ -73,7 +69,7 @@
selectionKey="id"
items={selectCollections}
readonly={!interactive || field.id}
bind:keyOfSelected={field.options.collectionId}
bind:keyOfSelected={field.collectionId}
>
<svelte:fragment slot="afterOptions">
<hr />
@@ -111,35 +107,36 @@
<div class="grid grid-sm">
{#if !isSingle}
<div class="col-sm-6">
<Field class="form-field" name="schema.{key}.options.minSelect" let:uniqueId>
<Field class="form-field" name="fields.{key}.minSelect" let:uniqueId>
<label for={uniqueId}>Min select</label>
<input
type="number"
id={uniqueId}
step="1"
min="1"
min="0"
placeholder="No min limit"
bind:value={field.options.minSelect}
value={field.minSelect || ""}
on:input={(e) => (field.minSelect = e.target.value << 0)}
/>
</Field>
</div>
<div class="col-sm-6">
<Field class="form-field" name="schema.{key}.options.maxSelect" let:uniqueId>
<Field class="form-field" name="fields.{key}.maxSelect" let:uniqueId>
<label for={uniqueId}>Max select</label>
<input
type="number"
id={uniqueId}
step="1"
placeholder="No max limit"
min={field.options.minSelect || 2}
bind:value={field.options.maxSelect}
placeholder="Default to single"
min={field.minSelect || 1}
bind:value={field.maxSelect}
/>
</Field>
</div>
{/if}
<div class="col-sm-12">
<Field class="form-field" name="schema.{key}.options.cascadeDelete" let:uniqueId>
<Field class="form-field" name="fields.{key}.cascadeDelete" let:uniqueId>
<label for={uniqueId}>
<span class="txt">Cascade delete</span>
<!-- prettier-ignore -->
@@ -157,7 +154,7 @@
<ObjectSelect
id={uniqueId}
items={defaultOptions}
bind:keyOfSelected={field.options.cascadeDelete}
bind:keyOfSelected={field.cascadeDelete}
/>
</Field>
</div>
@@ -169,7 +166,7 @@
bind:this={upsertPanel}
on:save={(e) => {
if (e?.detail?.collection?.id && e.detail.collection.type != "view") {
field.options.collectionId = e.detail.collection.id;
field.collectionId = e.detail.collection.id;
}
}}
/>
@@ -1,10 +1,9 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import Field from "@/components/base/Field.svelte";
import MultipleValueInput from "@/components/base/MultipleValueInput.svelte";
import ObjectSelect from "@/components/base/ObjectSelect.svelte";
import SchemaField from "@/components/collections/schema/SchemaField.svelte";
import MultipleValueInput from "@/components/base/MultipleValueInput.svelte";
export let field;
export let key = "";
@@ -14,27 +13,25 @@
{ label: "Multiple", value: false },
];
let isSingle = field.options?.maxSelect <= 1;
let isSingle = field.maxSelect <= 1;
let oldIsSingle = isSingle;
$: if (CommonHelper.isEmpty(field.options)) {
$: if (typeof field.maxSelect == "undefined") {
loadDefaults();
}
$: if (oldIsSingle != isSingle) {
oldIsSingle = isSingle;
if (isSingle) {
field.options.maxSelect = 1;
field.maxSelect = 1;
} else {
field.options.maxSelect = field.options?.values?.length || 2;
field.maxSelect = field.values?.length || 2;
}
}
function loadDefaults() {
field.options = {
maxSelect: 1,
values: [],
};
field.maxSelect = 1;
field.values = [];
isSingle = true;
oldIsSingle = isSingle;
}
@@ -47,7 +44,7 @@
<Field
class="form-field required {!interactive ? 'readonly' : ''}"
inlineError
name="schema.{key}.options.values"
name="fields.{key}.values"
let:uniqueId
>
<div use:tooltip={{ text: "Choices (comma separated)", position: "top-left", delay: 700 }}>
@@ -56,7 +53,7 @@
placeholder="Choices: eg. optionA, optionB"
required
readonly={!interactive}
bind:value={field.options.values}
bind:value={field.values}
/>
</div>
</Field>
@@ -81,15 +78,16 @@
<svelte:fragment slot="options">
{#if !isSingle}
<Field class="form-field required" name="schema.{key}.options.maxSelect" let:uniqueId>
<Field class="form-field" name="fields.{key}.maxSelect" let:uniqueId>
<label for={uniqueId}>Max select</label>
<input
id={uniqueId}
type="number"
step="1"
min="2"
required
bind:value={field.options.maxSelect}
max={field.values.length}
placeholder="Default to single"
bind:value={field.maxSelect}
/>
</Field>
{/if}
@@ -1,6 +1,7 @@
<script>
import SchemaField from "@/components/collections/schema/SchemaField.svelte";
import tooltip from "@/actions/tooltip";
import Field from "@/components/base/Field.svelte";
import SchemaField from "@/components/collections/schema/SchemaField.svelte";
export let field;
export let key = "";
@@ -9,37 +10,73 @@
<SchemaField bind:field {key} on:rename on:remove on:duplicate {...$$restProps}>
<svelte:fragment slot="options">
<div class="grid grid-sm">
<div class="col-sm-3">
<Field class="form-field" name="schema.{key}.options.min" let:uniqueId>
<label for={uniqueId}>Min length</label>
<input type="number" id={uniqueId} step="1" min="0" bind:value={field.options.min} />
</Field>
</div>
<div class="col-sm-3">
<Field class="form-field" name="schema.{key}.options.max" let:uniqueId>
<label for={uniqueId}>Max length</label>
<div class="col-sm-6">
<Field class="form-field" name="fields.{key}.min" let:uniqueId>
<label for={uniqueId}>
<span class="txt">Min length</span>
<i
class="ri-information-line link-hint"
use:tooltip={"Clear the field or set it to 0 for no limit."}
/>
</label>
<input
type="number"
id={uniqueId}
step="1"
min={field.options.min || 0}
bind:value={field.options.max}
min="0"
placeholder="No min limit"
value={field.min || ""}
on:input={(e) => (field.min = e.target.value << 0)}
/>
</Field>
</div>
<div class="col-sm-6">
<Field class="form-field" name="schema.{key}.options.pattern" let:uniqueId>
<label for={uniqueId}>Regex pattern</label>
<Field class="form-field" name="fields.{key}.max" let:uniqueId>
<label for={uniqueId}>
<span class="txt">Max length</span>
<i
class="ri-information-line link-hint"
use:tooltip={"Clear the field or set it to 0 to fallback to the default limit."}
/>
</label>
<input
type="text"
type="number"
id={uniqueId}
placeholder={"Valid Go regular expression, eg. ^\\w+$"}
bind:value={field.options.pattern}
step="1"
placeholder="Default to max 5000 characters"
min={field.min || 0}
value={field.max || ""}
on:input={(e) => (field.max = e.target.value << 0)}
/>
</Field>
</div>
<div class="col-sm-6">
<Field class="form-field" name="fields.{key}.pattern" let:uniqueId>
<label for={uniqueId}>Validation pattern</label>
<input type="text" id={uniqueId} bind:value={field.pattern} />
<div class="help-block">
<p>Ex. <code>{"^[a-z0-9]+$"}</code></p>
</div>
</Field>
</div>
<div class="col-sm-6">
<Field class="form-field" name="fields.{key}.pattern" let:uniqueId>
<label for={uniqueId}>
<span class="txt">Autogenerate pattern</span>
<i
class="ri-information-line link-hint"
use:tooltip={"Set and autogenerate text matching the pattern on missing record create value."}
/>
</label>
<input type="text" id={uniqueId} bind:value={field.autogeneratePattern} />
<div class="help-block">
<p>Ex. <code>{"[a-z0-9]{30}"}</code></p>
</div>
</Field>
</div>
</div>
</svelte:fragment>
</SchemaField>
+43 -8
View File
@@ -66,12 +66,13 @@
"execTime",
"type",
"auth",
"authId",
"status",
"method",
"url",
"referer",
"remoteIp",
"userIp",
"remoteIP",
"userIP",
"userAgent",
"error",
"details",
@@ -135,19 +136,29 @@
<tr>
<td class="min-width txt-hint txt-bold">id</td>
<td>
<div class="label">
<span class="txt">{log.id}</span>
<div class="copy-icon-wrapper">
<CopyIcon value={log.id} />
<div class="txt">{log.id}</div>
</div>
</td>
</tr>
<tr>
<td class="min-width txt-hint txt-bold">level</td>
<td><LogLevel level={log.level} /></td>
<td>
<LogLevel level={log.level} />
<div class="copy-icon-wrapper">
<CopyIcon value={log.level} />
</div>
</td>
</tr>
<tr>
<td class="min-width txt-hint txt-bold">created</td>
<td><LogDate date={log.created} /></td>
<td>
<LogDate date={log.created} />
<div class="copy-icon-wrapper">
<CopyIcon value={log.created} />
</div>
</td>
</tr>
{#if !isRequest}
<tr>
@@ -155,6 +166,10 @@
<td>
{#if log.message}
<span class="txt">{log.message}</span>
<div class="copy-icon-wrapper">
<CopyIcon value={log.message} />
</div>
{:else}
<span class="txt txt-hint">N/A</span>
{/if}
@@ -163,13 +178,14 @@
{/if}
{#each extractKeys(log.data) as key}
{@const value = log.data[key]}
{@const isJson = value !== null && typeof value == "object"}
{@const isEmpty = CommonHelper.isEmpty(value)}
{@const isJson = !isEmpty && value !== null && typeof value == "object"}
<tr>
<td class="min-width txt-hint txt-bold" class:v-align-top={isJson}>
data.{key}
</td>
<td>
{#if CommonHelper.isEmpty(value)}
{#if isEmpty}
<span class="txt txt-hint">N/A</span>
{:else if isJson}
<CodeBlock content={JSON.stringify(value, null, 2)} />
@@ -184,6 +200,12 @@
{value}{isRequest && key == "execTime" ? "ms" : ""}
</span>
{/if}
{#if !isEmpty}
<div class="copy-icon-wrapper">
<CopyIcon {value} />
</div>
{/if}
</td>
</tr>
{/each}
@@ -207,4 +229,17 @@
.log-error-label {
white-space: normal;
}
.copy-icon-wrapper {
position: absolute;
right: 12px;
top: 12px;
opacity: 0;
transition: opacity var(--baseAnimationSpeed);
}
tr:hover .copy-icon-wrapper {
opacity: 1;
}
td:has(.copy-icon-wrapper) {
padding-right: 30px;
}
</style>
+57 -1
View File
@@ -14,8 +14,10 @@
Tooltip,
} from "chart.js";
import "chartjs-adapter-luxon";
import zoomPlugin from "chartjs-plugin-zoom";
export let filter = "";
export let zoom = {};
export let presets = "";
let chartCanvas;
@@ -23,6 +25,7 @@
let chartData = [];
let totalLogs = 0;
let isLoading = false;
let isZoomedOrPanned = false;
$: if (typeof filter !== "undefined" || typeof presets !== "undefined") {
load();
@@ -74,8 +77,13 @@
totalLogs = 0;
}
function resetZoom() {
chartInst?.resetZoom();
}
onMount(() => {
Chart.register(LineElement, PointElement, LineController, LinearScale, TimeScale, Filler, Tooltip);
Chart.register(zoomPlugin);
chartInst = new Chart(chartCanvas, {
type: "line",
@@ -143,6 +151,41 @@
legend: {
display: false,
},
zoom: {
enabled: true,
zoom: {
mode: "x",
pinch: {
enabled: true,
},
drag: {
enabled: true,
backgroundColor: "rgba(255, 99, 132, 0.2)",
borderWidth: 0,
threshold: 10,
},
limits: {
x: { minRange: 100000000 },
y: { minRange: 100000000 },
},
onZoomComplete: ({ chart }) => {
isZoomedOrPanned = chart.isZoomedOrPanned();
if (!isZoomedOrPanned) {
if (zoom.min || zoom.max) {
zoom = {}; // reset
}
} else {
// trim minutes and seconds since the statistic is hourly based
zoom.min =
CommonHelper.formatToUTCDate(chart.scales.x.min, "yyyy-MM-dd HH") +
":00:00.000Z";
zoom.max =
CommonHelper.formatToUTCDate(chart.scales.x.max, "yyyy-MM-dd HH") +
":59:59.999Z";
}
},
},
},
},
},
});
@@ -156,10 +199,18 @@
Found {totalLogs}
{totalLogs == 1 ? "log" : "logs"}
</div>
{#if isLoading}
<div class="chart-loader loader" transition:scale={{ duration: 150 }} />
{/if}
<canvas bind:this={chartCanvas} class="chart-canvas" />
<canvas bind:this={chartCanvas} class="chart-canvas" on:dblclick={resetZoom} />
{#if isZoomedOrPanned}
<button type="button" class="btn btn-secondary btn-sm btn-chart-zoom" on:click={resetZoom}>
Reset zoom
</button>
{/if}
</div>
<style>
@@ -187,4 +238,9 @@
font-size: var(--smFontSize);
color: var(--txtHintColor);
}
.btn-chart-zoom {
position: absolute;
right: 10px;
top: 20px;
}
</style>
+22 -14
View File
@@ -1,12 +1,12 @@
<script>
import { createEventDispatcher } from "svelte";
import { fly } from "svelte/transition";
import Scroller from "@/components/base/Scroller.svelte";
import SortHeader from "@/components/base/SortHeader.svelte";
import LogDate from "@/components/logs/LogDate.svelte";
import LogLevel from "@/components/logs/LogLevel.svelte";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import SortHeader from "@/components/base/SortHeader.svelte";
import Scroller from "@/components/base/Scroller.svelte";
import LogLevel from "@/components/logs/LogLevel.svelte";
import LogDate from "@/components/logs/LogDate.svelte";
import { createEventDispatcher } from "svelte";
import { fly } from "svelte/transition";
const dispatch = createEventDispatcher();
@@ -14,7 +14,8 @@
export let filter = "";
export let presets = "";
export let sort = "-rowid";
export let zoom = {};
export let sort = "-@rowid";
let logs = [];
let currentPage = 1;
@@ -23,7 +24,12 @@
let yieldedId = 0;
let bulkSelected = {};
$: if (typeof sort !== "undefined" || typeof filter !== "undefined" || typeof presets !== "undefined") {
$: if (
typeof sort !== "undefined" ||
typeof filter !== "undefined" ||
typeof presets !== "undefined" ||
typeof zoom !== "undefined"
) {
clearList();
load(1);
}
@@ -37,15 +43,17 @@
export async function load(page = 1, breakTasks = true) {
isLoading = true;
const normalizedFilter = [presets, CommonHelper.normalizeLogsFilter(filter)]
.filter(Boolean)
.join("&&");
const normalizedFilter = [presets, CommonHelper.normalizeLogsFilter(filter)];
if (zoom.min && zoom.max) {
normalizedFilter.push(`created >= "${zoom.min}" && created <= "${zoom.max}"`);
}
return ApiClient.logs
.getList(page, perPage, {
sort: sort,
skipTotal: 1,
filter: normalizedFilter,
filter: normalizedFilter.filter(Boolean).join("&&"),
})
.then(async (result) => {
if (page <= 1) {
@@ -174,7 +182,7 @@
}
if (log.data.type == "request") {
const requestKeys = ["status", "execTime", "auth", "userIp"];
const requestKeys = ["status", "execTime", "auth", "authId", "userIP"];
for (let key of requestKeys) {
if (typeof log.data[key] != "undefined") {
keys.push({ key });
@@ -298,7 +306,7 @@
{:else}
{keyItem.key}: {CommonHelper.stringifyValue(
log.data[keyItem.key],
"-",
"N/A",
80,
)}
{/if}
@@ -4,7 +4,6 @@
import ApiClient from "@/utils/ApiClient";
import { setErrors } from "@/stores/errors";
import { addSuccessToast } from "@/stores/toasts";
import tooltip from "@/actions/tooltip";
import Field from "@/components/base/Field.svelte";
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
import LogsLevelsInfo from "@/components/logs/LogsLevelsInfo.svelte";
@@ -86,7 +85,7 @@
}
</script>
<OverlayPanel bind:this={panel} popup class="admin-panel" beforeHide={() => !isSaving} on:hide on:show>
<OverlayPanel bind:this={panel} popup class="superuser-panel" beforeHide={() => !isSaving} on:hide on:show>
<svelte:fragment slot="header">
<h4>Logs settings</h4>
</svelte:fragment>
@@ -114,10 +113,15 @@
</div>
</Field>
<Field class="form-field form-field-toggle" name="logs.logIp" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={formSettings.logs.logIp} />
<Field class="form-field form-field-toggle" name="logs.logIP" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={formSettings.logs.logIP} />
<label for={uniqueId}>Enable IP logging</label>
</Field>
<Field class="form-field form-field-toggle" name="logs.logAuthId" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={formSettings.logs.logAuthId} />
<label for={uniqueId}>Enable Auth Id logging</label>
</Field>
</form>
{/if}
+14 -13
View File
@@ -16,8 +16,8 @@
$pageTitle = "Logs";
const LOG_QUERY_KEY = "logId";
const ADMIN_REQUESTS_QUERY_KEY = "adminRequests";
const ADMIN_REQUESTS_STORAGE_KEY = "adminLogRequests";
const ADMIN_REQUESTS_QUERY_KEY = "superuserRequests";
const ADMIN_REQUESTS_STORAGE_KEY = "superuserLogRequests";
const initialQueryParams = new URLSearchParams($querystring);
@@ -25,20 +25,21 @@
let logsSettingsPanel;
let refreshKey = 1;
let filter = initialQueryParams.get("filter") || "";
let withAdminLogs =
let zoom = {};
let withSuperuserLogs =
(initialQueryParams.get(ADMIN_REQUESTS_QUERY_KEY) ||
window.localStorage?.getItem(ADMIN_REQUESTS_STORAGE_KEY)) << 0;
let initialWithAdminLogs = withAdminLogs;
let initialWithSuperuserLogs = withSuperuserLogs;
$: if (initialQueryParams.get(LOG_QUERY_KEY) && logViewPanel) {
logViewPanel.show(initialQueryParams.get(LOG_QUERY_KEY));
}
$: presets = !withAdminLogs ? 'data.auth!="admin"' : "";
$: presets = !withSuperuserLogs ? 'data.auth!="_superusers"' : "";
$: if (initialWithAdminLogs != withAdminLogs) {
initialWithAdminLogs = withAdminLogs;
window.localStorage?.setItem(ADMIN_REQUESTS_STORAGE_KEY, withAdminLogs << 0);
$: if (initialWithSuperuserLogs != withSuperuserLogs) {
initialWithSuperuserLogs = withSuperuserLogs;
window.localStorage?.setItem(ADMIN_REQUESTS_STORAGE_KEY, withSuperuserLogs << 0);
updateQueryParams();
}
@@ -53,7 +54,7 @@
function updateQueryParams(extra = {}) {
let queryParams = {};
queryParams.filter = filter || null;
queryParams[ADMIN_REQUESTS_QUERY_KEY] = withAdminLogs << 0 || null;
queryParams[ADMIN_REQUESTS_QUERY_KEY] = withSuperuserLogs << 0 || null;
CommonHelper.replaceHashQueryParams(Object.assign(queryParams, extra));
}
</script>
@@ -81,8 +82,8 @@
<div class="inline-flex">
<Field class="form-field form-field-toggle m-0" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={withAdminLogs} />
<label for={uniqueId}>Include requests by admins</label>
<input type="checkbox" id={uniqueId} bind:checked={withSuperuserLogs} />
<label for={uniqueId}>Include requests by superusers</label>
</Field>
</div>
</header>
@@ -96,12 +97,12 @@
<LogsLevelsInfo class="block txt-sm txt-hint m-t-xs m-b-base" />
{#key refreshKey}
<LogsChart {filter} {presets} />
<LogsChart bind:zoom {filter} {presets} />
{/key}
</div>
{#key refreshKey}
<LogsList bind:filter {presets} on:select={(e) => logViewPanel?.show(e?.detail)} />
<LogsList bind:filter bind:zoom {presets} on:select={(e) => logViewPanel?.show(e?.detail)} />
{/key}
</PageWrapper>
@@ -0,0 +1,44 @@
<script>
import tooltip from "@/actions/tooltip";
import { collections } from "@/stores/collections";
import CommonHelper from "@/utils/CommonHelper";
const detailedDateFormat = "yyyy-MM-dd HH:mm:ss.SSS";
export let record;
let tooltipDates = [];
$: collection = record && $collections.find((c) => c.id == record.collectionId);
$: if (record) {
refreshTooltipDates();
}
function refreshTooltipDates() {
tooltipDates = [];
const fields = collection.fields || [];
for (let field of fields) {
if (field.type != "autodate") {
continue;
}
tooltipDates.push(
field.name +
": " +
CommonHelper.formatToLocalDate(record[field.name], detailedDateFormat) +
" Local",
);
}
}
</script>
<i
class="ri-calendar-event-line txt-disabled"
use:tooltip={{
text: tooltipDates.join("\n"),
position: "left",
}}
/>
@@ -1,10 +1,10 @@
<script>
import { createEventDispatcher } from "svelte";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import providersList from "@/providers.js";
import { confirm } from "@/stores/confirmation";
import { addSuccessToast } from "@/stores/toasts";
import providersList from "@/providers.js";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
@@ -31,7 +31,12 @@
isLoading = true;
try {
externalAuths = await ApiClient.collection(record.collectionId).listExternalAuths(record.id);
externalAuths = await ApiClient.collection("_externalAuths").getFullList({
filter: ApiClient.filter("collectionRef = {:collectionId} && recordRef = {:recordId}", {
collectionId: record.collectionId,
recordId: record.id,
}),
});
} catch (err) {
ApiClient.error(err);
}
@@ -39,23 +44,28 @@
isLoading = false;
}
function unlinkExternalAuth(provider) {
if (!record?.id || !provider) {
function unlinkExternalAuth(externalAuth) {
if (!record?.id || !externalAuth) {
return; // nothing to unlink
}
confirm(`Do you really want to unlink the ${getProviderTitle(provider)} provider?`, () => {
return ApiClient.collection(record.collectionId)
.unlinkExternalAuth(record.id, provider)
.then(() => {
addSuccessToast(`Successfully unlinked the ${getProviderTitle(provider)} provider.`);
dispatch("unlink", provider);
loadExternalAuths(); // reload list
})
.catch((err) => {
ApiClient.error(err);
});
});
confirm(
`Do you really want to unlink the ${getProviderTitle(externalAuth.provider)} provider?`,
() => {
return ApiClient.collection("_externalAuths")
.delete(externalAuth.id)
.then(() => {
addSuccessToast(
`Successfully unlinked the ${getProviderTitle(externalAuth.provider)} provider.`,
);
dispatch("unlink", externalAuth.provider);
loadExternalAuths(); // reload list
})
.catch((err) => {
ApiClient.error(err);
});
},
);
}
loadExternalAuths();
@@ -80,7 +90,7 @@
<button
type="button"
class="btn btn-transparent link-hint btn-circle btn-sm m-l-auto"
on:click={() => unlinkExternalAuth(auth.provider)}
on:click={() => unlinkExternalAuth(auth)}
>
<i class="ri-close-line" />
</button>
@@ -0,0 +1,157 @@
<script>
import { createEventDispatcher } from "svelte";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import { addSuccessToast } from "@/stores/toasts";
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
import CopyIcon from "@/components/base/CopyIcon.svelte";
import SdkTabs from "@/components/base/SdkTabs.svelte";
import Field from "@/components/base/Field.svelte";
const dispatch = createEventDispatcher();
const formId = "impersonate_" + CommonHelper.randomString(5);
export let collection;
export let record;
let panel;
let duration = 0;
let isSubmitting = false;
let impersonateClient;
$: backendAbsUrl = CommonHelper.getApiExampleUrl(impersonateClient?.baseURL);
export function show() {
if (!record) {
return;
}
panel?.show();
}
export function hide() {
panel?.hide();
reset();
}
async function submit() {
if (isSubmitting || !collection || !record) {
return;
}
isSubmitting = true;
try {
impersonateClient = await ApiClient.collection(collection.name).impersonate(record.id, duration);
dispatch("submit", impersonateClient);
} catch (err) {
ApiClient.error(err);
}
isSubmitting = false;
}
function reset() {
duration = 0;
impersonateClient = undefined;
}
</script>
<OverlayPanel
bind:this={panel}
overlayClose={false}
escClose={!isSubmitting}
beforeHide={() => !isSubmitting}
popup
on:show
on:hide
>
<svelte:fragment slot="header">
<h4>Impersonate auth token</h4>
</svelte:fragment>
<div class="clearfix"></div>
{#if impersonateClient?.authStore?.token}
<div class="alert alert-success">
<div class="content txt-bold">
<span class="txt token-holder">{impersonateClient.authStore.token}</span>
<CopyIcon value={impersonateClient.authStore.token} />
</div>
</div>
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const token = "...";
const pb = new PocketBase('${backendAbsUrl}');
pb.authStore.save(token, null);
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final token = "...";
final pb = PocketBase('${backendAbsUrl}');
pb.authStore.save(token, null);
`}
/>
{:else}
<form id={formId} on:submit|preventDefault={submit}>
<p>
Generate a non-refreshable auth token for
<strong>{CommonHelper.displayValue(record)}:</strong>
</p>
<Field class="form-field m-b-0 m-t-sm" name="duration" let:uniqueId>
<label for={uniqueId}>Token duration (in seconds)</label>
<input
type="number"
id={uniqueId}
placeholder="Default to the collection setting ({collection?.authToken?.duration || 0}s)"
min="0"
step="1"
value={duration || ""}
on:input={(e) => (duration = e.target.value << 0)}
/>
</Field>
</form>
{/if}
<svelte:fragment slot="footer">
<button type="button" class="btn btn-transparent" on:click={hide} disabled={isSubmitting}>
<span class="txt">Close</span>
</button>
{#if impersonateClient?.authStore?.token}
<button
type="button"
class="btn btn-secondary btn-expanded"
disabled={isSubmitting}
on:click={() => reset()}
>
<span class="txt">Generate a new one</span>
</button>
{:else}
<button
type="submit"
form={formId}
class="btn btn-expanded"
class:btn-loading={isSubmitting}
disabled={isSubmitting}
on:click={() => submit()}
>
<span class="txt">Generate token</span>
</button>
{/if}
</svelte:fragment>
</OverlayPanel>
<style>
.token-holder {
user-select: all;
}
</style>
@@ -1,5 +1,8 @@
<script>
import { onMount } from "svelte";
import { pageTitle } from "@/stores/app";
$pageTitle = "OAuth2 auth failed";
onMount(() => {
window.close();
@@ -1,5 +1,8 @@
<script>
import { onMount } from "svelte";
import { pageTitle } from "@/stores/app";
$pageTitle = "OAuth2 auth completed";
onMount(() => {
window.close();
@@ -20,7 +20,7 @@
isLoading = true;
// init a custom client to avoid interfering with the admin state
// init a custom client to avoid interfering with the superuser state
const client = new PocketBase(import.meta.env.PB_BACKEND_URL);
try {
@@ -21,7 +21,7 @@
isLoading = true;
// init a custom client to avoid interfering with the admin state
// init a custom client to avoid interfering with the superuser state
const client = new PocketBase(import.meta.env.PB_BACKEND_URL);
try {
@@ -1,40 +1,74 @@
<script>
import PocketBase, { getTokenPayload } from "pocketbase";
import FullPage from "@/components/base/FullPage.svelte";
import ApiClient from "@/utils/ApiClient";
import PocketBase, { getTokenPayload, isTokenExpired } from "pocketbase";
export let params;
let success = false;
let isLoading = false;
let successConfirm = false;
let isConfirming = false;
let successResend = false;
let isResending = false;
send();
async function send() {
isLoading = true;
if (isConfirming) {
return;
}
// init a custom client to avoid interfering with the admin state
isConfirming = true;
// init a custom client to avoid interfering with the superuser state
const client = new PocketBase(import.meta.env.PB_BACKEND_URL);
try {
const payload = getTokenPayload(params?.token);
await client.collection(payload.collectionId).confirmVerification(params?.token);
success = true;
successConfirm = true;
} catch (err) {
success = false;
successConfirm = false;
}
isLoading = false;
isConfirming = false;
}
$: canResend = params?.token && isTokenExpired(params.token);
async function resend() {
const payload = getTokenPayload(params?.token);
if (isResending || !payload.collectionId || !payload.email) {
return;
}
isResending = true;
// init a custom client to avoid interfering with the superuser state
const client = new PocketBase(import.meta.env.PB_BACKEND_URL);
try {
const payload = getTokenPayload(params?.token);
await client.collection(payload.collectionId).requestVerification(payload.email);
successResend = true;
} catch (err) {
ApiClient.error(err);
successResend = false;
}
isResending = false;
}
</script>
<FullPage nobranding>
{#if isLoading}
{#if isConfirming}
<div class="txt-center">
<div class="loader loader-lg">
<em>Please wait...</em>
</div>
</div>
{:else if success}
{:else if successConfirm}
<div class="alert alert-success">
<div class="icon"><i class="ri-checkbox-circle-line" /></div>
<div class="content txt-bold">
@@ -42,6 +76,17 @@
</div>
</div>
<button type="button" class="btn btn-transparent btn-block" on:click={() => window.close()}>
Close
</button>
{:else if successResend}
<div class="alert alert-success">
<div class="icon"><i class="ri-checkbox-circle-line" /></div>
<div class="content txt-bold">
<p>Please check your email for the new verification link.</p>
</div>
</div>
<button type="button" class="btn btn-transparent btn-block" on:click={() => window.close()}>
Close
</button>
@@ -53,8 +98,20 @@
</div>
</div>
<button type="button" class="btn btn-transparent btn-block" on:click={() => window.close()}>
Close
</button>
{#if canResend}
<button
type="button"
class="btn btn-transparent btn-block"
class:btn-loading={isResending}
disabled={isResending}
on:click={resend}
>
<span class="txt">Resend</span>
</button>
{:else}
<button type="button" class="btn btn-transparent btn-block" on:click={() => window.close()}>
Close
</button>
{/if}
{/if}
</FullPage>
+30 -21
View File
@@ -1,26 +1,26 @@
<script>
import { tick } from "svelte";
import { querystring } from "svelte-spa-router";
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import PageWrapper from "@/components/base/PageWrapper.svelte";
import RefreshButton from "@/components/base/RefreshButton.svelte";
import Searchbar from "@/components/base/Searchbar.svelte";
import CollectionDocsPanel from "@/components/collections/CollectionDocsPanel.svelte";
import CollectionUpsertPanel from "@/components/collections/CollectionUpsertPanel.svelte";
import CollectionsSidebar from "@/components/collections/CollectionsSidebar.svelte";
import RecordPreviewPanel from "@/components/records/RecordPreviewPanel.svelte";
import RecordUpsertPanel from "@/components/records/RecordUpsertPanel.svelte";
import RecordsCount from "@/components/records/RecordsCount.svelte";
import RecordsList from "@/components/records/RecordsList.svelte";
import { hideControls, pageTitle } from "@/stores/app";
import {
collections,
activeCollection,
changeActiveCollectionById,
collections,
isCollectionsLoading,
loadCollections,
changeActiveCollectionById,
} from "@/stores/collections";
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 CollectionsSidebar from "@/components/collections/CollectionsSidebar.svelte";
import CollectionUpsertPanel from "@/components/collections/CollectionUpsertPanel.svelte";
import CollectionDocsPanel from "@/components/collections/CollectionDocsPanel.svelte";
import RecordUpsertPanel from "@/components/records/RecordUpsertPanel.svelte";
import RecordPreviewPanel from "@/components/records/RecordPreviewPanel.svelte";
import RecordsList from "@/components/records/RecordsList.svelte";
import RecordsCount from "@/components/records/RecordsCount.svelte";
import CommonHelper from "@/utils/CommonHelper";
import { tick } from "svelte";
import { querystring } from "svelte-spa-router";
const initialQueryParams = new URLSearchParams($querystring);
@@ -31,7 +31,7 @@
let recordsList;
let recordsCount;
let filter = initialQueryParams.get("filter") || "";
let sort = initialQueryParams.get("sort") || "-created";
let sort = initialQueryParams.get("sort") || "-@rowid";
let selectedCollectionId = initialQueryParams.get("collectionId") || $activeCollection?.id;
let totalCount = 0; // used to manully change the count without the need of reloading the recordsCount component
@@ -78,7 +78,7 @@
function reset() {
selectedCollectionId = $activeCollection?.id;
filter = "";
sort = "-created";
sort = "-@rowid";
updateQueryParams({ recordId: null });
@@ -106,7 +106,10 @@
// invalid sort expression or missing sort field
if (sortFields.filter((f) => collectionFields.includes(f)).length != sortFields.length) {
if (collectionFields.includes("created")) {
if ($activeCollection?.type != "view") {
sort = "-@rowid"; // all collections with exception to the view has this field
} else if (collectionFields.includes("created")) {
// common autodate field
sort = "-created";
} else {
sort = "";
@@ -248,7 +251,13 @@
</PageWrapper>
{/if}
<CollectionUpsertPanel bind:this={collectionUpsertPanel} />
<CollectionUpsertPanel
bind:this={collectionUpsertPanel}
on:truncate={() => {
recordsList?.load();
recordsCount?.reload();
}}
/>
<CollectionDocsPanel bind:this={collectionDocsPanel} />
@@ -1,11 +1,12 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import FormattedDate from "@/components/base/FormattedDate.svelte";
import CopyIcon from "@/components/base/CopyIcon.svelte";
import FormattedDate from "@/components/base/FormattedDate.svelte";
import TinyMCE from "@/components/base/TinyMCE.svelte";
import RecordFileThumb from "@/components/records/RecordFileThumb.svelte";
import RecordInfo from "@/components/records/RecordInfo.svelte";
import TinyMCE from "@/components/base/TinyMCE.svelte";
import { superuser } from "@/stores/superuser";
import CommonHelper from "@/utils/CommonHelper";
export let record;
export let field;
@@ -14,7 +15,15 @@
$: rawValue = record?.[field.name];
</script>
{#if field.type === "json"}
{#if field.primaryKey}
<div class="label">
<CopyIcon value={rawValue} />
<div class="txt txt-ellipsis">{rawValue}</div>
</div>
{#if record.collectionName == "_superusers" && record.id == $superuser.id}
<span class="label label-warning">You</span>
{/if}
{:else if field.type === "json"}
{@const stringifiedJson = CommonHelper.trimQuotedValue(JSON.stringify(rawValue)) || '""'}
{#if short}
<span class="txt txt-ellipsis">
@@ -31,7 +40,7 @@
{:else if CommonHelper.isEmpty(rawValue)}
<span class="txt-hint">N/A</span>
{:else if field.type === "bool"}
<span class="txt">{rawValue ? "True" : "False"}</span>
<span class="label" class:label-success={!!rawValue}>{rawValue ? "True" : "False"}</span>
{:else if field.type === "number"}
<span class="txt">{rawValue}</span>
{:else if field.type === "url"}
@@ -72,7 +81,7 @@
disabled
/>
{/if}
{:else if field.type === "date"}
{:else if field.type === "date" || field.type === "autodate"}
<FormattedDate date={rawValue} />
{:else if field.type === "select"}
<div class="inline-flex">
@@ -103,7 +112,7 @@
{:else if field.type === "file"}
{@const files = CommonHelper.toArray(rawValue)}
{@const filesLimit = short ? 10 : 500}
<div class="inline-flex" class:multiple={field.options?.maxSelect != 1}>
<div class="inline-flex" class:multiple={field.maxSelect != 1}>
{#each files.slice(0, filesLimit) as filename, i (i + filename)}
<RecordFileThumb {record} {filename} size="sm" />
{/each}
@@ -1,15 +1,15 @@
<script>
import { createEventDispatcher } from "svelte";
import CommonHelper from "@/utils/CommonHelper";
import ApiClient from "@/utils/ApiClient";
import tooltip from "@/actions/tooltip";
import { collections } from "@/stores/collections";
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
import Searchbar from "@/components/base/Searchbar.svelte";
import Field from "@/components/base/Field.svelte";
import ObjectSelect from "@/components/base/ObjectSelect.svelte";
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
import Scroller from "@/components/base/Scroller.svelte";
import Searchbar from "@/components/base/Searchbar.svelte";
import RecordUpsertPanel from "@/components/records/RecordUpsertPanel.svelte";
import { collections } from "@/stores/collections";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
const uniqueId = "file_picker_" + CommonHelper.randomString(5);
@@ -37,15 +37,14 @@
$: fileCollections = $collections.filter((c) => {
return (
c.type !== "view" &&
!!CommonHelper.toArray(c.schema).find((f) => {
!!CommonHelper.toArray(c.fields).find((f) => {
return (
// is file field
f.type === "file" &&
// is public (aka. doesn't require file token)
!f.options?.protected &&
!f.protected &&
// allow any MIME type OR image/*
(!f.options?.mimeTypes?.length ||
!!f.options?.mimeTypes?.find((t) => t.startsWith("image/")))
(!f.mimeTypes?.length || !!f.mimeTypes?.find((t) => t.startsWith("image/")))
);
})
);
@@ -56,7 +55,7 @@
selectedCollection = fileCollections[0];
}
$: fileFields = selectedCollection?.schema?.filter((f) => f.type === "file" && !f.options?.protected);
$: fileFields = selectedCollection?.fields?.filter((f) => f.type === "file" && !f.protected);
// reset filter on collection change
$: if (selectedCollection?.id) {
@@ -149,13 +148,13 @@
}
function refreshSizeOptions() {
let sizes = ["100x100"]; // default Admin UI thumb
let sizes = ["100x100"]; // default Superuser UI thumb
// extract the thumb sizes of the selected file field
if (selectedFile?.record?.id) {
for (const field of fileFields) {
if (CommonHelper.toArray(selectedFile.record[field.name]).includes(selectedFile.name)) {
sizes = sizes.concat(CommonHelper.toArray(field.options?.thumbs));
sizes = sizes.concat(CommonHelper.toArray(field.thumbs));
break;
}
}
@@ -206,8 +205,8 @@
{
size: selectedSize,
},
selectedFile
)
selectedFile,
),
);
hide();
@@ -279,7 +278,7 @@
{#if CommonHelper.hasImageExtension(name)}
<img
loading="lazy"
src={ApiClient.files.getUrl(record, name, { thumb: "100x100" })}
src={ApiClient.files.getURL(record, name, { thumb: "100x100" })}
alt={name}
/>
{:else}
@@ -1,7 +1,7 @@
<script>
import PreviewPopup from "@/components/base/PreviewPopup.svelte";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import PreviewPopup from "@/components/base/PreviewPopup.svelte";
export let record = null;
export let filename = "";
@@ -19,17 +19,17 @@
$: hasPreview = ["image", "audio", "video"].includes(type) || filename.endsWith(".pdf");
$: originalUrl = !isLoadingToken ? ApiClient.files.getUrl(record, filename, { token }) : "";
$: originalUrl = !isLoadingToken ? ApiClient.files.getURL(record, filename, { token }) : "";
$: thumbUrl = !isLoadingToken
? ApiClient.files.getUrl(record, filename, { thumb: "100x100", token: token })
? ApiClient.files.getURL(record, filename, { thumb: "100x100", token: token })
: "";
async function loadFileToken() {
isLoadingToken = true;
try {
token = await ApiClient.getAdminFileToken(record.collectionId);
token = await ApiClient.getSuperuserFileToken(record.collectionId);
} catch (err) {
console.warn("File token failure:", err);
}
+28 -56
View File
@@ -1,70 +1,43 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import { collections } from "@/stores/collections";
import RecordFileThumb from "@/components/records/RecordFileThumb.svelte";
import RecordInfoContent from "@/components/records/RecordInfoContent.svelte";
import CommonHelper from "@/utils/CommonHelper";
export let record;
let fileDisplayFields = [];
let nonFileDisplayFields = [];
$: collection = $collections?.find((item) => item.id == record?.collectionId);
$: if (collection) {
loadDisplayFields();
}
function loadDisplayFields() {
const fields = collection?.schema || [];
// reset
fileDisplayFields = fields.filter((f) => f.presentable && f.type == "file").map((f) => f.name);
nonFileDisplayFields = fields.filter((f) => f.presentable && f.type != "file").map((f) => f.name);
// fallback to the first single file field that accept images
// if no presentable field is available
if (!fileDisplayFields.length && !nonFileDisplayFields.length) {
const fallbackFileField = fields.find((f) => {
return (
f.type == "file" &&
f.options?.maxSelect == 1 &&
f.options?.mimeTypes?.find((t) => t.startsWith("image/"))
);
});
if (fallbackFileField) {
fileDisplayFields.push(fallbackFileField.name);
}
function excludeProps(item, ...props) {
const result = Object.assign({}, item);
for (let prop of props) {
delete result[prop];
}
return result;
}
</script>
<div class="record-info">
<i
class="link-hint txt-sm ri-information-line"
<RecordInfoContent {record} />
<a
href="#/collections?collectionId={record.collectionId}&recordId={record.id}"
target="_blank"
class="inline-flex link-hint"
rel="noopener noreferrer"
use:tooltip={{
text: CommonHelper.truncate(
JSON.stringify(CommonHelper.truncateObject(record), null, 2),
800,
true,
),
text:
"Open relation record in new tab:\n" +
CommonHelper.truncate(
JSON.stringify(CommonHelper.truncateObject(excludeProps(record, "expand")), null, 2),
800,
true,
),
class: "code",
position: "left",
}}
/>
{#each fileDisplayFields as name}
{@const filenames = CommonHelper.toArray(record[name]).slice(0, 5)}
{#each filenames as filename}
{#if !CommonHelper.isEmpty(filename)}
<RecordFileThumb {record} {filename} size="xs" />
{/if}
{/each}
{/each}
<span class="txt txt-ellipsis">
{CommonHelper.truncate(CommonHelper.displayValue(record, nonFileDisplayFields), 70)}
</span>
on:click|stopPropagation
on:keydown|stopPropagation
>
<i class="ri-external-link-line txt-sm"></i>
</a>
</div>
<style lang="scss">
@@ -72,11 +45,10 @@
display: inline-flex;
vertical-align: top;
align-items: center;
justify-content: center;
max-width: 100%;
min-width: 0;
gap: 5px;
:global(.thumb) {
box-shadow: none;
}
padding-left: 1px; // for visual alignment with the new tab icon
}
</style>
@@ -0,0 +1,61 @@
<script>
import RecordFileThumb from "@/components/records/RecordFileThumb.svelte";
import RecordInfoContent from "@/components/records/RecordInfoContent.svelte";
import { collections } from "@/stores/collections";
import CommonHelper from "@/utils/CommonHelper";
export let record;
let fileDisplayFields = [];
let nonFileDisplayFields = [];
$: collection = $collections?.find((item) => item.id == record?.collectionId);
$: if (collection) {
loadDisplayFields();
}
function loadDisplayFields() {
const fields = collection?.fields || [];
fileDisplayFields = fields.filter((f) => !f.hidden && f.presentable && f.type == "file");
nonFileDisplayFields = fields.filter((f) => !f.hidden && f.presentable && f.type != "file");
// fallback to the first single file field that accept images
// if no presentable field is available
if (!fileDisplayFields.length && !nonFileDisplayFields.length) {
const fallbackFileField = fields.find((f) => {
return (
!f.hidden &&
f.type == "file" &&
f.maxSelect == 1 &&
f.mimeTypes?.find((t) => t.startsWith("image/"))
);
});
if (fallbackFileField) {
fileDisplayFields.push(fallbackFileField);
}
}
}
</script>
{#each fileDisplayFields as field}
{@const filenames = CommonHelper.toArray(record[field.name]).slice(0, 5)}
{#each filenames as filename}
{#if !CommonHelper.isEmpty(filename)}
<RecordFileThumb {record} {filename} size="xs" />
{/if}
{/each}
{/each}
{#each nonFileDisplayFields as field, i}
{#if i > 0},{/if}
{#if field.type == "relation" && record.expand?.[field.name]}
<RecordInfoContent bind:record={record.expand[field.name]} />
{:else}
{CommonHelper.truncate(CommonHelper.displayValue(record, [field.name]), 70)}
{/if}
{:else}
{CommonHelper.truncate(CommonHelper.displayValue(record, []), 70)}
{/each}
@@ -12,7 +12,7 @@
let record = {};
let isLoading = false;
$: hasEditorField = !!collection?.schema?.find((f) => f.type === "editor");
$: hasEditorField = !!collection?.fields?.find((f) => f.type === "editor");
export function show(model) {
load(model);
@@ -77,7 +77,7 @@
</td>
</tr>
{#each collection?.schema as field}
{#each collection?.fields as field}
<tr>
<td class="min-width txt-hint txt-bold">{field.name}</td>
<td class="col-field">

Some files were not shown because too many files have changed in this diff Show More