Merge branch 'mgn-dv' of https://github.com/appwrite/console into mgn-dv

This commit is contained in:
Torsten Dittmann
2025-05-12 12:23:50 +02:00
48 changed files with 1859 additions and 781 deletions
+1 -1
View File
@@ -27,7 +27,7 @@
"@appwrite.io/pink-icons": "0.25.0",
"@appwrite.io/pink-icons-svelte": "https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@bd21ff7f",
"@appwrite.io/pink-legacy": "^1.0.3",
"@appwrite.io/pink-svelte": "https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@de27ca4",
"@appwrite.io/pink-svelte": "https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@e5a77b9",
"@popperjs/core": "^2.11.8",
"@sentry/sveltekit": "^8.38.0",
"@stripe/stripe-js": "^3.5.0",
+9 -9
View File
@@ -27,8 +27,8 @@ importers:
specifier: ^1.0.3
version: 1.0.3
'@appwrite.io/pink-svelte':
specifier: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@de27ca4
version: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@de27ca4(react-dom@18.3.1(react@18.3.1))(svelte@5.25.3)
specifier: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@e5a77b9
version: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@e5a77b9(react-dom@18.3.1(react@18.3.1))(svelte@5.25.3)
'@popperjs/core':
specifier: ^2.11.8
version: 2.11.8
@@ -282,8 +282,8 @@ packages:
peerDependencies:
svelte: ^4.0.0
'@appwrite.io/pink-icons-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@de27ca407599e51b378814b421fb63625cb98c10':
resolution: {tarball: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@de27ca407599e51b378814b421fb63625cb98c10}
'@appwrite.io/pink-icons-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@e5a77b9b25e2cb5e55587e93725d7a06df0b3d94':
resolution: {tarball: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@e5a77b9b25e2cb5e55587e93725d7a06df0b3d94}
version: 1.0.0-next.7
peerDependencies:
svelte: ^4.0.0
@@ -297,8 +297,8 @@ packages:
'@appwrite.io/pink-legacy@1.0.3':
resolution: {integrity: sha512-GGde5fmPhs+s6/3aFeMPc/kKADG/gTFkYQSy6oBN8pK0y0XNCLrZZgBv+EBbdhwdtqVEWXa0X85Mv9w7jcIlwQ==}
'@appwrite.io/pink-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@de27ca4':
resolution: {tarball: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@de27ca4}
'@appwrite.io/pink-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@e5a77b9':
resolution: {tarball: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@e5a77b9}
version: 1.0.0-next.85
peerDependencies:
react-dom: ^18.0.0
@@ -3836,7 +3836,7 @@ snapshots:
dependencies:
svelte: 5.25.3
'@appwrite.io/pink-icons-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@de27ca407599e51b378814b421fb63625cb98c10(svelte@5.25.3)':
'@appwrite.io/pink-icons-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@e5a77b9b25e2cb5e55587e93725d7a06df0b3d94(svelte@5.25.3)':
dependencies:
svelte: 5.25.3
@@ -3849,9 +3849,9 @@ snapshots:
'@appwrite.io/pink-icons': 1.0.0
the-new-css-reset: 1.11.3
'@appwrite.io/pink-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@de27ca4(react-dom@18.3.1(react@18.3.1))(svelte@5.25.3)':
'@appwrite.io/pink-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@e5a77b9(react-dom@18.3.1(react@18.3.1))(svelte@5.25.3)':
dependencies:
'@appwrite.io/pink-icons-svelte': https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@de27ca407599e51b378814b421fb63625cb98c10(svelte@5.25.3)
'@appwrite.io/pink-icons-svelte': https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@e5a77b9b25e2cb5e55587e93725d7a06df0b3d94(svelte@5.25.3)
'@floating-ui/dom': 1.6.13
'@melt-ui/pp': 0.3.2(@melt-ui/svelte@0.86.6(svelte@5.25.3))(svelte@5.25.3)
'@melt-ui/svelte': 0.86.6(svelte@5.25.3)
+9
View File
@@ -152,6 +152,7 @@ export enum Click {
DatabaseIndexDelete = 'click_index_delete',
DatabaseCollectionDelete = 'click_collection_delete',
DatabaseDatabaseDelete = 'click_database_delete',
DatabaseImportCsv = 'click_database_import_csv',
DomainCreateClick = 'click_domain_create',
DomainDeleteClick = 'click_domain_delete',
DomainRetryDomainVerificationClick = 'click_domain_retry_domain_verification',
@@ -161,6 +162,7 @@ export enum Click {
FunctionsDeploymentDeleteClick = 'click_deployment_delete',
FunctionsDeploymentCancelClick = 'click_deployment_cancel',
KeyCreateClick = 'click_key_create',
DevKeyCreateClick = 'click_dev_key_create',
MenuDropDownClick = 'click_menu_dropdown',
MenuOverviewClick = 'click_menu_overview',
ModalCloseClick = 'click_close_modal',
@@ -270,6 +272,7 @@ export enum Submit {
DatabaseCreate = 'submit_database_create',
DatabaseDelete = 'submit_database_delete',
DatabaseUpdateName = 'submit_database_update_name',
DatabaseImportCsv = 'submit_database_import_csv',
AttributeCreate = 'submit_attribute_create',
AttributeUpdate = 'submit_attribute_update',
AttributeDelete = 'submit_attribute_delete',
@@ -311,12 +314,18 @@ export enum Submit {
VariableDelete = 'submit_variable_delete',
VariableUpdate = 'submit_variable_update',
VariableEditor = 'submit_variable_editor',
KeyCreate = 'submit_key_create',
KeyDelete = 'submit_key_delete',
KeyUpdateName = 'submit_key_update_name',
KeyUpdateScopes = 'submit_key_update_scopes',
KeyUpdateExpire = 'submit_key_update_expire',
DevKeyCreate = 'submit_dev_key_create',
DevKeyDelete = 'submit_dev_key_delete',
DevKeyUpdateName = 'submit_dev_key_update_name',
DevKeyUpdateExpire = 'submit_dev_key_update_expire',
PlatformCreate = 'submit_platform_create',
PlatformDelete = 'submit_platform_delete',
PlatformUpdate = 'submit_platform_update',
+241
View File
@@ -0,0 +1,241 @@
<script lang="ts">
import { onMount } from 'svelte';
import { base } from '$app/paths';
import { page } from '$app/state';
import { sdk } from '$lib/stores/sdk';
import { Dependencies } from '$lib/constants';
import { goto, invalidate } from '$app/navigation';
import { getProjectId } from '$lib/helpers/project';
import { writable, type Writable } from 'svelte/store';
import { addNotification } from '$lib/stores/notifications';
import { Layout, Typography } from '@appwrite.io/pink-svelte';
import { type Models, type Payload, Query } from '@appwrite.io/console';
type ImportItem = {
status: string;
collection?: string;
};
type ImportItemsMap = Map<string, ImportItem>;
/**
* Keeps a track of the active and ongoing csv migrations.
*
* The structure is as follows -
* `{ migrationId: { status: status, collection: collection } }`
*/
const importItems: Writable<ImportItemsMap> = writable(new Map());
async function showCompletionNotification(
databaseId: string,
collectionId: string,
importData: Payload
) {
const projectId = page.params.project;
await invalidate(Dependencies.DOCUMENTS);
const url = `${base}/project-${projectId}/databases/database-${databaseId}/collection-${collectionId}`;
// extract clean message from nested backend error.
const match = importData.errors.join('').match(/message: '(.*)' Message:/i);
const errorMessage = match?.[1];
const type = importData.status === 'completed' ? 'success' : 'error';
const message =
importData.status === 'completed'
? 'CSV import finished successfully.'
: `${errorMessage}`;
addNotification({
type,
message,
isHtml: true,
buttons:
collectionId === page.params.collection || type === 'error'
? undefined
: [
{
name: 'View documents',
method: () => goto(url)
}
]
});
}
async function updateOrAddItem(importData: Payload | Models.Migration) {
if (importData.source.toLowerCase() !== 'csv') return;
const status = importData.status;
const resourceId = importData.resourceId ?? '';
const [databaseId, collectionId] = resourceId.split(':') ?? [];
const current = $importItems.get(importData.$id);
let collectionName = current?.collection ?? null;
if (!collectionName && collectionId) {
try {
const collection = await sdk.forProject.databases.getCollection(
databaseId,
collectionId
);
collectionName = collection.name;
} catch {
collectionName = null;
}
}
importItems.update((items) => {
const existing = items.get(importData.$id);
const isDone = (s: string) => s === 'completed' || s === 'failed';
const isInProgress = (s: string) => ['pending', 'processing', 'uploading'].includes(s);
const shouldSkip =
(existing && isDone(existing.status) && isInProgress(status)) ||
existing?.status === status;
if (shouldSkip) return items;
const next = new Map(items);
next.set(importData.$id, { status, collection: collectionName ?? undefined });
return next;
});
if (status === 'completed' || status === 'failed') {
await showCompletionNotification(databaseId, collectionId, importData);
}
}
function clear() {
importItems.update((items) => {
items.clear();
return items;
});
}
function graphSize(status: string): number {
switch (status) {
case 'pending':
return 10;
case 'processing':
return 30;
case 'uploading':
return 60;
case 'completed':
case 'failed':
return 100;
default:
return 30;
}
}
function text(status: string, collectionName = '') {
const name = collectionName ? `<b>${collectionName}</b>` : '';
switch (status) {
case 'completed':
case 'failed':
return `Import to ${name} ${status}`;
case 'processing':
return `Importing CSV file${name ? ` to ${name}` : ''}`;
default:
return 'Preparing CSV for import...';
}
}
onMount(() => {
sdk.forProject.migrations
.list([Query.equal('source', 'CSV'), Query.equal('status', ['pending', 'processing'])])
.then((migrations) => {
migrations.migrations.forEach(updateOrAddItem);
});
return sdk.forConsole.client.subscribe('console', (response) => {
if (!response.channels.includes(`projects.${getProjectId()}`)) return;
if (response.events.includes('migrations.*')) {
updateOrAddItem(response.payload as Payload);
}
});
});
$: isOpen = true;
$: showCsvImportBox = $importItems.size > 0;
</script>
{#if showCsvImportBox}
<Layout.Stack direction="column" gap="l" alignItems="flex-end">
<section class="upload-box">
<header class="upload-box-header">
<h4 class="upload-box-title">
<Typography.Text variant="m-500">
Importing documents ({$importItems.size})
</Typography.Text>
</h4>
<button
class="upload-box-button"
class:is-open={isOpen}
aria-label="toggle upload box"
on:click={() => (isOpen = !isOpen)}>
<span class="icon-cheveron-up" aria-hidden="true"></span>
</button>
<button
class="upload-box-button"
aria-label="close backup restore box"
on:click={clear}>
<span class="icon-x" aria-hidden="true"></span>
</button>
</header>
{#each [...$importItems.entries()] as [key, value] (key)}
<div class="upload-box-content" class:is-open={isOpen}>
<ul class="upload-box-list">
<li class="upload-box-item">
<section class="progress-bar u-width-full-line">
<div
class="progress-bar-top-line u-flex u-gap-8 u-main-space-between">
<Typography.Text>
{@html text(value.status, value.collection)}
</Typography.Text>
</div>
<div
class="progress-bar-container"
class:is-danger={value.status === 'failed'}
style="--graph-size:{graphSize(value.status)}%">
</div>
</section>
</li>
</ul>
</div>
{/each}
</section>
</Layout.Stack>
{/if}
<style lang="scss">
.upload-box-title {
font-size: 11px;
}
.upload-box-content {
min-width: 400px;
max-width: 100vw;
}
.upload-box-button {
display: flex;
align-items: center;
justify-content: center;
}
.progress-bar-container {
height: 4px;
&::before {
height: 4px;
background-color: var(--bgcolor-neutral-invert);
}
&.is-danger::before {
height: 4px;
background-color: var(--bgcolor-error);
}
}
</style>
+473 -225
View File
@@ -1,36 +1,45 @@
<script lang="ts">
import { Id } from '.';
import { Button } from '$lib/elements/forms';
import { sdk } from '$lib/stores/sdk';
import { ID, Query, Permission, Role } from '@appwrite.io/console';
import type { Models } from '@appwrite.io/console';
import { calculateSize } from '$lib/helpers/sizeConvertion';
import InputSearch from '$lib/elements/forms/inputSearch.svelte';
import { writable } from 'svelte/store';
import { EmptySearch, Id } from '.';
import { onMount } from 'svelte';
import { base } from '$app/paths';
import { page } from '$app/state';
import { sdk } from '$lib/stores/sdk';
import { goto } from '$app/navigation';
import { writable } from 'svelte/store';
import { Button, InputSelect } from '$lib/elements/forms';
import DualTimeView from './dualTimeView.svelte';
import type { Models } from '@appwrite.io/console';
import { calculateSize } from '$lib/helpers/sizeConvertion';
import InputSearch from '$lib/elements/forms/inputSearch.svelte';
import { ID, Query, Permission, Role } from '@appwrite.io/console';
import {
Layout,
Typography,
Modal,
ActionMenu,
Table,
Spinner,
ToggleButton,
Selector,
Card,
Divider,
Empty,
Card
Layout,
Modal,
Selector,
Spinner,
Table,
ToggleButton,
Tooltip,
Typography
} from '@appwrite.io/pink-svelte';
import Form from '$lib/elements/forms/form.svelte';
import { IconCheck, IconViewGrid, IconViewList } from '@appwrite.io/pink-icons-svelte';
import { isSmallViewport } from '$lib/stores/viewport';
import { IconViewGrid, IconViewList } from '@appwrite.io/pink-icons-svelte';
import { showCreateBucket } from '$routes/(console)/project-[project]/storage/+page.svelte';
export let show: boolean;
export let mimeTypeQuery: string = 'image/';
export let allowedExtension: string = '*';
export let selectedBucket: string = null;
export let selectedFile: string = null;
export let onSelect: (e: Models.File) => void;
export let gridImageDimensions: { imageHeight?: number; imageWidth?: number } = {
imageHeight: 148
};
let search = writable('');
let searchEnabled = false;
@@ -120,14 +129,27 @@
return response;
}
function truncatedFilename(file: Models.File, max: number = 15): string {
const length = file.name.length;
return length > 15 ? `${file.name.substring(0, max)}...` : file.name;
}
function getProperQuery(): string[] {
let query = [Query.orderDesc('$createdAt')];
if (allowedExtension === '*') {
query.push(Query.startsWith('mimeType', mimeTypeQuery));
} else {
query.push(Query.endsWith('name', `.${allowedExtension}`));
}
return query;
}
$: files =
currentBucket &&
sdk.forProject.storage
.listFiles(
currentBucket.$id,
[Query.startsWith('mimeType', mimeTypeQuery), Query.orderDesc('$createdAt')],
$search || undefined
)
.listFiles(currentBucket.$id, getProperQuery(), $search || undefined)
.then((response) => {
if ($search === '') {
searchEnabled = response.total > 0;
@@ -139,236 +161,462 @@
$: if ($search) {
resetFile();
}
$: extension = allowedExtension === '*' ? mimeTypeQuery : `.${allowedExtension}`;
$: console.log(`Allowed extensions: ${allowedExtension}, MimeType: ${mimeTypeQuery}`);
</script>
<svelte:document on:visibilitychange={handleVisibilityChange} />
<Form {onSubmit}>
<Form {onSubmit} isModal class="file-picker-modal-form">
<Modal bind:open={show} title="Select file" size="l">
<Layout.Stack direction="row" height="50vh">
<Layout.Stack direction={$isSmallViewport ? 'column' : 'row'} height="50vh" gap="none">
<!-- min-width to avoid a layout-shift -->
<aside>
<Typography.Caption variant="500">Buckets</Typography.Caption>
{#if !$isSmallViewport}
<Typography.Caption variant="500">Buckets</Typography.Caption>
{/if}
{#await buckets}
<div class="u-flex u-main-center">
<div class="loader"></div>
</div>
{#if $isSmallViewport}
<!-- disabled state -->
<div style:padding-block-start="1rem">
<InputSelect
required
disabled
id="bucket"
options={[]}
label="Bucket"
placeholder="Loading buckets..." />
</div>
{/if}
{:then response}
<ActionMenu.Root>
{#each response.buckets as bucket}
{@const isSelected = bucket.$id === selectedBucket}
<ActionMenu.Item.Button
on:click={() => selectBucket(bucket)}
leadingIcon={isSelected ? IconCheck : undefined}>
{bucket.name}
</ActionMenu.Item.Button>
{:else}
<ActionMenu.Item.Button>No buckets found</ActionMenu.Item.Button>
{/each}
</ActionMenu.Root>
{#if $isSmallViewport}
<div style:padding-block-start="1rem">
<InputSelect
required
id="bucket"
label="Bucket"
bind:value={selectedBucket}
placeholder="Select bucket"
on:change={(event) => {
const bucketId = event.detail;
const bucket = response.buckets.find(
(bucket) => bucket.$id === bucketId
);
selectBucket(bucket);
}}
options={response.buckets.map((bucket) => {
return {
value: bucket.$id,
label: `${bucket.name}`
};
})} />
</div>
{:else}
<div class="action-menu-holder">
<ActionMenu.Root width="180px">
{#each response.buckets as bucket}
{@const isSelected = bucket.$id === selectedBucket}
<div class="action-button" class:active-item={isSelected}>
<ActionMenu.Item.Button
on:click={() => selectBucket(bucket)}>
{bucket.name}
</ActionMenu.Item.Button>
</div>
{:else}
<ActionMenu.Item.Button
>No buckets found</ActionMenu.Item.Button>
{/each}
</ActionMenu.Root>
</div>
{/if}
{/await}
</aside>
<Layout.Stack>
{#await buckets then response}
{#if response?.total}
{#if currentBucket}
<Layout.Stack>
<Layout.Stack direction="row" alignItems="center">
<Typography.Title>{currentBucket?.name}</Typography.Title>
<Id value={currentBucket?.$id} event="bucket">
{currentBucket?.$id}
</Id>
</Layout.Stack>
<Layout.Stack direction="row" alignItems="center">
<InputSearch
placeholder="Search files"
bind:value={$search}
disabled={!searchEnabled} />
<ToggleButton
bind:active={view}
buttons={[
{
id: 'list',
label: 'list view',
disabled: !searchEnabled,
icon: IconViewList
},
{
id: 'grid',
label: 'grid view',
disabled: !searchEnabled,
icon: IconViewGrid
}
]} />
<Button
secondary
disabled={uploading}
on:click={() => fileSelector.click()}>
<input
tabindex="-1"
type="file"
accept="image/*"
class="u-hide"
on:change={uploadFile}
bind:this={fileSelector} />
{#if uploading}
<div class="loader is-small"></div>
<span>Uploading</span>
{:else}
<span class="icon-upload" aria-hidden="true"></span>
<span>Upload</span>
{/if}
</Button>
</Layout.Stack>
</Layout.Stack>
<div style:padding-inline-start="1rem" style:opacity={$isSmallViewport ? 0 : 1}>
<Divider vertical />
</div>
{#if files}
{#await files}
<Layout.Stack
justifyContent="center"
alignContent="center"
alignItems="center"
height="100%">
<Spinner size="l" />
<span>Loading files...</span>
</Layout.Stack>
{:then response}
{#if response?.files?.length}
{#if view === 'grid'}
<Layout.Grid
columnsXXS={1}
columnsXS={2}
columnsS={3}
columns={4}>
{#each response?.files as file}
<Card.Selector
group="files"
name="files"
title="files"
value={file.$id}
src={getPreview(
currentBucket.$id,
file.$id,
360
)}
on:click={() => selectFile(file)} />
{/each}
</Layout.Grid>
<div class="files-section">
<Layout.Stack gap="l">
{#if $isSmallViewport}
<Button
secondary
disabled={uploading}
on:click={() => fileSelector.click()}>
<input
type="file"
tabindex="-1"
class="u-hide"
accept={extension}
on:change={uploadFile}
bind:this={fileSelector} />
{#if uploading}
<div class="loader is-small"></div>
<span>Uploading</span>
{:else}
<span class="icon-upload" aria-hidden="true"></span>
<span>Upload</span>
{/if}
</Button>
{/if}
{#await buckets then response}
{#if response?.total}
{#if currentBucket}
<Layout.Stack>
{#if !$isSmallViewport}
{#key currentBucket?.$id}
<Layout.Stack direction="row" alignItems="center">
<Typography.Title size="s"
>{currentBucket?.name}</Typography.Title>
<Id value={currentBucket?.$id} event="bucket">
{currentBucket?.$id}
</Id>
</Layout.Stack>
{/key}
{/if}
<Layout.Stack direction="row" alignItems="center">
<InputSearch
placeholder="Search files"
bind:value={$search}
disabled={!searchEnabled} />
<ToggleButton
bind:active={view}
buttons={[
{
id: 'list',
label: 'list view',
disabled: !searchEnabled,
icon: IconViewList
},
{
id: 'grid',
label: 'grid view',
disabled: !searchEnabled,
icon: IconViewGrid
}
]} />
{#if !$isSmallViewport}
<Button
secondary
disabled={uploading}
on:click={() => fileSelector.click()}>
<input
type="file"
tabindex="-1"
class="u-hide"
accept={extension}
on:change={uploadFile}
bind:this={fileSelector} />
{#if uploading}
<div class="loader is-small"></div>
<span>Uploading</span>
{:else}
<span class="icon-upload" aria-hidden="true"
></span>
<span>Upload</span>
{/if}
</Button>
{/if}
{#if view === 'list'}
<Table.Root
let:root
columns={[
{ id: 'filename', width: { min: 140 } },
{ id: 'id', width: { min: 100 } },
{ id: 'type', width: { min: 100 } },
{ id: 'size', width: { min: 100 } },
{ id: 'created', width: { min: 120 } }
]}>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell column="filename" {root}>
Filename
</Table.Header.Cell>
<Table.Header.Cell column="id" {root}>
ID
</Table.Header.Cell>
<Table.Header.Cell column="type" {root}>
Type
</Table.Header.Cell>
<Table.Header.Cell column="size" {root}>
Size
</Table.Header.Cell>
<Table.Header.Cell column="created" {root}>
Created
</Table.Header.Cell>
</svelte:fragment>
{#each response?.files as file}
<Table.Row.Button
{root}
on:click={() => selectFile(file)}>
<Table.Cell column="filename" {root}>
<div
class="u-inline-flex u-cross-center u-gap-12">
<Selector.Radio
name="file"
group="file"
</Layout.Stack>
</Layout.Stack>
{#if files}
{#await files}
<Layout.Stack
justifyContent="center"
alignContent="center"
alignItems="center"
gap="xl"
height="100%">
<Spinner size="l" />
<span>Loading files...</span>
</Layout.Stack>
{:then response}
{#if response?.files?.length}
{#if view === 'grid'}
{#if $isSmallViewport}
<Layout.Stack gap="l">
{#each response?.files as file}
<Card.Selector
radius="s"
name="files"
padding="xxs"
imageHeight={32}
imageWidth={32}
imageRadius="xs"
bind:group={selectedFile}
title={truncatedFilename(file, 14)}
value={file.$id}
src={getPreview(
currentBucket.$id,
file.$id,
360
)}
on:click={() => selectFile(file)} />
{/each}
</Layout.Stack>
{:else}
<Layout.Grid
columnsXXS={1}
columnsXS={2}
columnsS={3}
columns={4}>
{#each response?.files as file}
<div class="image-selector">
<Card.Selector
radius="s"
name="files"
padding="xxs"
imageWidth={gridImageDimensions.imageWidth}
imageHeight={gridImageDimensions.imageHeight}
imageRadius="xs"
bind:group={selectedFile}
title={truncatedFilename(
file,
14
)}
value={file.$id}
checked={file.$id ===
selectedFile} />
<img
style:border-radius="var(--border-radius-xsmall)"
width="28"
height="28"
src={getPreview(
currentBucket.$id,
file.$id
file.$id,
360
)}
alt={file.name} />
<Typography.Text truncate>
{file.name}
</Typography.Text>
on:click={() =>
selectFile(file)} />
</div>
</Table.Cell>
<Table.Cell column="id" {root}>
<Id value={file.$id}>{file.$id}</Id>
</Table.Cell>
<Table.Cell column="type" {root}>
{file.mimeType}
</Table.Cell>
<Table.Cell column="size" {root}>
{calculateSize(file.sizeOriginal)}
</Table.Cell>
<Table.Cell column="created" {root}>
<DualTimeView time={file.$createdAt} />
</Table.Cell>
</Table.Row.Button>
{/each}
</Table.Root>
{/each}
</Layout.Grid>
{/if}
{/if}
{#if view === 'list'}
<Table.Root
let:root
columns={[
{ id: 'filename', width: { min: 225 } },
{ id: 'id', width: { min: 200 } },
{ id: 'type', width: { min: 100 } },
{ id: 'size', width: { min: 120 } },
{ id: 'created', width: { min: 140 } }
]}>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell column="filename" {root}>
Filename
</Table.Header.Cell>
<Table.Header.Cell column="id" {root}>
ID
</Table.Header.Cell>
<Table.Header.Cell column="type" {root}>
Type
</Table.Header.Cell>
<Table.Header.Cell column="size" {root}>
Size
</Table.Header.Cell>
<Table.Header.Cell column="created" {root}>
Created
</Table.Header.Cell>
</svelte:fragment>
{#each response?.files as file (file.$id)}
<Table.Row.Button
{root}
on:click={() => selectFile(file)}>
<Table.Cell column="filename" {root}>
<div
class="u-inline-flex u-cross-center u-gap-12">
<Selector.Radio
size="s"
name="file"
value={file.$id}
bind:group={selectedFile} />
<div class="preview-block">
<img
alt={file.name}
src={getPreview(
currentBucket.$id,
file.$id
)} />
</div>
<Tooltip
disabled={file.name.length <
15}
maxWidth="fit-content">
<Typography.Text truncate>
{truncatedFilename(
file
)}
</Typography.Text>
<span slot="tooltip">
{file.name}
</span>
</Tooltip>
</div>
</Table.Cell>
<Table.Cell column="id" {root}>
<Id value={file.$id}>{file.$id}</Id>
</Table.Cell>
<Table.Cell column="type" {root}>
{file.mimeType}
</Table.Cell>
<Table.Cell column="size" {root}>
{calculateSize(file.sizeOriginal)}
</Table.Cell>
<Table.Cell column="created" {root}>
<DualTimeView
time={file.$createdAt} />
</Table.Cell>
</Table.Row.Button>
{/each}
</Table.Root>
{/if}
{:else if $search}
<EmptySearch
hidePages
hidePagination
bind:search={$search}
target="files">
<Button secondary on:click={() => ($search = '')}>
Clear search
</Button>
</EmptySearch>
{:else}
<Card.Base padding="none">
<Empty
title="No files found within this bucket."
description="Need a hand? Learn more in our documentation.">
<slot name="actions" slot="actions">
<Button
text
external
size="s"
event="empty_documentation"
href="https://appwrite.io/docs/products/storage/upload-download"
ariaLabel="create document"
>Documentation</Button>
<Button
secondary
disabled={uploading}
on:click={() => fileSelector.click()}
>Upload file
</Button>
</slot>
</Empty>
</Card.Base>
{/if}
{:else if $search}
<Empty
type="secondary"
title={`Sorry we couldn't find "${$search}"`}
description="There are no files that match your search.">
<Button
secondary
slot="actions"
on:click={() => ($search = '')}
>Clear search</Button>
</Empty>
{:else}
<Empty title="No files found within this bucket.">
<Button
secondary
slot="actions"
disabled={uploading}
on:click={() => fileSelector.click()}
>Upload file</Button>
</Empty>
{/if}
{/await}
{/await}
{/if}
{/if}
{:else}
<Card.Base padding="none">
<Empty
title="No buckets found"
description="Need a hand? Learn more in our documentation.">
<slot name="actions" slot="actions">
<Button
text
external
size="s"
event="empty_documentation"
href="https://appwrite.io/docs/products/storage/buckets"
ariaLabel="create document">Documentation</Button>
<Button
secondary
on:click={async () => {
await goto(
`${base}/project-${page.params.project}/storage`
);
$showCreateBucket = true;
}}>
Create bucket
</Button>
</slot>
</Empty>
</Card.Base>
{/if}
{:else}
<Empty title="No buckets found">
<Button
slot="actions"
secondary
external
href={`${base}/project-${page.params.project}/storage`}>
Create bucket
</Button>
</Empty>
{/if}
{/await}
</Layout.Stack>
{/await}
</Layout.Stack>
</div>
</Layout.Stack>
<svelte:fragment slot="footer">
<Layout.Stack direction="row" justifyContent="flex-end">
<Button text on:click={closeModal}>Cancel</Button>
<Button submit disabled={selectedBucket === null || selectedFile === null}
>Select</Button>
>Select
</Button>
</Layout.Stack>
</svelte:fragment>
</Modal>
</Form>
<style>
:global(.file-picker-modal-form dialog .content) {
overflow: hidden;
padding: unset !important;
/* multiple scroll bars from `.content` and `.files-section` look very odd */
scrollbar-width: none;
-ms-overflow-style: none;
}
aside {
min-width: 200px;
padding: var(--space-7);
@media (max-width: 768px) {
padding-block: unset;
padding-inline: var(--space-7);
}
}
.files-section {
width: 100%;
height: 100%;
overflow: auto;
padding: var(--space-8);
background: var(--bgcolor-neutral-default);
&::-webkit-scrollbar {
display: none;
}
}
.action-menu-holder :global(div:first-of-type) {
padding-inline: unset;
}
.action-button {
width: 100%;
margin-block: 0.125rem;
& :global(button) {
width: 100%;
}
&.active-item {
border-radius: var(--border-radius-s);
background-color: var(--bgcolor-neutral-secondary, #f4f4f7);
}
}
.preview-block {
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
border: var(--border-width-S, 1px) solid var(--border-neutral-strong, #d8d8db);
& img {
border-radius: 50%;
align-self: center;
}
}
.image-selector :global(img) {
border: 1px solid var(--border-neutral);
}
</style>
-1
View File
@@ -83,4 +83,3 @@ export { default as Confirm } from './confirm.svelte';
export { default as UsageCard } from './usageCard.svelte';
export { default as ViewToggle } from './viewToggle.svelte';
export { default as Terminal } from './studio/terminal.svelte';
export { default as ExpirationInput } from './expirationInput.svelte';
-1
View File
@@ -37,7 +37,6 @@
<style>
.subMenu {
min-width: 244px;
margin-inline: -4px;
margin-block: -4px;
}
</style>
+2
View File
@@ -48,6 +48,8 @@ export enum Dependencies {
PLATFORMS = 'dependency:platforms',
KEY = 'dependency:key',
KEYS = 'dependency:keys',
DEV_KEY = 'dependency:dev_key',
DEV_KEYS = 'dependency:dev_keys',
DOMAINS = 'dependency:domains',
DOMAIN = 'dependency:domains',
WEBHOOK = 'dependency:webhook',
@@ -25,7 +25,7 @@
<Label {optionalText} {tooltip} hide={!label}>
{label}{#if $$slots.popover && isPopoverDefined}
<Drop bind:show display="inline-block">
<!-- TODO: make unclicked icon greyed out and hover and clicked filled -->
<!-- TODO: make un-clicked icon greyed out and hover and clicked filled -->
&nbsp;<button
type="button"
on:click={() => (show = !show)}
+6 -5
View File
@@ -1,4 +1,5 @@
<script context="module" lang="ts">
import { ProjectUsageRange, type Models } from '@appwrite.io/console';
export type UsagePeriods = '24h' | '30d' | '90d';
export function periodToDates(period: UsagePeriods): {
@@ -68,14 +69,14 @@
</script>
<script lang="ts">
import { BarChart } from '$lib/charts';
import { formatNumberWithCommas } from '$lib/helpers/numbers';
import { Card } from '$lib/components';
import { ProjectUsageRange, type Models } from '@appwrite.io/console';
import { page } from '$app/state';
import { Layout, Typography } from '@appwrite.io/pink-svelte';
import { goto } from '$app/navigation';
import { BarChart } from '$lib/charts';
import { Card } from '$lib/components';
import { InputSelect } from '$lib/elements/forms';
import { formatNumberWithCommas } from '$lib/helpers/numbers';
import { Layout, Typography } from '@appwrite.io/pink-svelte';
type MetricMetadata = {
title: string;
@@ -25,6 +25,7 @@
canWriteSites
} from '$lib/stores/roles';
import { consoleProfile } from '$lib/system';
import CsvImportBox from '$lib/components/csvImportBox.svelte';
onMount(() => {
return sdk.forConsole.client.subscribe(['project', 'console'], (response) => {
@@ -119,6 +120,7 @@
<UploadBox />
<MigrationBox />
<BackupRestoreBox />
<CsvImportBox />
</div>
{/if}
@@ -13,14 +13,20 @@
import CreateAttributeDropdown from './attributes/createAttributeDropdown.svelte';
import type { Option } from './attributes/store';
import CreateAttribute from './createAttribute.svelte';
import { collection, columns } from './store';
import { collection, columns, isCsvImportInProgress } from './store';
import Table from './table.svelte';
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
import { base } from '$app/paths';
import { Submit, trackEvent } from '$lib/actions/analytics';
import { Click, Submit, trackError, trackEvent } from '$lib/actions/analytics';
import FilePicker from '$lib/components/filePicker.svelte';
import type { Models } from '@appwrite.io/console';
import { sdk } from '$lib/stores/sdk';
import { addNotification } from '$lib/stores/notifications';
import { isSmallViewport } from '$lib/stores/viewport';
export let data: PageData;
let showImportCSV = false;
let showCreateAttribute = false;
let selectedAttribute: Option['name'] = null;
@@ -38,18 +44,65 @@
);
$: hasAttributes = !!$collection.attributes.length;
$: hasValidAttributes = $collection?.attributes?.some((attr) => attr.status === 'available');
async function onSelect(file: Models.File) {
$isCsvImportInProgress = true;
try {
await sdk.forProject.migrations.createCsvMigration(
file.bucketId,
file.$id,
`${page.params.database}:${page.params.collection}`
);
addNotification({
type: 'success',
message: 'Documents import from csv has started'
});
trackEvent(Submit.DatabaseImportCsv);
} catch (e) {
trackError(e, Submit.DatabaseImportCsv);
addNotification({
type: 'error',
message: e.message
});
} finally {
$isCsvImportInProgress = false;
}
}
</script>
{#key page.params.collection}
<Container>
<Layout.Stack direction="row" justifyContent="space-between">
<Filters
query={data.query}
{columns}
disabled={!(hasAttributes && hasValidAttributes)}
analyticsSource="database_documents" />
<Layout.Stack direction="row" alignItems="center" justifyContent="flex-end">
<ViewSelector view={data.view} {columns} hideView />
<Layout.Stack direction="column" gap="xl">
<Layout.Stack direction="row" justifyContent="space-between">
<Filters
query={data.query}
{columns}
disabled={!(hasAttributes && hasValidAttributes)}
analyticsSource="database_documents" />
<Layout.Stack direction="row" alignItems="center" justifyContent="flex-end">
<ViewSelector view={data.view} {columns} hideView />
<Button
secondary
event={Click.DatabaseImportCsv}
disabled={!(hasAttributes && hasValidAttributes)}
on:click={() => (showImportCSV = true)}>
Import CSV
</Button>
{#if !$isSmallViewport}
<Button
disabled={!(hasAttributes && hasValidAttributes)}
href={`${base}/project-${page.params.project}/databases/database-${page.params.database}/collection-${page.params.collection}/create`}
event="create_document">
<Icon icon={IconPlus} slot="start" size="s" />
Create document
</Button>
{/if}
</Layout.Stack>
</Layout.Stack>
{#if $isSmallViewport}
<Button
disabled={!(hasAttributes && hasValidAttributes)}
href={`${base}/project-${page.params.project}/databases/database-${page.params.database}/collection-${page.params.collection}/create`}
@@ -57,7 +110,7 @@
<Icon icon={IconPlus} slot="start" size="s" />
Create document
</Button>
</Layout.Stack>
{/if}
</Layout.Stack>
{#if hasAttributes && hasValidAttributes}
@@ -149,3 +202,16 @@
bind:showCreate={showCreateAttribute}
bind:selectedOption={selectedAttribute} />
{/if}
{#if showImportCSV}
<!-- CSVs can be text/plain or text/csv sometimes! -->
<FilePicker
{onSelect}
mimeTypeQuery="text/"
allowedExtension="csv"
bind:show={showImportCSV}
gridImageDimensions={{
imageHeight: 32,
imageWidth: 32
}} />
{/if}
@@ -19,7 +19,7 @@
import { isRelationship } from '../document-[document]/attributes/store';
import FailedModal from '../failedModal.svelte';
import CreateIndex from '../indexes/createIndex.svelte';
import { attributes, type Attributes } from '../store';
import { attributes, type Attributes, isCsvImportInProgress } from '../store';
import CreateAttributeDropdown from './createAttributeDropdown.svelte';
import Delete from './deleteAttribute.svelte';
import Edit from './edit.svelte';
@@ -32,10 +32,12 @@
IconPencil,
IconPlus,
IconSwitchHorizontal,
IconTrash
IconTrash,
IconViewList
} from '@appwrite.io/pink-icons-svelte';
import type { ComponentProps } from 'svelte';
import { Click, trackEvent } from '$lib/actions/analytics';
import CsvDisabled from '../csvDisabled.svelte';
const projectId = page.params.project;
const databaseId = page.params.database;
@@ -54,7 +56,7 @@
ip: IconLocationMarker,
url: IconLink,
email: IconLink,
enum: IconSwitchHorizontal
enum: IconViewList
};
function getAttributeStatusBadge(status: string): ComponentProps<Badge>['type'] {
@@ -155,45 +157,53 @@
: '-'}
</Table.Cell>
<Table.Cell column="actions" {root}>
<Popover let:toggle padding="none" placement="bottom-end">
<Button text icon ariaLabel="more options" on:click={toggle}>
<Icon icon={IconDotsHorizontal} size="s" />
</Button>
<ActionMenu.Root slot="tooltip">
<ActionMenu.Item.Button
leadingIcon={IconPencil}
on:click={() => {
selectedAttribute = attribute;
showEdit = true;
showDropdown[index] = false;
}}>
Update
</ActionMenu.Item.Button>
{#if !isRelationship(attribute)}
{#if $isCsvImportInProgress}
<CsvDisabled>
<Button disabled text icon ariaLabel="more options">
<Icon icon={IconDotsHorizontal} size="s" />
</Button>
</CsvDisabled>
{:else}
<Popover let:toggle padding="none" placement="bottom-end">
<Button text icon ariaLabel="more options" on:click={toggle}>
<Icon icon={IconDotsHorizontal} size="s" />
</Button>
<ActionMenu.Root slot="tooltip">
<ActionMenu.Item.Button
leadingIcon={IconPlus}
leadingIcon={IconPencil}
on:click={() => {
selectedAttribute = attribute;
showCreateIndex = true;
showEdit = true;
showDropdown[index] = false;
}}>
Create index
Update
</ActionMenu.Item.Button>
{/if}
{#if attribute.status !== 'processing'}
<ActionMenu.Item.Button
leadingIcon={IconTrash}
on:click={() => {
selectedAttribute = attribute;
showDelete = true;
showDropdown[index] = false;
trackEvent(Click.DatabaseAttributeDelete);
}}>
Delete
</ActionMenu.Item.Button>
{/if}
</ActionMenu.Root>
</Popover>
{#if !isRelationship(attribute)}
<ActionMenu.Item.Button
leadingIcon={IconPlus}
on:click={() => {
selectedAttribute = attribute;
showCreateIndex = true;
showDropdown[index] = false;
}}>
Create index
</ActionMenu.Item.Button>
{/if}
{#if attribute.status !== 'processing'}
<ActionMenu.Item.Button
leadingIcon={IconTrash}
on:click={() => {
selectedAttribute = attribute;
showDelete = true;
showDropdown[index] = false;
trackEvent(Click.DatabaseAttributeDelete);
}}>
Delete
</ActionMenu.Item.Button>
{/if}
</ActionMenu.Root>
</Popover>
{/if}
</Table.Cell>
</Table.Row.Base>
{/each}
@@ -3,28 +3,39 @@
import { ActionMenu, Icon, Popover } from '@appwrite.io/pink-svelte';
import { attributeOptions, type Option } from './store';
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
import CsvDisabled from '../csvDisabled.svelte';
import { isCsvImportInProgress } from '../store';
export let selectedOption: Option['name'] = null;
export let showCreate = false;
</script>
<Popover let:toggle padding="none" placement="bottom-start">
<slot {toggle}>
<Button on:click={toggle} event="create_attribute">
{#if $isCsvImportInProgress}
<CsvDisabled>
<Button size="s" disabled>
<Icon icon={IconPlus} slot="start" size="s" />
Create attribute
</Button>
</slot>
<ActionMenu.Root slot="tooltip">
{#each attributeOptions as attribute}
<ActionMenu.Item.Button
leadingIcon={attribute.icon}
on:click={() => {
selectedOption = attribute.name;
showCreate = true;
}}>
{attribute.name}
</ActionMenu.Item.Button>
{/each}
</ActionMenu.Root>
</Popover>
</CsvDisabled>
{:else}
<Popover let:toggle padding="none" placement="bottom-start">
<slot {toggle}>
<Button on:click={toggle} event="create_attribute">
<Icon icon={IconPlus} slot="start" size="s" />
Create attribute
</Button>
</slot>
<ActionMenu.Root slot="tooltip">
{#each attributeOptions as attribute}
<ActionMenu.Item.Button
leadingIcon={attribute.icon}
on:click={() => {
selectedOption = attribute.name;
showCreate = true;
}}>
{attribute.name}
</ActionMenu.Item.Button>
{/each}
</ActionMenu.Root>
</Popover>
{/if}
@@ -0,0 +1,12 @@
<script lang="ts">
import { isCsvImportInProgress } from './store';
import { Tooltip } from '@appwrite.io/pink-svelte';
</script>
<Tooltip disabled={!$isCsvImportInProgress} maxWidth="12rem">
<div>
<slot />
</div>
<div slot="tooltip">This action is disabled during import.</div>
</Tooltip>
@@ -26,3 +26,5 @@ export const attributes = derived(
export const indexes = derived(page, ($page) => $page.data.collection.indexes as Models.Index[]);
export const columns = writable<Column[]>([]);
export const isCsvImportInProgress = writable(false);
@@ -44,7 +44,7 @@
href={`${base}/project-${project}/databases/database-${databaseId}`}
class="database-name u-flex u-cross-center body-text-2 u-gap-8 is-not-mobile is-selected">
<Icon icon={IconDatabase} size="s" color="--fgcolor-neutral-weak" />
{data.database.name}
{data.database?.name}
</a>
<div class="collection-content">
{#if data?.allCollections?.total}
@@ -3,7 +3,7 @@
import { base } from '$app/paths';
import { page } from '$app/state';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Alert, Id, Modal } from '$lib/components';
import { Alert, Confirm, Id } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button as ConsoleButton, InputChoice } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
@@ -112,7 +112,6 @@
let selectedRows: string[] = [];
let showDelete = false;
let deleting = false;
async function handleDelete() {
showDelete = false;
@@ -276,11 +275,7 @@
</FloatingActionBar>
{/if}
<Modal
title="Delete Documents"
bind:show={showDelete}
onSubmit={handleDelete}
dismissible={!deleting}>
<Confirm title="Delete Documents" bind:open={showDelete} onSubmit={handleDelete}>
<div>
Are you sure you want to delete <b>{selectedRows.length}</b>
{selectedRows.length > 1 ? 'documents' : 'document'}?
@@ -332,12 +327,4 @@
<p class="u-bold">This action is irreversible.</p>
{/if}
</div>
<svelte:fragment slot="footer">
<ConsoleButton text on:click={() => (showDelete = false)} disabled={deleting}
>Cancel</ConsoleButton>
<ConsoleButton secondary submit disabled={deleting || (relAttributes?.length && !checked)}>
Delete
</ConsoleButton>
</svelte:fragment>
</Modal>
</Confirm>
@@ -89,7 +89,7 @@
</script>
<Form onSubmit={update}>
<CardGrid hideFooter={message.status != 'draft'}>
<CardGrid hideFooter={message.status !== 'draft'}>
<div class="grid-1-2-col-1 u-flex-vertical u-cross-start u-gap-16">
<Typography.Title size="s">Message</Typography.Title>
<div class="u-flex u-margin-block-start-24 u-width-full-line">
@@ -0,0 +1,101 @@
<script lang="ts">
import { base } from '$app/paths';
import { page } from '$app/stores';
import { InputText } from '$lib/elements/forms/index.js';
import { Wizard } from '$lib/layout';
import { Fieldset, Layout, Typography } from '@appwrite.io/pink-svelte';
import ExpirationInput from './expirationInput.svelte';
import Button from '$lib/elements/forms/button.svelte';
import Form from '$lib/elements/forms/form.svelte';
import { sdk } from '$lib/stores/sdk';
import { onboarding } from '../../store';
import { goto, invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { addNotification } from '$lib/stores/notifications';
import { writable } from 'svelte/store';
import Scopes from '../keys/scopes.svelte';
const projectId = $page.params.project;
let showExitModal = false;
let formComponent: Form;
let isSubmitting = writable(false);
let scopes = [];
let name = '',
expire = '';
async function create() {
try {
const { $id } = await sdk.forConsole.projects.createKey(
projectId,
name,
scopes,
expire || undefined
);
if ($onboarding) {
await invalidate(Dependencies.PROJECT);
}
trackEvent(Submit.KeyCreate);
await goto(`${base}/project-${projectId}/overview/keys/${$id}`);
addNotification({
message: `API key has been created`,
type: 'success'
});
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.KeyCreate);
}
}
</script>
<Wizard
title="Create API key"
href={`${base}/project-${projectId}/overview/keys/`}
bind:showExitModal
column
columnSize="s"
confirmExit>
<Form bind:this={formComponent} onSubmit={create} bind:isSubmitting>
<Layout.Stack gap="xxl">
<Fieldset legend="Configuration">
<Layout.Stack>
<InputText
id="name"
label="Name"
placeholder="Enter key name"
required
bind:value={name} />
<ExpirationInput bind:value={expire} keyType="api" />
</Layout.Stack>
</Fieldset>
<Fieldset legend="Scopes">
<Layout.Stack gap="xl">
<Typography.Text>
Choose which permission scopes to grant your application. It is best
practice to allow only the permissions you need to meet your project goals.
</Typography.Text>
<Scopes bind:scopes />
</Layout.Stack>
</Fieldset>
</Layout.Stack>
</Form>
<svelte:fragment slot="footer">
<Button fullWidthMobile secondary on:click={() => (showExitModal = true)}>Cancel</Button>
<Button
fullWidthMobile
on:click={() => formComponent.triggerSubmit()}
disabled={$isSubmitting}>
Create
</Button>
</svelte:fragment>
</Wizard>
@@ -0,0 +1,49 @@
<script lang="ts">
import { page } from '$app/state';
import { base } from '$app/paths';
import { sdk } from '$lib/stores/sdk';
import { Dependencies } from '$lib/constants';
import { type Models } from '@appwrite.io/console';
import { goto, invalidate } from '$app/navigation';
import Confirm from '$lib/components/confirm.svelte';
import { addNotification } from '$lib/stores/notifications';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
export let showDelete = false;
export let keyType: 'api' | 'dev' = 'api';
export let key: Models.DevKey | Models.Key;
const projectId = page.params.project;
const isApiKey = keyType === 'api';
const label = isApiKey ? 'API' : 'Dev';
const slug = isApiKey ? 'keys' : 'dev-keys';
const event = isApiKey ? Submit.KeyDelete : Submit.DevKeyDelete;
const dependency = isApiKey ? Dependencies.KEYS : Dependencies.DEV_KEYS;
let error: string;
async function handleDelete() {
try {
isApiKey
? await sdk.forConsole.projects.deleteKey(projectId, key.$id)
: await sdk.forConsole.projects.deleteDevKey(projectId, key.$id);
await invalidate(dependency);
showDelete = false;
addNotification({
type: 'success',
message: `${key.name} has been deleted`
});
trackEvent(event);
await goto(`${base}/project-${projectId}/overview/${slug}`);
} catch (e) {
error = e.message;
trackError(e, event);
}
}
</script>
<Confirm onSubmit={handleDelete} title="Delete {label} key" bind:open={showDelete} bind:error>
Are you sure you want to delete this key?
</Confirm>
@@ -0,0 +1,59 @@
<script lang="ts">
import { page } from '$app/state';
import { base } from '$app/paths';
import { sdk } from '$lib/stores/sdk';
import { Dependencies } from '$lib/constants';
import { goto, invalidate } from '$app/navigation';
import Confirm from '$lib/components/confirm.svelte';
import { addNotification } from '$lib/stores/notifications';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
export let showDelete = false;
export let keyIds: string[] = [];
export let keyType: 'api' | 'dev' = 'api';
let error: string;
const isApiKey = keyType === 'api';
const label = isApiKey ? 'API' : 'dev';
const projectId = page.params.project;
async function handleDelete() {
const slug = isApiKey ? 'keys' : 'dev-keys';
const event = isApiKey ? Submit.KeyDelete : Submit.DevKeyDelete;
const dependency = isApiKey ? Dependencies.KEYS : Dependencies.DEV_KEYS;
try {
await Promise.all(
keyIds.map((key) =>
isApiKey
? sdk.forConsole.projects.deleteKey(projectId, key)
: sdk.forConsole.projects.deleteDevKey(projectId, key)
)
);
await invalidate(dependency);
showDelete = false;
addNotification({
type: 'success',
message: `${keyIds.length} ${label} key${keyIds.length > 1 ? 's' : ''} deleted`
});
trackEvent(event);
await goto(`${base}/project-${projectId}/overview/${slug}`);
} catch (e) {
error = e.message;
trackError(e, event);
} finally {
keyIds = [];
}
}
</script>
<Confirm onSubmit={handleDelete} title={`Delete ${label} key`} bind:open={showDelete} bind:error>
<p>
Are you sure you want to delete <b>{keyIds.length}</b>
{label} key{keyIds.length > 1 ? 's' : ''}?
</p>
</Confirm>
@@ -19,7 +19,7 @@
return date.toISOString();
}
const options = [
const defaultOptions = [
{
label: 'Never',
value: null
@@ -46,13 +46,30 @@
}
];
const limitedOptions = [
{
label: '1 Day',
value: incrementToday(1, 'day')
},
{
label: '7 Days',
value: incrementToday(7, 'day')
},
{
label: '30 days',
value: incrementToday(30, 'day')
}
];
export let value: string | null = null;
export let dateSelectorLabel: string | undefined = undefined;
export let selectorLabel: string | undefined = 'Expiration Date';
export let resourceType: string | 'key' | 'token' | undefined = 'key';
export let keyType: 'api' | 'dev' = 'api';
const options = keyType === 'api' ? defaultOptions : limitedOptions;
function initExpirationSelect() {
if (value === null || !isValidDate(value)) return null;
if (value === null || !isValidDate(value)) {
return options[0]?.value ?? null;
}
let result = 'custom';
for (const option of options) {
@@ -68,20 +85,27 @@
}
let expirationSelect = initExpirationSelect();
let expirationCustom: string | null = value ?? null;
$: helper =
expirationSelect !== 'custom' && expirationSelect !== null
? `Your ${resourceType} will expire in ${toLocaleDate(value)}`
: null;
$: {
if (!isSameDay(new Date(expirationSelect), new Date(value))) {
value = expirationSelect === 'custom' ? expirationCustom : expirationSelect;
}
}
$: helper =
expirationSelect !== 'custom' && expirationSelect !== null
? `Your key will expire in ${toLocaleDate(value)}`
: undefined;
</script>
<InputSelect bind:value={expirationSelect} {options} id="preset" label={selectorLabel} {helper}>
</InputSelect>
<InputSelect
required
{helper}
{options}
id="preset"
label="Expiration date"
bind:value={expirationSelect} />
{#if expirationSelect === 'custom'}
<InputDateTime required id="expire" label={dateSelectorLabel} bind:value={expirationCustom} />
<InputDateTime required id="expire" label="" bind:value={expirationCustom} />
{/if}
@@ -0,0 +1,205 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { Box, CardGrid } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button, Form, InputText } from '$lib/elements/forms';
import { toLocaleDate, toLocaleDateTime } from '$lib/helpers/date';
import { Container } from '$lib/layout';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import { project } from '../../store';
import Delete from './delete.svelte';
import UpdateExpirationDate from './updateExpirationDate.svelte';
import type { Models } from '@appwrite.io/console';
import { symmetricDifference } from '$lib/helpers/array';
import Scopes from '../keys/scopes.svelte';
import { InteractiveText, Layout, Typography } from '@appwrite.io/pink-svelte';
export let key: Models.DevKey | Models.Key;
export let keyType: 'api' | 'dev' = 'api';
let name = null;
let scopes = null;
let showDelete = false;
const isApiKey = keyType === 'api';
const label = isApiKey ? 'API' : 'Dev';
const event = isApiKey ? Submit.KeyUpdateName : Submit.DevKeyUpdateName;
const dependency = isApiKey ? Dependencies.KEY : Dependencies.DEV_KEY;
onMount(() => {
name ??= key.name;
if (isApiKey) {
scopes ??= (key as Models.Key).scopes;
}
});
function asApiKey(key: Models.Key | Models.DevKey) {
return key as Models.Key;
}
async function updateName() {
try {
isApiKey
? await sdk.forConsole.projects.updateKey(
$project.$id,
key.$id,
name,
scopes,
key.expire
)
: await sdk.forConsole.projects.updateDevKey(
$project.$id,
key.$id,
name,
key.expire
);
await invalidate(dependency);
trackEvent(event);
addNotification({
type: 'success',
message: `${label} key name has been updated`
});
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, event);
}
}
async function updateScopes() {
try {
await sdk.forConsole.projects.updateKey(
$project.$id,
key.$id,
key.name,
scopes,
key.expire
);
await invalidate(Dependencies.KEY);
trackEvent(Submit.KeyUpdateScopes, {
scopes
});
addNotification({
type: 'success',
message: 'API key scopes have been updated'
});
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.KeyUpdateScopes);
}
}
let tiles: Record<string, string>;
$: tiles = {
Name: key.name,
'Last accessed': key.accessedAt ? toLocaleDate(key.accessedAt) : 'never',
'Expiration date': key.expire ? toLocaleDateTime(key.expire) : 'never',
Secret: key.secret
};
</script>
<svelte:head>
<title>{label} key - Appwrite</title>
</svelte:head>
<Container>
{@const accessedAt = key.accessedAt ? toLocaleDate(key.accessedAt) : 'never'}
<CardGrid>
<svelte:fragment slot="title">Key details</svelte:fragment>
<svelte:fragment slot="aside">
<Layout.GridFraction start={1} end={2} gap="xxxl" rowGap="xl">
{#each Object.entries(tiles) as [label, value]}
<Layout.Stack gap="xxxs">
<Typography.Text variant="m-400" color="--fgcolor-neutral-tertiary">
{label}
</Typography.Text>
<Typography.Text variant="m-400" color="--fgcolor-neutral-primary">
{#if label === 'Secret'}
<InteractiveText
variant="secret-code"
isVisible={false}
text={value} />
{:else}
{value}
{/if}
</Typography.Text>
</Layout.Stack>
{/each}
</Layout.GridFraction>
</svelte:fragment>
</CardGrid>
<Form onSubmit={updateName}>
<CardGrid>
<svelte:fragment slot="title">Name</svelte:fragment>
Choose any name that will help you distinguish between {label} keys.
<svelte:fragment slot="aside">
<InputText
id="name"
label="Name"
bind:value={name}
required
placeholder="Enter name" />
</svelte:fragment>
<svelte:fragment slot="actions">
<Button disabled={name === key.name} submit>Update</Button>
</svelte:fragment>
</CardGrid>
</Form>
{#if isApiKey}
<Form onSubmit={updateScopes}>
{@const apiKey = asApiKey(key)}
<CardGrid>
<svelte:fragment slot="title">Scopes</svelte:fragment>
You can choose which permission scope to grant your application. It is a best practice
to allow only the permissions you need to meet your project goals.
<svelte:fragment slot="aside">
{#if scopes !== null}
<Scopes bind:scopes />
{/if}
</svelte:fragment>
<svelte:fragment slot="actions">
<Button
submit
disabled={scopes &&
apiKey?.scopes &&
!symmetricDifference(scopes, apiKey.scopes).length}>Update</Button>
</svelte:fragment>
</CardGrid>
</Form>
{/if}
<UpdateExpirationDate {keyType} {key} />
<CardGrid>
<svelte:fragment slot="title">Delete {label} key</svelte:fragment>
The Dev key will be permanently deleted. This action is irreversible.
<svelte:fragment slot="aside">
<Box>
<div class="u-flex u-gap-16">
<div class="u-cross-child-center u-line-height-1-5">
<h6 class="u-bold" data-private>{key.name}</h6>
<p>Last accessed: {accessedAt}</p>
</div>
</div>
</Box>
</svelte:fragment>
<svelte:fragment slot="actions">
<Button secondary on:click={() => (showDelete = true)}>Delete</Button>
</svelte:fragment>
</CardGrid>
</Container>
<Delete {keyType} {key} bind:showDelete />
@@ -0,0 +1,127 @@
<script lang="ts">
import { base } from '$app/paths';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { Empty } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { canWriteKeys } from '$lib/stores/roles';
import type { Models } from '@appwrite.io/console';
import { diffDays, toLocaleDate, toLocaleDateTime } from '$lib/helpers/date';
import { devKeyColumns, keyColumns, showDevKeysCreateModal } from '../store';
import { Badge, FloatingActionBar, Layout, Table } from '@appwrite.io/pink-svelte';
import DeleteBatch from './deleteBatch.svelte';
import { capitalize } from '$lib/helpers/string';
export let keyType: 'api' | 'dev' = 'api';
export let keys: Models.KeyList | Models.DevKeyList;
let showDeleteModal = false;
let selectedRows: string[] = [];
const isApiKey = keyType === 'api';
const label = isApiKey ? 'API' : 'dev';
const slug = isApiKey ? 'keys' : 'dev-keys';
function asApiKey(key: Models.Key | Models.DevKey) {
return key as Models.Key;
}
function getExpiryDetails(key: Models.Key | Models.DevKey): {
status: 'success' | 'warning' | 'error' | null;
message: string | null;
} {
const isExpired = key.expire !== null && new Date(key.expire) < new Date();
const isExpiring = key.expire && diffDays(new Date(), new Date(key.expire)) < 14;
return {
status: isExpired ? 'error' : isExpiring ? 'warning' : null,
message: isExpired ? 'Expired' : isExpiring ? 'Expires soon' : null
};
}
function getKeys(): Models.Key[] | Models.DevKey[] {
if (isApiKey) return keys['keys'] as Models.Key[];
else return keys['devKeys'] as Models.DevKey[];
}
function getDescription(): string {
if (isApiKey)
return 'Use API keys to authenticate your app’s requests in production, granting secure access to live data and services.';
else
return 'Dev keys allow bypassing rate limits and accessing more detailed error messages while in development.';
}
const columns = isApiKey ? $keyColumns : $devKeyColumns;
</script>
{#if keys.total}
<Table.Root let:root {columns} bind:selectedRows allowSelection={$canWriteKeys}>
<svelte:fragment slot="header" let:root>
{#each columns as column}
<Table.Header.Cell column={column.id} {root}>{column.title}</Table.Header.Cell>
{/each}
</svelte:fragment>
{#each getKeys() as key (key.$id)}
<Table.Row.Link id={key.$id} href={`${slug}/${key.$id}`} {root}>
<Table.Cell {root}>
{key.name}
</Table.Cell>
<Table.Cell {root}>
{key.accessedAt ? toLocaleDate(key.accessedAt) : 'never'}
</Table.Cell>
<Table.Cell {root}>
{@const expiration = getExpiryDetails(key)}
<Layout.Stack gap="s" direction="row">
{key.expire ? toLocaleDateTime(key.expire) : 'never'}
{#if expiration.status}
<Badge
size="s"
variant="secondary"
type={expiration.status}
content={expiration.message} />
{/if}
</Layout.Stack>
</Table.Cell>
{#if isApiKey}
<Table.Cell {root}>
{asApiKey(key).scopes.length} Scopes
</Table.Cell>
{/if}
</Table.Row.Link>
{/each}
</Table.Root>
{:else}
<Empty
single
allowCreate={$canWriteKeys}
href="https://appwrite.io/docs/advanced/platform/{slug}"
target="{label} key"
description={getDescription()}
on:click={() => {
if (isApiKey) {
goto(`${base}/project-${page.params.project}/overview/${slug}/create`);
} else {
$showDevKeysCreateModal = true;
}
}} />
{/if}
{#if selectedRows.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedRows.length.toString()} />
<span>
{capitalize(label)}
{selectedRows.length > 1 ? 'keys' : 'key'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedRows = [])}>Cancel</Button>
<Button secondary on:click={() => (showDeleteModal = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
<DeleteBatch {keyType} bind:keyIds={selectedRows} bind:showDelete={showDeleteModal} />
@@ -0,0 +1,100 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { CardGrid } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button, Form } from '$lib/elements/forms';
import { diffDays, isSameDay } from '$lib/helpers/date';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { Alert } from '@appwrite.io/pink-svelte';
import ExpirationInput from './expirationInput.svelte';
import type { Models } from '@appwrite.io/console';
import { page } from '$app/stores';
export let keyType: 'api' | 'dev' = 'api';
export let key: Models.DevKey | Models.Key;
const projectId = $page.params.project;
const isApiKey = keyType === 'api';
const label = isApiKey ? 'API' : 'Dev';
const dependency = isApiKey ? Dependencies.KEY : Dependencies.DEV_KEY;
const event = isApiKey ? Submit.KeyUpdateExpire : Submit.DevKeyUpdateExpire;
let expiration = key.expire;
async function updateExpire() {
try {
isApiKey
? await sdk.forConsole.projects.updateKey(
projectId,
key.$id,
key.name,
(key as Models.Key).scopes,
expiration
)
: await sdk.forConsole.projects.updateDevKey(
projectId,
key.$id,
key.name,
expiration
);
await invalidate(dependency);
trackEvent(event);
addNotification({
type: 'success',
message: `${label} key expiration has been updated`
});
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, event);
}
}
let alertsDismissed = false;
$: isExpiring =
!alertsDismissed && key.expire && diffDays(new Date(), new Date(key.expire)) < 14;
$: isExpired = !alertsDismissed && key.expire !== null && new Date(key.expire) < new Date();
// for comparison's sake.
$: disableButton =
// expiration becomes `null` in `ExpirationInput`.
typeof key?.expire === 'undefined' && expiration === null
? true
: isSameDay(new Date(expiration), new Date(key?.expire));
</script>
<Form onSubmit={updateExpire}>
<CardGrid>
<svelte:fragment slot="title">Expiration date</svelte:fragment>
Set a date after which your {label} key will expire.
<svelte:fragment slot="aside">
{#if isExpired}
<Alert.Inline
status="error"
on:dismiss={() => (alertsDismissed = true)}
title="Your {label} key has expired">
For security reasons, we recommend you delete your expired key and create a new
one.
</Alert.Inline>
{:else if isExpiring}
<Alert.Inline
status="warning"
on:dismiss={() => (alertsDismissed = true)}
title="Your {label} key is about to expire">
Update the expiration date to keep the key active
</Alert.Inline>
{/if}
<ExpirationInput bind:value={expiration} {keyType} />
</svelte:fragment>
<svelte:fragment slot="actions">
<Button disabled={disableButton} submit>Update</Button>
</svelte:fragment>
</CardGrid>
</Form>
@@ -52,6 +52,10 @@
return usage.load(dates.start, dates.end, dates.period);
}
function isTabSelected(key: string) {
return page.url.pathname === `${path}/${key}`;
}
$: $registerCommands([
{
label: 'Add platform',
@@ -67,11 +71,21 @@
label: 'Create API Key',
icon: IconPlus,
callback() {
goto(`${base}/project-[project]/overview/keys/create`);
goto(`${base}/project-${projectId}/overview/keys/create`);
},
keys: ['c', 'k'],
group: 'integrations',
disabled: !$canWriteProjects
},
{
label: 'Create Dev Key',
icon: IconPlus,
callback() {
goto(`${base}/project-${projectId}/overview/dev-keys/create`);
},
keys: ['c', 'd', 'k'],
group: 'integrations',
disabled: !$canWriteProjects
}
]);
@@ -205,28 +219,31 @@
</Layout.Stack>
{/if}
<div class="u-margin-block-start-64">
<Layout.Stack gap="xl">
<Typography.Title>Integrations</Typography.Title>
<Layout.Stack gap="xl" direction="row" justifyContent="space-between">
<Tabs>
<Tab
noscroll
href={`${path}/platforms`}
selected={page.url.pathname === `${path}/platforms`}
event="platforms">Platforms</Tab>
<Tab
noscroll
href={`${path}/keys`}
selected={page.url.pathname === `${path}/keys`}
event="keys">API keys</Tab>
</Tabs>
{#if $action}
<svelte:component this={$action} />
{/if}
</Layout.Stack>
<slot />
<Layout.Stack gap="xl">
<Typography.Title>Integrations</Typography.Title>
<Layout.Stack gap="xl" direction="row" justifyContent="space-between">
<Tabs>
<Tab
noscroll
event="platforms"
href={`${path}/platforms`}
selected={isTabSelected('platforms')}>Platforms</Tab>
<Tab
noscroll
event="keys"
href={`${path}/keys`}
selected={isTabSelected('keys')}>API keys</Tab>
<Tab
noscroll
event="keys"
href={`${path}/dev-keys`}
selected={isTabSelected('dev-keys')}>Dev keys</Tab>
</Tabs>
{#if $action}
<svelte:component this={$action} />
{/if}
</Layout.Stack>
</div>
<slot />
</Layout.Stack>
</Layout.Stack>
</Container>
@@ -0,0 +1,12 @@
<script lang="ts">
import type { PageData } from './$types';
import Action from './action.svelte';
import { setOverviewAction } from '../context';
import Table from '../(components)/table.svelte';
export let data: PageData;
setOverviewAction(Action);
</script>
<Table keys={data.devKeys} keyType="dev" />
@@ -0,0 +1,13 @@
import { Dependencies } from '$lib/constants';
import { sdk } from '$lib/stores/sdk';
import { selectedTab } from '../store';
import type { PageLoad } from './$types';
selectedTab.set('dev-keys');
export const load: PageLoad = async ({ params, depends }) => {
depends(Dependencies.DEV_KEYS);
return {
devKeys: await sdk.forConsole.projects.listDevKeys(params.project)
};
};
@@ -0,0 +1,22 @@
import Header from './header.svelte';
import Breadcrumbs from './breadcrumbs.svelte';
import { sdk } from '$lib/stores/sdk';
import { Dependencies } from '$lib/constants';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params, depends }) => {
depends(Dependencies.DEV_KEY);
const key = await sdk.forConsole.projects.getDevKey(params.project, params.key);
if (key.expire) {
key.expire = new Date(key.expire).toISOString().slice(0, 23);
} else {
key.expire = undefined;
}
return {
header: Header,
breadcrumbs: Breadcrumbs,
key
};
};
@@ -0,0 +1,6 @@
<script lang="ts">
import { devKey } from './store';
import KeyDetails from '../../(components)/keyDetails.svelte';
</script>
<KeyDetails key={$devKey} keyType="dev" />
@@ -0,0 +1,28 @@
<script lang="ts">
import { base } from '$app/paths';
import { Breadcrumbs } from '$lib/layout';
import { organization } from '$lib/stores/organization';
import { project } from '../../../store';
import { devKey } from './store';
$: breadcrumbs = [
{
href: `${base}/organization-${$organization?.$id}`,
title: $organization?.name
},
{
href: `${base}/project-${$project?.$id}`,
title: $project?.name
},
{
href: `${base}/project-${$project?.$id}/overview/dev-keys`,
title: 'Dev keys'
},
{
href: `${base}/project-${$project?.$id}/overview/dev-keys/${$devKey?.$id}`,
title: $devKey?.name
}
];
</script>
<Breadcrumbs {breadcrumbs} />
@@ -0,0 +1,17 @@
<script lang="ts">
import { base } from '$app/paths';
import { page } from '$app/state';
import { Id } from '$lib/components';
import { Cover, CoverTitle } from '$lib/layout';
import { devKey } from './store';
const projectId = page.params.project;
</script>
<Cover>
<svelte:fragment slot="header">
<CoverTitle href={`${base}/project-${projectId}/overview/dev-keys`}>
{$devKey?.name}
</CoverTitle>
</svelte:fragment>
</Cover>
@@ -0,0 +1,5 @@
import { page } from '$app/stores';
import { derived } from 'svelte/store';
import type { Models } from '@appwrite.io/console';
export const devKey = derived(page, ($page) => $page.data.key as Models.DevKey);
@@ -0,0 +1,80 @@
<script lang="ts">
import Button from '$lib/elements/forms/button.svelte';
import { canWriteKeys } from '$lib/stores/roles';
import { Icon, Modal, Layout } from '@appwrite.io/pink-svelte';
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
import { Form, InputText } from '$lib/elements/forms/index.js';
import ExpirationInput from '../(components)/expirationInput.svelte';
import { sdk } from '$lib/stores/sdk';
import { goto } from '$app/navigation';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { base } from '$app/paths';
import { addNotification } from '$lib/stores/notifications';
import { page } from '$app/state';
import { showDevKeysCreateModal } from '$routes/(console)/project-[project]/overview/store';
let isSubmitting = false;
let name = '',
expire = '';
const projectId = page.params.project;
async function create() {
try {
isSubmitting = true;
const { $id } = await sdk.forConsole.projects.createDevKey(
projectId,
name,
expire || undefined
);
$showDevKeysCreateModal = false;
trackEvent(Submit.DevKeyCreate);
await goto(`${base}/project-${projectId}/overview/dev-keys/${$id}`);
addNotification({
message: `Dev key has been created`,
type: 'success'
});
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.DevKeyCreate);
} finally {
isSubmitting = false;
}
}
</script>
{#if $canWriteKeys}
<Button on:click={() => ($showDevKeysCreateModal = true)}>
<Icon icon={IconPlus} slot="start" size="s" />
Create dev key
</Button>
<Form onSubmit={create} isModal>
<Modal title="Create dev key" bind:open={$showDevKeysCreateModal}>
<span slot="description">
Test your app without rate limits and more detailed error messages.
</span>
<Layout.Stack>
<InputText
id="name"
label="Name"
placeholder="Enter key name"
required
bind:value={name} />
<ExpirationInput bind:value={expire} keyType="dev" />
</Layout.Stack>
<Layout.Stack direction="row" justifyContent="flex-end" slot="footer">
<Button fullWidthMobile secondary on:click={() => ($showDevKeysCreateModal = false)}
>Cancel</Button>
<Button fullWidthMobile submit disabled={isSubmitting}>Create</Button>
</Layout.Stack>
</Modal>
</Form>
{/if}
@@ -1,50 +1,12 @@
<script lang="ts">
import { Empty } from '$lib/components';
import { toLocaleDate, toLocaleDateTime } from '$lib/helpers/date';
import { canWriteKeys } from '$lib/stores/roles';
import type { PageData } from './$types';
import Action from './action.svelte';
import { setOverviewAction } from '../context';
import { Table } from '@appwrite.io/pink-svelte';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { page } from '$app/state';
import Table from '../(components)/table.svelte';
export let data: PageData;
setOverviewAction(Action);
</script>
{#if data.keys.total}
<Table.Root columns={4} let:root>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell {root}>Name</Table.Header.Cell>
<Table.Header.Cell {root}>Last accessed</Table.Header.Cell>
<Table.Header.Cell {root}>Expiration date</Table.Header.Cell>
<Table.Header.Cell {root}>Scopes</Table.Header.Cell>
</svelte:fragment>
{#each data.keys.keys as key}
<Table.Row.Link href={`keys/${key.$id}`} {root}>
<Table.Cell {root}>
{key.name}
</Table.Cell>
<Table.Cell {root}>
{key.accessedAt ? toLocaleDate(key.accessedAt) : 'never'}
</Table.Cell>
<Table.Cell {root}>
{key.expire ? toLocaleDateTime(key.expire) : 'never'}
</Table.Cell>
<Table.Cell {root}>
{key.scopes.length} Scopes
</Table.Cell>
</Table.Row.Link>
{/each}
</Table.Root>
{:else}
<Empty
single
allowCreate={$canWriteKeys}
href="https://appwrite.io/docs/advanced/platform/api-keys"
target="API key"
on:click={() => goto(`${base}/project-${page.params.project}/overview/keys/create`)} />
{/if}
<Table keys={data.keys} />
@@ -1,165 +1,6 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { Box, CardGrid, Secret } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button, Form, InputText } from '$lib/elements/forms';
import { symmetricDifference } from '$lib/helpers/array';
import { toLocaleDate } from '$lib/helpers/date';
import { Container } from '$lib/layout';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import { project } from '../../../store';
import Scopes from '../scopes.svelte';
import Delete from './delete.svelte';
import { key } from './store';
import UpdateExpirationDate from './updateExpirationDate.svelte';
let showDelete = false;
let name: string = null;
let secret: string = null;
let scopes: string[] = null;
onMount(() => {
name ??= $key.name;
secret ??= $key.secret;
scopes ??= $key.scopes;
});
async function updateName() {
try {
await sdk.forConsole.projects.updateKey(
$project.$id,
$key.$id,
name,
$key.scopes,
$key.expire
);
await invalidate(Dependencies.KEY);
trackEvent(Submit.KeyUpdateName);
addNotification({
type: 'success',
message: 'API key name has been updated'
});
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.KeyUpdateName);
}
}
async function updateScopes() {
try {
await sdk.forConsole.projects.updateKey(
$project.$id,
$key.$id,
$key.name,
scopes,
$key.expire
);
await invalidate(Dependencies.KEY);
trackEvent(Submit.KeyUpdateScopes, {
scopes
});
addNotification({
type: 'success',
message: 'API key scopes have been updated'
});
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.KeyUpdateScopes);
}
}
import KeyDetails from '../../(components)/keyDetails.svelte';
</script>
<svelte:head>
<title>API key - Appwrite</title>
</svelte:head>
<Container>
{@const accessedAt = $key.accessedAt ? toLocaleDate($key.accessedAt) : 'never'}
<CardGrid>
<svelte:fragment slot="title">{$key.name}</svelte:fragment>
<svelte:fragment slot="aside">
<p>
Last accessed: {accessedAt}<br />
Scopes granted: {$key.scopes.length}
</p>
</svelte:fragment>
</CardGrid>
<CardGrid>
<svelte:fragment slot="title">API key secret</svelte:fragment>
<svelte:fragment slot="aside">
<Secret copyEvent="key" bind:value={secret} />
</svelte:fragment>
</CardGrid>
<Form onSubmit={updateName}>
<CardGrid>
<svelte:fragment slot="title">Name</svelte:fragment>
Choose any name that will help you distinguish between API keys.
<svelte:fragment slot="aside">
<InputText
id="name"
label="Name"
bind:value={name}
required
placeholder="Enter name" />
</svelte:fragment>
<svelte:fragment slot="actions">
<Button disabled={name === $key.name} submit>Update</Button>
</svelte:fragment>
</CardGrid>
</Form>
<Form onSubmit={updateScopes}>
<CardGrid>
<svelte:fragment slot="title">Scopes</svelte:fragment>
You can choose which permission scope to grant your application. It is a best practice to
allow only the permissions you need to meet your project goals.
<svelte:fragment slot="aside">
{#if scopes !== null}
<Scopes bind:scopes />
{/if}
</svelte:fragment>
<svelte:fragment slot="actions">
<Button
submit
disabled={scopes &&
$key?.scopes &&
!symmetricDifference(scopes, $key.scopes).length}>Update</Button>
</svelte:fragment>
</CardGrid>
</Form>
<UpdateExpirationDate />
<CardGrid>
<svelte:fragment slot="title">Delete API key</svelte:fragment>
The API key will be permanently deleted. This action is irreversible.
<svelte:fragment slot="aside">
<Box>
<div class="u-flex u-gap-16">
<div class="u-cross-child-center u-line-height-1-5">
<h6 class="u-bold" data-private>{$key.name}</h6>
<p>Last accessed: {accessedAt}</p>
</div>
</div>
</Box>
</svelte:fragment>
<svelte:fragment slot="actions">
<Button secondary on:click={() => (showDelete = true)}>Delete</Button>
</svelte:fragment>
</CardGrid>
</Container>
<Delete bind:showDelete />
<KeyDetails key={$key} keyType="api" />
@@ -1,35 +0,0 @@
<script lang="ts">
import { goto, invalidate } from '$app/navigation';
import { base } from '$app/paths';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import Confirm from '$lib/components/confirm.svelte';
import { Dependencies } from '$lib/constants';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { project } from '../../../store';
import { key } from './store';
export let showDelete = false;
let error: string;
async function handleDelete() {
try {
await sdk.forConsole.projects.deleteKey($project.$id, $key.$id);
await invalidate(Dependencies.KEYS);
showDelete = false;
addNotification({
type: 'success',
message: `${$key.name} has been deleted`
});
trackEvent(Submit.KeyDelete);
await goto(`${base}/project-${$project.$id}/overview/keys`);
} catch (e) {
error = e.message;
trackError(e, Submit.KeyDelete);
}
}
</script>
<Confirm onSubmit={handleDelete} title="Delete API key" bind:open={showDelete} bind:error>
Are you sure you want to delete this execution?
</Confirm>
@@ -13,6 +13,5 @@
<CoverTitle href={`${base}/project-${projectId}/overview/keys`}>
{$key?.name}
</CoverTitle>
<Id value={$key?.secret}>API key secret</Id>
</svelte:fragment>
</Cover>
@@ -1,5 +1,5 @@
import { derived } from 'svelte/store';
import { page } from '$app/stores';
import { derived } from 'svelte/store';
import type { Models } from '@appwrite.io/console';
export const key = derived(page, ($page) => $page.data.key as Models.Key);
@@ -1,75 +0,0 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { CardGrid } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button, Form } from '$lib/elements/forms';
import { diffDays } from '$lib/helpers/date';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { Alert } from '@appwrite.io/pink-svelte';
import { project } from '../../../store';
import { ExpirationInput } from '$lib/components';
import { key } from './store';
let expiration = $key.expire;
async function updateExpire() {
try {
await sdk.forConsole.projects.updateKey(
$project.$id,
$key.$id,
$key.name,
$key.scopes,
expiration
);
await invalidate(Dependencies.KEY);
trackEvent(Submit.KeyUpdateExpire);
addNotification({
type: 'success',
message: 'API key expiration has been updated'
});
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.KeyUpdateExpire);
}
}
let alertsDismissed = false;
$: isExpiring =
!alertsDismissed && $key.expire && diffDays(new Date(), new Date($key.expire)) < 14;
$: isExpired = !alertsDismissed && $key.expire !== null && new Date($key.expire) < new Date();
</script>
<Form onSubmit={updateExpire}>
<CardGrid>
<svelte:fragment slot="title">Expiration date</svelte:fragment>
Set a date after which your API key will expire.
<svelte:fragment slot="aside">
{#if isExpired}
<Alert.Inline
status="error"
on:dismiss={() => (alertsDismissed = true)}
title="Your API key has expired">
For security reasons, we recommend you delete your expired key and create a new
one.
</Alert.Inline>
{:else if isExpiring}
<Alert.Inline
status="warning"
on:dismiss={() => (alertsDismissed = true)}
title="Your API key is about to expire">
Update the expiration date to keep the key active
</Alert.Inline>
{/if}
<ExpirationInput bind:value={expiration} />
</svelte:fragment>
<svelte:fragment slot="actions">
<Button disabled={expiration === $key.expire} submit>Update</Button>
</svelte:fragment>
</CardGrid>
</Form>
@@ -1,98 +1,5 @@
<script lang="ts">
import { base } from '$app/paths';
import { page } from '$app/state';
import { InputText } from '$lib/elements/forms/index.js';
import { Wizard } from '$lib/layout';
import { Fieldset, Layout, Typography } from '@appwrite.io/pink-svelte';
import { ExpirationInput } from '$lib/components';
import Scopes from '../scopes.svelte';
import Button from '$lib/elements/forms/button.svelte';
import Form from '$lib/elements/forms/form.svelte';
import { sdk } from '$lib/stores/sdk';
import { onboarding } from '$routes/(console)/project-[project]/store';
import { goto, invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { addNotification } from '$lib/stores/notifications';
import { writable } from 'svelte/store';
let showExitModal = false;
let formComponent: Form;
let isSubmitting = writable(false);
let name = '',
expire = '';
let scopes = [];
async function create() {
try {
const { $id } = await sdk.forConsole.projects.createKey(
page.params.project,
name,
scopes,
expire || undefined
);
if ($onboarding) {
await invalidate(Dependencies.PROJECT);
}
trackEvent(Submit.KeyCreate, {
scopes: scopes
});
goto(`${base}/project-${page.params.project}/overview/keys/${$id}`);
addNotification({
message: `API key has been created`,
type: 'success'
});
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.KeyCreate);
}
}
import CreateKey from '../../(components)/create.svelte';
</script>
<Wizard
title="Create API key"
href={`${base}/project-${page.params.project}/overview/keys/`}
bind:showExitModal
column
confirmExit>
<Form bind:this={formComponent} onSubmit={create} bind:isSubmitting>
<Layout.Stack gap="xxl">
<Fieldset legend="Configuration">
<Layout.Stack>
<InputText
id="name"
label="Name"
placeholder="API key name"
required
bind:value={name} />
<ExpirationInput bind:value={expire} />
</Layout.Stack>
</Fieldset>
<Fieldset legend="Scopes">
<Layout.Stack gap="xl">
<Typography.Text>
Choose which permission scopes to grant your application. It is best
practice to allow only the permissions you need to meet your project goals.
</Typography.Text>
<Scopes bind:scopes />
</Layout.Stack>
</Fieldset>
</Layout.Stack>
</Form>
<svelte:fragment slot="footer">
<Button fullWidthMobile secondary on:click={() => (showExitModal = true)}>Cancel</Button>
<Button
fullWidthMobile
on:click={() => formComponent.triggerSubmit()}
disabled={$isSubmitting}>
Create
</Button>
</svelte:fragment>
</Wizard>
<CreateKey />
@@ -17,6 +17,16 @@
Other = 'Other'
}
const categories = [
Category.Auth,
Category.Database,
Category.Functions,
Category.Storage,
Category.Messaging,
Category.Sites,
Category.Other
];
let mounted = false;
onMount(() => {
@@ -77,8 +87,6 @@
}
}
}
$: console.log(scopes);
</script>
<Layout.Stack>
@@ -92,14 +100,16 @@
<Layout.Stack gap="none">
<Divider />
{#each [Category.Auth, Category.Database, Category.Functions, Category.Storage, Category.Messaging, Category.Sites, Category.Other] as category}
{#each categories as category, index}
{@const checked = categoryState(category, scopes)}
{@const isLastItem = index === categories.length - 1}
{@const scopesLength = allScopes.filter(
(n) => n.category === category && scopes.includes(n.scope)
).length}
<Accordion
selectable
title={category}
hideDivider={isLastItem}
badge={`${scopesLength} ${scopesLength === 1 ? 'Scope' : 'Scopes'}`}
{checked}
on:change={(event) => onCategoryChange(event, category)}>
@@ -1,7 +1,8 @@
import { sdk } from '$lib/stores/sdk';
import { cachedStore } from '$lib/helpers/cache';
import { writable, type Writable } from 'svelte/store';
import { get, readable, writable, type Writable } from 'svelte/store';
import type { Models, ProjectUsageRange } from '@appwrite.io/console';
import type { Column } from '$lib/helpers/types';
export const usage = cachedStore<
Models.UsageProject,
@@ -17,4 +18,17 @@ export const usage = cachedStore<
};
});
export const selectedTab: Writable<'platforms' | 'keys'> = writable('platforms');
export const selectedTab: Writable<'platforms' | 'keys' | 'dev-keys'> = writable('platforms');
export const showDevKeysCreateModal: Writable<boolean> = writable(false);
export const devKeyColumns = readable<Column[]>([
{ id: 'name', title: 'Name', type: 'string', width: { min: 120 } },
{ id: 'last_accessed', title: 'Last accessed', type: 'datetime', width: { min: 120 } },
{ id: 'expiration', title: 'Expiration date', type: 'datetime', width: { min: 120 } }
]);
export const keyColumns = readable<Column[]>([
...get(devKeyColumns),
{ id: 'scopes', title: 'Scopes', type: 'string', width: { min: 120 } }
]);
@@ -49,7 +49,7 @@
try {
await sdk.forProject.storage.deleteFile(file.bucketId, file.$id);
await invalidate(Dependencies.FILES);
uploader.removeFile(file);
await uploader.removeFile(file);
trackEvent(Submit.FileDelete);
} catch (error) {
addNotification({
@@ -218,11 +218,11 @@
<Empty
single
target="file"
href="https://appwrite.io/docs/products/storage/upload-download"
on:click={() =>
on:click={() => {
goto(
`${base}/project-${page.params.project}/storage/bucket-${page.params.bucket}/create`
)} />
);
}} />
{/if}
</Container>
@@ -15,19 +15,20 @@
try {
const fileName = $file.name;
await sdk.forProject.storage.deleteFile($file.bucketId, $file.$id);
showDelete = false;
await goto(`${base}/project-${page.params.project}/storage/bucket-${$file.bucketId}`);
addNotification({
type: 'success',
message: `${fileName} has been deleted`
});
trackEvent(Submit.FileDelete);
await goto(`${base}/project-${page.params.project}/storage/bucket-${$file.bucketId}`);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.FileDelete);
} finally {
showDelete = false;
}
};
</script>
@@ -186,11 +186,11 @@
</svelte:fragment>
<svelte:fragment slot="links">
<li class="inline-links-item">
<a href={`${base}/recover`}><span class="text">Forgot Password?</span></a>
<a href={`${base}/recover`}><span class="text">Forgot password?</span></a>
</li>
<li class="inline-links-item">
<a href={`${base}/register${page?.url?.search ?? ''}`}>
<span class="text">Sign Up</span>
<span class="text">Sign up</span>
</a>
</li>
</svelte:fragment>