merge v0.23.0-rc changes
This commit is contained in:
@@ -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>
|
||||
@@ -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()} />
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
/>
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />
|
||||
|
||||
+33
-82
@@ -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>
|
||||
{/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>
|
||||
{/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>
|
||||
@@ -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>
|
||||
|
||||
+3
-2
@@ -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>
|
||||
|
||||
|
||||
-42
@@ -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
-37
@@ -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>
|
||||
-56
@@ -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">
|
||||
-32
@@ -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>
|
||||
|
||||
-38
@@ -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">
|
||||
-32
@@ -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
-1
@@ -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>
|
||||
+9
-11
@@ -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>
|
||||
+7
-7
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user