Merge branch 'main' into bun

This commit is contained in:
Darshan
2026-01-06 17:26:09 +05:30
73 changed files with 1262 additions and 874 deletions
+2 -1
View File
@@ -268,7 +268,7 @@ export enum Submit {
AuthSessionAlertsUpdate = 'submit_auth_session_alerts_update',
AuthMembershipPrivacyUpdate = 'submit_auth_membership_privacy_update',
AuthMockNumbersUpdate = 'submit_auth_mock_numbers_update',
AuthInvalidateSesssion = 'submit_auth_invalidate_session',
AuthInvalidateSession = 'submit_auth_invalidate_session',
SessionsLengthUpdate = 'submit_sessions_length_update',
SessionsLimitUpdate = 'submit_sessions_limit_update',
SessionDelete = 'submit_session_delete',
@@ -277,6 +277,7 @@ export enum Submit {
DatabaseDelete = 'submit_database_delete',
DatabaseUpdateName = 'submit_database_update_name',
DatabaseImportCsv = 'submit_database_import_csv',
DatabaseBackupDelete = 'submit_database_backup_delete',
ColumnCreate = 'submit_column_create',
ColumnUpdate = 'submit_column_update',
+28 -5
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import PaginationWithLimit from './paginationWithLimit.svelte';
import { Button, InputText } from '$lib/elements/forms';
import { GridItem1, CardContainer, Modal } from '$lib/components';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
@@ -45,9 +46,19 @@
projectsToArchive: Models.Project[];
organization: Organization;
currentPlan: Plan;
archivedTotalOverall: number;
archivedOffset: number;
limit: number;
}
let { projectsToArchive, organization, currentPlan }: Props = $props();
let {
projectsToArchive,
organization,
currentPlan,
archivedTotalOverall,
archivedOffset,
limit
}: Props = $props();
// Check if current plan order is less than Pro (order < 1 means FREE plan)
let isPlanBelowPro = $derived(currentPlan?.order < 1);
@@ -187,7 +198,7 @@
}
import { formatName as formatNameHelper } from '$lib/helpers/string';
function formatName(name: string, limit: number = 19) {
function formatName(name: string, limit: number = 16) {
return formatNameHelper(name, limit, $isSmallViewport);
}
</script>
@@ -196,7 +207,7 @@
<div class="archive-projects-margin-top">
<Accordion
title={isPlanBelowPro ? 'Archived projects' : 'Pending archive'}
badge={`${projectsToArchive.length}`}>
badge={`${archivedTotalOverall}`}>
<Typography.Text tag="p" size="s">
{#if isPlanBelowPro}
These projects are archived and require a plan upgrade to restore access.
@@ -206,7 +217,7 @@
</Typography.Text>
<div class="archive-projects-margin">
<CardContainer disableEmpty={true} total={projectsToArchive.length}>
<CardContainer disableEmpty={true} total={archivedTotalOverall}>
{#each projectsToArchive as project}
{@const platforms = filterPlatforms(
project.platforms.map((platform) => getPlatformInfo(platform.type))
@@ -266,7 +277,7 @@
</Badge>
{/each}
{#if platforms.length > 3}
{#if platforms.length > 2}
<Badge
variant="secondary"
content={`+${platforms.length - 2}`}
@@ -282,6 +293,15 @@
</GridItem1>
{/each}
</CardContainer>
<PaginationWithLimit
name="Archived Projects"
{limit}
offset={archivedOffset}
total={archivedTotalOverall}
pageParam="archivedPage"
removeOnFirstPage
class="pagination-container" />
</div>
</Accordion>
</div>
@@ -355,4 +375,7 @@
align-items: center;
gap: 8px;
}
:global(.pagination-container) {
margin-top: 16px;
}
</style>
@@ -1,26 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import { Button } from '$lib/elements/forms';
import { HeaderAlert } from '$lib/layout';
import { hideBillingHeaderRoutes, paymentMissingMandate } from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import { confirmSetup } from '$lib/stores/stripe';
async function verifyPaymentMethod() {
const method = await sdk.forConsole.billing.setupPaymentMandate(
$organization.$id,
$paymentMissingMandate.$id
);
await confirmSetup(method.clientSecret, method.providerMethodId);
}
</script>
{#if $paymentMissingMandate && $paymentMissingMandate?.country?.toLowerCase() === 'in' && $paymentMissingMandate.mandateId === null && !hideBillingHeaderRoutes.includes(page.url.pathname)}
<HeaderAlert title="Authorization required" type="info">
The payment method for {$organization.name} needs to be verified.
<svelte:fragment slot="buttons">
<Button secondary on:click={verifyPaymentMethod}>Verify payment method</Button>
</svelte:fragment>
</HeaderAlert>
{/if}
+13 -7
View File
@@ -35,16 +35,22 @@
</Typography.Text>
</Layout.Stack>
<Table.Root columns={3} let:root>
<Table.Root
columns={[
{ id: 'type', width: { min: 150 } },
{ id: 'name', width: { min: 80 } },
{ id: 'value', width: { min: 100 } }
]}
let:root>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell {root}>Type</Table.Header.Cell>
<Table.Header.Cell {root}>Name</Table.Header.Cell>
<Table.Header.Cell {root}>Value</Table.Header.Cell>
<Table.Header.Cell column="type" {root}>Type</Table.Header.Cell>
<Table.Header.Cell column="name" {root}>Name</Table.Header.Cell>
<Table.Header.Cell column="value" {root}>Value</Table.Header.Cell>
</svelte:fragment>
<Table.Row.Base {root}>
<Table.Cell {root}>CNAME</Table.Cell>
<Table.Cell {root}>{subdomain}</Table.Cell>
<Table.Cell {root}>
<Table.Cell column="type" {root}>CNAME</Table.Cell>
<Table.Cell column="name" {root}>{subdomain}</Table.Cell>
<Table.Cell column="value" {root}>
<InteractiveText
variant="copy"
isVisible
+16 -10
View File
@@ -101,29 +101,35 @@
</Typography.Text>
</Layout.Stack>
<Table.Root columns={3} let:root>
<Table.Root
columns={[
{ id: 'type', width: { min: 150 } },
{ id: 'name', width: { min: 80 } },
{ id: 'value', width: { min: 100 } }
]}
let:root>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell {root}>Type</Table.Header.Cell>
<Table.Header.Cell {root}>Name</Table.Header.Cell>
<Table.Header.Cell {root}>Value</Table.Header.Cell>
<Table.Header.Cell column="type" {root}>Type</Table.Header.Cell>
<Table.Header.Cell column="name" {root}>Name</Table.Header.Cell>
<Table.Header.Cell column="value" {root}>Value</Table.Header.Cell>
</svelte:fragment>
<Table.Row.Base {root}>
<Table.Cell {root}>{variant.toUpperCase()}</Table.Cell>
<Table.Cell {root}>{subdomain || '@'}</Table.Cell>
<Table.Cell {root}>
<Table.Cell column="type" {root}>{variant.toUpperCase()}</Table.Cell>
<Table.Cell column="name" {root}>{subdomain || '@'}</Table.Cell>
<Table.Cell column="value" {root}>
<InteractiveText variant="copy" isVisible text={setTarget()} />
</Table.Cell>
</Table.Row.Base>
{#if $regionalConsoleVariables._APP_DOMAIN_TARGET_CAA}
<Table.Row.Base {root}>
<Table.Cell {root}>
<Table.Cell column="type" {root}>
<Layout.Stack gap="s" direction="row" alignItems="center">
<span>CAA</span>
<Badge variant="secondary" size="xs" content="Recommended" />
</Layout.Stack>
</Table.Cell>
<Table.Cell {root}>@</Table.Cell>
<Table.Cell {root}>
<Table.Cell column="name" {root}>@</Table.Cell>
<Table.Cell column="value" {root}>
<InteractiveText variant="copy" isVisible text={caaText} />
</Table.Cell>
</Table.Row.Base>
+13 -4
View File
@@ -8,6 +8,8 @@
export let sum: number;
export let limit: number;
export let name: string;
export let pageParam: string = 'page';
export let removeOnFirstPage: boolean = false;
const options = [
{ label: '6', value: 6 },
@@ -23,10 +25,17 @@
url.searchParams.set('limit', limit.toString());
await preferences.setLimit(limit);
if (url.searchParams.has('page')) {
const page = Number(url.searchParams.get('page'));
const newPage = Math.floor(((page - 1) * previousLimit) / limit);
url.searchParams.set('page', newPage.toString());
if (url.searchParams.has(pageParam)) {
const page = Number(url.searchParams.get(pageParam));
const prev =
Number.isFinite(previousLimit) && previousLimit > 0 ? previousLimit : limit;
const newPage = Math.floor(((page - 1) * prev) / limit) + 1;
const safePage = Math.max(1, Number.isFinite(newPage) ? newPage : 1);
if (removeOnFirstPage && safePage === 1) {
url.searchParams.delete(pageParam);
} else {
url.searchParams.set(pageParam, safePage.toString());
}
}
await goto(url.toString());
+93 -20
View File
@@ -1,5 +1,13 @@
<script lang="ts" module>
export type DeleteOperationState = Error | void;
export type DeleteOperationState = {
error?: Error;
deleted: string[];
} | void;
export type DeleteOperation = (
deleteFn: (id: string) => Promise<unknown>,
batchSize?: number
) => Promise<Exclude<DeleteOperationState, void>>;
</script>
<script lang="ts">
@@ -40,7 +48,14 @@
children: Snippet<[root: TableRootProps]>;
deleteContent?: Snippet<[count: number]>;
deleteContentNotice?: Snippet;
onDelete?: (selectedRows: string[]) => Promise<DeleteOperationState> | DeleteOperationState;
onDelete?: (
batchDelete: DeleteOperation,
/**
* this is useful when you have a custom deletion logic
* and the default `batchDelete` helper doesn't fit the use-case!
*/
selectedRows: string[]
) => Promise<DeleteOperationState> | DeleteOperationState;
onCancel?: () => Promise<void> | void;
} = $props();
@@ -49,10 +64,8 @@
let onDeleteError: string | null = $state(null);
let showConfirmDeletion: boolean = $state(false);
function notifySuccess() {
function notifySuccess(count: number) {
if (!showSuccessNotification) return;
const count = selectedRows.length;
if (count === 0) return;
const label = `${resource}${count > 1 ? 's' : ''}`;
@@ -71,6 +84,75 @@
return `${resource}s`;
}
async function batchDelete(
ids: string[],
deleteFn: (id: string) => Promise<unknown>,
batchSize: number | undefined = undefined
): Promise<Exclude<DeleteOperationState, void>> {
const deleted: string[] = [];
let firstError: Error | undefined;
// prevent infinite loop
if (batchSize !== undefined) {
batchSize = Math.max(1, Math.floor(Math.abs(batchSize)));
}
async function processBatch(batch: string[]) {
// build promises
const results = await Promise.allSettled(batch.map((id) => deleteFn(id)));
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
// success, log it!
deleted.push(batch[index]);
} else if (!firstError) {
// error
firstError =
result.reason instanceof Error
? result.reason
: new Error(String(result.reason));
}
});
}
// batch when needed.
// example: >= 100 items to delete!
if (batchSize && batchSize < ids.length) {
for (let i = 0; i < ids.length; i += batchSize) {
const batch = ids.slice(i, i + batchSize);
await processBatch(batch);
}
} else {
await processBatch(ids);
}
return {
deleted,
error: firstError
};
}
async function consumeDeleteOperation() {
const state = await onDelete?.((deleteFn, batchSize) => {
return batchDelete(selectedRows, deleteFn, batchSize);
}, selectedRows);
if (!state) {
return false;
}
const deletedCount = state.deleted.length;
selectedRows = selectedRows.filter((id) => !state.deleted.includes(id));
if (state.error) {
onDeleteError = `Some ${getPluralResource()} were not deleted. Error: ${state.error.message}`;
return false;
}
notifySuccess(deletedCount);
return true;
}
</script>
{#key computeKey}
@@ -104,13 +186,7 @@
if (confirmDeletion) {
showConfirmDeletion = true;
} else {
const state = await onDelete?.(selectedRows);
if (state instanceof Error) {
// user should handle error on their own!
} else {
notifySuccess();
selectedRows = [];
}
await consumeDeleteOperation();
}
}}>Delete</Button>
</svelte:fragment>
@@ -129,16 +205,13 @@
disableModal = true;
onDeleteError = null;
const state = await onDelete?.(selectedRows);
if (state instanceof Error) {
disableModal = false;
onDeleteError = state.message || `Failed to delete ${resource}s`;
} else {
notifySuccess();
selectedRows = [];
disableModal = false;
const allDeleted = await consumeDeleteOperation();
if (allDeleted) {
showConfirmDeletion = false;
}
disableModal = false;
}}>
<Typography.Text>
{@const selectionCount = selectedRows.length}
+8 -2
View File
@@ -6,15 +6,21 @@
export let limit: number;
export let offset: number;
export let useCreateLink = true;
export let pageParam: string = 'page';
export let removeOnFirstPage: boolean = false;
$: currentPage = Math.floor(offset / limit + 1);
function getLink(page: number): string {
const url = new URL(pageStore.url);
if (page === 1) {
url.searchParams.delete('page');
if (removeOnFirstPage) {
url.searchParams.delete(pageParam);
} else {
url.searchParams.set(pageParam, '1');
}
} else {
url.searchParams.set('page', page.toString());
url.searchParams.set(pageParam, page.toString());
}
return url.toString();
+17 -4
View File
@@ -8,13 +8,19 @@
offset,
total,
name,
useCreateLink = true
useCreateLink = true,
pageParam = 'page',
removeOnFirstPage = false,
...restProps
}: {
limit: number;
offset: number;
total: number;
name: string;
useCreateLink?: boolean;
pageParam?: string;
removeOnFirstPage?: boolean;
[key: string]: unknown;
} = $props();
const showLimit = $derived(!!useCreateLink);
@@ -22,10 +28,17 @@
const alignItems = $derived(showLimit ? 'center' : 'flex-end');
</script>
<Layout.Stack wrap="wrap" {direction} {alignItems} justifyContent="space-between">
<Layout.Stack wrap="wrap" {direction} {alignItems} justifyContent="space-between" {...restProps}>
{#if showLimit}
<Limit {limit} sum={total} {name} />
<Limit {limit} sum={total} {name} {pageParam} {removeOnFirstPage} />
{/if}
<Pagination on:page {limit} {offset} sum={total} {useCreateLink} />
<Pagination
on:page
{limit}
{offset}
sum={total}
{useCreateLink}
{pageParam}
{removeOnFirstPage} />
</Layout.Stack>
+4 -2
View File
@@ -13,7 +13,8 @@
name = 'items',
gap = 's',
offset = $bindable(0),
children
children,
...restProps
}: {
items: T[];
limit?: number;
@@ -26,6 +27,7 @@
| undefined;
offset?: number;
children: Snippet<[T[], number]>;
[key: string]: unknown;
} = $props();
let total = $derived(items.length);
@@ -33,7 +35,7 @@
let paginatedItems = $derived(items.slice(offset, offset + limit));
</script>
<Layout.Stack {gap}>
<Layout.Stack {gap} {...restProps}>
{@render children(paginatedItems, limit)}
{#if !hideFooter}
+12
View File
@@ -206,6 +206,18 @@ export function timeFromNow(datetime: string): string {
return dayjs().to(dayjs(datetime));
}
export function timeFromNowShort(datetime: string): string {
if (!datetime || !isValidDate(datetime)) {
return 'n/a';
}
const timeStr = dayjs().to(dayjs(datetime));
return timeStr
.replace('second', 'sec') // seconds > secs
.replace('minute', 'min') // minutes > mins
.replace('hour', 'hr'); // hours > hrs
}
export function hoursToDays(hours: number) {
if (hours > 24) {
return `${Math.floor(hours / 24)} days`;
+74 -52
View File
@@ -1,9 +1,9 @@
<script lang="ts">
import { AvatarInitials, PaginationWithLimit, Trim } from '$lib/components';
import { Container } from '$lib/layout';
import { toLocaleDateTime } from '$lib/helpers/date';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import type { Models } from '@appwrite.io/console';
import { Layout, Table, Card, Empty } from '@appwrite.io/pink-svelte';
import { Layout, Table, Card, Empty, InteractiveText } from '@appwrite.io/pink-svelte';
import Button from '$lib/elements/forms/button.svelte';
import type { PinkColumn } from '$lib/helpers/types';
@@ -14,12 +14,32 @@
export let databasesScreen = false;
export let useCreateLinkForPagination = true;
function getColumnWidth(columnId: string): Pick<PinkColumn, 'width'> {
const widthConfig: Record<
string,
{ insideSheet: number | { min: number }; default: number | { min: number } }
> = {
user: { insideSheet: 140, default: { min: 100 } },
event: { insideSheet: 125, default: { min: 160 } },
location: { insideSheet: 100, default: { min: 120 } },
ip: { insideSheet: { min: 150 }, default: { min: 250 } },
date: { insideSheet: { min: 200 }, default: { min: 180 } }
};
const config = widthConfig[columnId];
if (!config) {
return {};
}
return { width: insideSideSheet ? config.insideSheet : config.default };
}
const columns: PinkColumn[] = [
{ id: 'user', ...(insideSideSheet ? { width: 140 } : {}) },
{ id: 'event', ...(insideSideSheet ? { width: 125 } : {}) },
{ id: 'location', ...(insideSideSheet ? { width: 100 } : {}) },
{ id: 'ip', ...(insideSideSheet ? { width: { min: 150 } } : {}) },
{ id: 'date', ...(insideSideSheet ? { width: { min: 200 } } : {}) }
{ id: 'user', ...getColumnWidth('user') },
{ id: 'event', ...getColumnWidth('event') },
{ id: 'location', ...getColumnWidth('location') },
{ id: 'ip', ...getColumnWidth('ip') },
{ id: 'date', ...getColumnWidth('date') }
];
</script>
@@ -29,55 +49,57 @@
expanded={databasesScreen && !insideSideSheet}
slotSpacing={databasesScreen && !insideSideSheet}>
{#if logs.total}
<div>
<Table.Root {columns} let:root>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell column="user" {root}>User</Table.Header.Cell>
<Table.Header.Cell column="event" {root}>Event</Table.Header.Cell>
<Table.Header.Cell column="location" {root}>Location</Table.Header.Cell>
<Table.Header.Cell column="ip" {root}>IP</Table.Header.Cell>
<Table.Header.Cell column="date" {root}>Date</Table.Header.Cell>
</svelte:fragment>
{#each logs.logs as log}
<Table.Row.Base {root}>
<Table.Cell column="user" {root}>
<Layout.Stack direction="row" alignItems="center">
{#if log.userEmail}
{#if log.userName}
<AvatarInitials size="xs" name={log.userName} />
<Trim>{log.userName}</Trim>
{:else}
<AvatarInitials size="xs" name={log.userEmail} />
<Trim>{log.userEmail}</Trim>
{/if}
<Table.Root {columns} let:root>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell column="user" {root}>User</Table.Header.Cell>
<Table.Header.Cell column="event" {root}>Event</Table.Header.Cell>
<Table.Header.Cell column="location" {root}>Location</Table.Header.Cell>
<Table.Header.Cell column="ip" {root}>IP</Table.Header.Cell>
<Table.Header.Cell column="date" {root}>Date</Table.Header.Cell>
</svelte:fragment>
{#each logs.logs as log}
<Table.Row.Base {root}>
<Table.Cell column="user" {root}>
<Layout.Stack direction="row" alignItems="center">
{#if log.userEmail}
{#if log.userName}
<AvatarInitials size="xs" name={log.userName} />
<Trim>{log.userName}</Trim>
{:else}
<div class="avatar is-size-small">
<span class="icon-anonymous" aria-hidden="true"></span>
</div>
<span class="text u-trim">{log.userName ?? 'Anonymous'}</span>
<AvatarInitials size="xs" name={log.userEmail} />
<Trim>{log.userEmail}</Trim>
{/if}
</Layout.Stack>
</Table.Cell>
<Table.Cell column="event" {root}>
{log.event}
</Table.Cell>
<Table.Cell column="location" {root}>
{#if log.countryCode !== '--'}
{log.countryName}
{:else}
Unknown
<div class="avatar is-size-small">
<span class="icon-anonymous" aria-hidden="true"></span>
</div>
<span class="text u-trim">{log.userName ?? 'Anonymous'}</span>
{/if}
</Table.Cell>
<Table.Cell column="ip" {root}>
{log.ip}
</Table.Cell>
<Table.Cell column="date" {root}>
{toLocaleDateTime(log.time)}
</Table.Cell>
</Table.Row.Base>
{/each}
</Table.Root>
</div>
</Layout.Stack>
</Table.Cell>
<Table.Cell column="event" {root}>
{log.event}
</Table.Cell>
<Table.Cell column="location" {root}>
{#if log.countryCode !== '--'}
{log.countryName}
{:else}
Unknown
{/if}
</Table.Cell>
<Table.Cell column="ip" {root}>
<InteractiveText variant="copy" text={log.ip} isVisible />
</Table.Cell>
<Table.Cell column="date" {root}>
<DualTimeView time={log.time} showDatetime />
</Table.Cell>
</Table.Row.Base>
{/each}
</Table.Root>
<PaginationWithLimit
{limit}
-20
View File
@@ -1300,26 +1300,6 @@ export class Billing {
);
}
async setupPaymentMandate(
organizationId: string,
paymentMethodId: string
): Promise<PaymentMethodData> {
const path = `/account/payment-methods/${paymentMethodId}/setup`;
const params = {
organizationId,
paymentMethodId
};
const uri = new URL(this.client.config.endpoint + path);
return await this.client.call(
'patch',
uri,
{
'content-type': 'application/json'
},
params
);
}
async listAddresses(queries: string[] = []): Promise<AddressesList> {
const path = `/account/billing-addresses`;
const params = {
+1 -20
View File
@@ -8,7 +8,7 @@ import MarkedForDeletion from '$lib/components/billing/alerts/markedForDeletion.
import MissingPaymentMethod from '$lib/components/billing/alerts/missingPaymentMethod.svelte';
import newDevUpgradePro from '$lib/components/billing/alerts/newDevUpgradePro.svelte';
import PaymentAuthRequired from '$lib/components/billing/alerts/paymentAuthRequired.svelte';
import PaymentMandate from '$lib/components/billing/alerts/paymentMandate.svelte';
import { BillingPlan, NEW_DEV_PRO_UPGRADE_COUPON } from '$lib/constants';
import { cachedStore } from '$lib/helpers/cache';
import { type Size, sizeToBytes } from '$lib/helpers/sizeConvertion';
@@ -18,7 +18,6 @@ import type {
Invoice,
InvoiceList,
PaymentList,
PaymentMethodData,
Plan,
PlansMap
} from '$lib/sdk/billing';
@@ -535,24 +534,6 @@ export function checkForMarkedForDeletion(org: Organization) {
}
}
export const paymentMissingMandate = writable<PaymentMethodData>(null);
export async function checkForMandate(org: Organization) {
const paymentId = org.paymentMethodId ?? org.backupPaymentMethodId;
if (!paymentId) return;
const paymentMethod = await sdk.forConsole.billing.getPaymentMethod(paymentId);
if (paymentMethod?.mandateId === null && paymentMethod?.country.toLowerCase() === 'in') {
headerAlert.add({
id: 'paymentMandate',
component: PaymentMandate,
show: true,
importance: 8
});
activeHeaderAlert.set(headerAlert.get());
paymentMissingMandate.set(paymentMethod);
}
}
export async function checkForMissingPaymentMethod() {
const orgs = await sdk.forConsole.billing.listOrganization([
Query.notEqual('billingPlan', BillingPlan.FREE),
-3
View File
@@ -15,7 +15,6 @@
import {
calculateTrialDay,
checkForEnterpriseTrial,
checkForMandate,
checkForMarkedForDeletion,
checkForMissingPaymentMethod,
checkForNewDevUpgradePro,
@@ -308,7 +307,6 @@
if (org?.billingPlan !== BillingPlan.FREE) {
await paymentExpired(org);
await checkPaymentAuthorizationRequired(org);
await checkForMandate(org);
if ($plansInfo.get(org.billingPlan)?.trialDays) {
calculateTrialDay(org);
@@ -334,7 +332,6 @@
<CommandCenter />
<Shell
showSideNavigation={page.url.pathname !== '/' &&
!page.url.pathname.includes(base + '/account') &&
!page.url.pathname.includes(base + '/card') &&
!page.url.pathname.includes(base + '/onboarding')}
showHeader={!page.url.pathname.includes(base + '/onboarding/create-project')}
@@ -5,7 +5,6 @@
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { identities } from './store';
import { toLocaleDateTime } from '$lib/helpers/date';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { oAuthProviders } from '$lib/stores/oauth-providers';
@@ -82,7 +81,13 @@
<DualTimeView time={identity.$createdAt} />
</Table.Cell>
<Table.Cell column="expiryDate" {root}>
{toLocaleDateTime(identity.providerAccessTokenExpiry)}
{#if identity.providerAccessTokenExpiry}
<DualTimeView
time={identity.providerAccessTokenExpiry}
showDatetime />
{:else}
-
{/if}
</Table.Cell>
<Table.Cell column="actions" {root}>
<Button text on:click={() => deleteIdentity(identity.$id)}>
@@ -9,13 +9,27 @@
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Alert } from '@appwrite.io/pink-svelte';
export let show = false;
export let selectedPaymentMethod: PaymentMethodData;
export let isLinked = false;
let {
show = $bindable(false),
isLinked = false,
selectedPaymentMethod
}: {
show: boolean;
isLinked?: boolean;
selectedPaymentMethod: PaymentMethodData;
} = $props();
let year: number | null = $state(null);
let month: string | null = $state(null);
let error: string | null = $state(null);
const currentYear = new Date().getFullYear();
let error: string;
let month: string;
let year: number;
const months = Array.from({ length: 12 }, (_, i) => {
const value = String(i + 1).padStart(2, '0');
return { value, label: value };
});
const options = $derived(createMonthOptions(year));
async function handleSubmit() {
try {
@@ -24,9 +38,9 @@
month,
year?.toString()
);
trackEvent(Submit.PaymentMethodUpdate);
invalidate(Dependencies.PAYMENT_METHODS);
show = false;
trackEvent(Submit.PaymentMethodUpdate);
await invalidate(Dependencies.PAYMENT_METHODS);
addNotification({
type: 'success',
message: 'Your payment method has been updated'
@@ -38,20 +52,6 @@
}
function createMonthOptions(year: number) {
const months = [
{ value: '01', label: '01' },
{ value: '02', label: '02' },
{ value: '03', label: '03' },
{ value: '04', label: '04' },
{ value: '05', label: '05' },
{ value: '06', label: '06' },
{ value: '07', label: '07' },
{ value: '08', label: '08' },
{ value: '09', label: '09' },
{ value: '10', label: '10' },
{ value: '11', label: '11' },
{ value: '12', label: '12' }
];
if (!year) return months;
if (year === currentYear) {
const currentMonth = new Date().getMonth() + 1;
@@ -60,8 +60,6 @@
return months;
}
}
$: options = createMonthOptions(year);
</script>
<Modal bind:error onSubmit={handleSubmit} bind:show title="Update payment method">
@@ -13,7 +13,14 @@
import type { PageData } from './$types';
import { addNotification } from '$lib/stores/notifications';
import { onMount } from 'svelte';
import { Badge, Layout, Table, Typography, Icon } from '@appwrite.io/pink-svelte';
import {
Badge,
Layout,
Table,
Typography,
Icon,
InteractiveText
} from '@appwrite.io/pink-svelte';
import { IconGlobeAlt } from '@appwrite.io/pink-icons-svelte';
export let data: PageData;
@@ -92,9 +99,9 @@
<Table.Root
let:root
columns={[
{ id: 'client' },
{ id: 'location', width: 200 },
{ id: 'ip', width: 200 },
{ id: 'client', width: { min: 450 } },
{ id: 'location', width: { min: 200 } },
{ id: 'ip', width: { min: 330 } },
{ id: 'actions', width: 100 }
]}>
<svelte:fragment slot="header" let:root>
@@ -146,7 +153,7 @@
{/if}
</Table.Cell>
<Table.Cell column="ip" {root}>
{session.ip}
<InteractiveText variant="copy" text={session.ip} isVisible />
</Table.Cell>
<Table.Cell column="actions" {root}>
<Button size="xs" secondary on:click={() => logout(session)}>Sign out</Button>
@@ -24,7 +24,7 @@ export const load: LayoutLoad = async ({ params, depends, parent }) => {
depends(Dependencies.MEMBERS);
depends(Dependencies.PAYMENT_METHODS);
const requestedOrg = await checkPlatformAndRedirect(params, organizations, prefs);
await checkPlatformAndRedirect(params, organizations, prefs);
let roles = isCloud ? [] : defaultRoles;
let scopes = isCloud ? [] : defaultScopes;
@@ -46,13 +46,8 @@ export const load: LayoutLoad = async ({ params, depends, parent }) => {
sdk.forConsole.account.updatePrefs({ prefs: newPrefs });
}
// fetch org only if we haven't already fetched it for platform check
const orgPromise: Promise<Organization> = requestedOrg
? Promise.resolve(requestedOrg)
: (sdk.forConsole.teams.get({ teamId: params.organization }) as Promise<Organization>);
const [org, members, countryList, locale] = await Promise.all([
orgPromise,
sdk.forConsole.teams.get({ teamId: params.organization }) as Promise<Organization>,
sdk.forConsole.teams.listMemberships({ teamId: params.organization }),
sdk.forConsole.locale.listCountries(),
sdk.forConsole.locale.get(),
@@ -96,7 +91,7 @@ async function checkPlatformAndRedirect(
params: { organization: string },
organizations: { teams: Array<{ $id: string; platform?: string }> },
prefs: Record<string, string>
): Promise<Organization | null> {
) {
// check if preloaded
let requestedOrg = organizations.teams.find((team) => team.$id === params.organization) as
| Organization
@@ -154,8 +149,4 @@ async function checkPlatformAndRedirect(
redirect(303, resolve('/(console)'));
}
}
// send the org,
// if already in the full list so we don't have to make another API request.
return requestedOrg;
}
@@ -71,12 +71,6 @@
return getServiceLimit('projects', null, data.currentPlan);
});
const projectsToArchive = $derived.by(() => {
return isCloud
? data.projects.projects.filter((project) => project.status === 'archived')
: [];
});
function filterPlatforms(platforms: { name: string; icon: string }[]) {
return platforms.filter(
(value, index, self) => index === self.findIndex((t) => t.name === value.name)
@@ -136,6 +130,19 @@
return project.status === 'archived';
}
const projectsToArchive = $derived(
(data.archivedProjectsPage ?? data.projects.projects).filter(
(project) => project.status === 'archived'
)
);
const activeTotalOverall = $derived(
data?.activeTotalOverall ??
data?.organization?.projects?.length ??
data?.projects?.total ??
0
);
function clearSearch() {
searchQuery?.clearInput();
}
@@ -238,7 +245,7 @@
{#if data.projects.total > 0}
<CardContainer
disableEmpty={!$canWriteProjects}
total={data.projects.total}
total={activeTotalOverall}
offset={data.offset}
on:click={handleCreateProject}>
{#each data.projects.projects as project}
@@ -323,13 +330,16 @@
name="Projects"
limit={data.limit}
offset={data.offset}
total={data.projects.total} />
total={activeTotalOverall} />
<!-- Archived Projects Section -->
<ArchiveProject
{projectsToArchive}
organization={data.organization}
currentPlan={$currentPlan} />
currentPlan={$currentPlan}
archivedTotalOverall={data.archivedTotalOverall}
archivedOffset={data.archivedOffset}
limit={data.limit} />
</Container>
<CreateOrganization bind:show={addOrganization} />
<CreateProject bind:show={showCreate} teamId={page.params.organization} />
@@ -5,7 +5,6 @@ import { CARD_LIMIT, Dependencies } from '$lib/constants';
import type { PageLoad } from './$types';
import { redirect } from '@sveltejs/kit';
import { base } from '$app/paths';
import { isCloud } from '$lib/system';
export const load: PageLoad = async ({ params, url, route, depends, parent }) => {
const { scopes } = await parent();
@@ -20,26 +19,69 @@ export const load: PageLoad = async ({ params, url, route, depends, parent }) =>
const offset = pageToOffset(page, limit);
const search = getSearch(url);
const projects = await sdk.forConsole.projects.list({
queries: [
Query.offset(offset),
Query.equal('teamId', params.organization),
Query.limit(limit),
Query.orderDesc(''),
Query.select(['$id', 'name', 'platforms', 'region', ...(isCloud ? ['status'] : [])])
],
search: search || undefined
});
const archivedPageRaw = parseInt(url.searchParams.get('archivedPage') || '1', 10);
const archivedPage =
Number.isFinite(archivedPageRaw) && archivedPageRaw > 0 ? archivedPageRaw : 1;
const archivedOffset = pageToOffset(archivedPage, limit);
const [activeProjects, archivedProjects, activeTotal, archivedTotal] = await Promise.all([
sdk.forConsole.projects.list({
queries: [
Query.offset(offset),
Query.equal('teamId', params.organization),
Query.or([Query.equal('status', 'active'), Query.isNull('status')]),
Query.limit(limit),
Query.orderDesc('')
],
search: search || undefined
}),
sdk.forConsole.projects.list({
queries: [
Query.offset(archivedOffset),
Query.equal('teamId', params.organization),
Query.equal('status', 'archived'),
Query.limit(limit),
Query.orderDesc('')
],
search: search || undefined
}),
sdk.forConsole.projects.list({
queries: [
Query.equal('teamId', params.organization),
Query.or([Query.equal('status', 'active'), Query.isNull('status')])
],
search: search || undefined
}),
sdk.forConsole.projects.list({
queries: [
Query.equal('teamId', params.organization),
Query.equal('status', 'archived')
],
search: search || undefined
})
]);
// set `default` if no region!
for (const project of projects.projects) {
for (const project of activeProjects.projects) {
project.region ??= 'default';
}
for (const project of archivedProjects.projects) {
project.region ??= 'default';
}
return {
offset,
limit,
projects,
projects: {
...activeProjects,
projects: activeProjects.projects,
total: activeTotal.total
},
activeProjectsPage: activeProjects.projects,
archivedProjectsPage: archivedProjects.projects,
activeTotalOverall: activeTotal.total,
archivedTotalOverall: archivedTotal.total,
archivedOffset,
archivedPage,
search
};
};
@@ -9,7 +9,6 @@
import PaymentHistory from './paymentHistory.svelte';
import TaxId from './taxId.svelte';
import { failedInvoice, tierToPlan, upgradeURL, useNewPricingModal } from '$lib/stores/billing';
import type { PaymentMethodData } from '$lib/sdk/billing';
import { onMount } from 'svelte';
import { page } from '$app/state';
import { confirmPayment } from '$lib/stores/stripe';
@@ -21,22 +20,15 @@
import { Alert } from '@appwrite.io/pink-svelte';
import { goto, invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { base } from '$app/paths';
import type { PageData } from './$types';
import { resolve } from '$app/paths';
export let data: PageData;
// Reactive statement to update organization when data changes
$: organization = data.organization;
// why are these reactive?
$: defaultPaymentMethod = data?.paymentMethods?.paymentMethods?.find(
(method: PaymentMethodData) => method.$id === organization?.paymentMethodId
);
$: backupPaymentMethod = data?.paymentMethods?.paymentMethods?.find(
(method: PaymentMethodData) => method.$id === organization?.backupPaymentMethodId
);
$: baseUrl = resolve('/(console)/organization-[organization]/billing', {
organization: organization.$id
});
onMount(async () => {
if (page.url.searchParams.has('type')) {
@@ -58,7 +50,7 @@
organization.$id,
invoice.clientSecret,
organization.paymentMethodId,
`${base}/organization-${organization.$id}/billing?type=validate-invoice&invoice=${invoice.$id}`
`${baseUrl}?type=validate-invoice&invoice=${invoice.$id}`
);
}
@@ -115,7 +107,7 @@
</Alert.Inline>
{/if}
{/if}
{#if defaultPaymentMethod?.failed && !backupPaymentMethod}
{#if data.primaryPaymentMethod?.failed && !data.backupPaymentMethod}
<Alert.Inline
status="error"
title={`The default payment method for ${organization.name} has expired`}>
@@ -146,7 +138,13 @@
currentInvoice={data?.billingInvoice} />
{/if}
<PaymentHistory />
<PaymentMethods organization={data?.organization} methods={data?.paymentMethods} />
<PaymentMethods
methods={data?.paymentMethods}
organization={data?.organization}
backupMethod={data.backupPaymentMethod}
primaryMethod={data.primaryPaymentMethod} />
<BillingAddress
organization={data?.organization}
billingAddress={data?.billingAddress}
@@ -1,11 +1,12 @@
import { BillingPlan, DEFAULT_BILLING_PROJECTS_LIMIT, Dependencies } from '$lib/constants';
import type { Address } from '$lib/sdk/billing';
import type { Address, PaymentList } from '$lib/sdk/billing';
import { type Organization } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
import { isCloud } from '$lib/system';
import { base } from '$app/paths';
import { type PaymentMethodData } from '$lib/sdk/billing';
import { getLimit, getPage, pageToOffset } from '$lib/helpers/load';
@@ -21,7 +22,7 @@ export const load: PageLoad = async ({ parent, depends, url, route }) => {
depends(Dependencies.CREDIT);
depends(Dependencies.INVOICES);
depends(Dependencies.ADDRESS);
//aggregation reloads on page param changes
// aggregation reloads on page param changes
depends(Dependencies.BILLING_AGGREGATION);
const billingAddressId = (organization as Organization)?.billingAddressId;
@@ -82,6 +83,7 @@ export const load: PageLoad = async ({ parent, depends, url, route }) => {
// make number
const credits = availableCredit ? availableCredit.available : null;
const { backup, primary } = getOrganizationPaymentMethods(organization, paymentMethods);
return {
paymentMethods,
@@ -98,6 +100,32 @@ export const load: PageLoad = async ({ parent, depends, url, route }) => {
offset: pageToOffset(
getPage(url) || 1,
getLimit(url, route, DEFAULT_BILLING_PROJECTS_LIMIT)
)
),
backupPaymentMethod: backup,
primaryPaymentMethod: primary
};
};
function getOrganizationPaymentMethods(
organization: Organization,
paymentMethods: PaymentList
): {
backup: PaymentMethodData | null;
primary: PaymentMethodData | null;
} {
let backup: PaymentMethodData | null = null;
let primary: PaymentMethodData | null = null;
for (const paymentMethod of paymentMethods.paymentMethods) {
if (paymentMethod.$id === organization.paymentMethodId) {
primary = paymentMethod;
} else if (paymentMethod.$id === organization.backupPaymentMethodId) {
backup = paymentMethod;
}
if (primary && backup) break;
}
return { primary, backup };
}
@@ -25,7 +25,7 @@
message: `The payment method has been removed from ${$organization.name}`
});
trackEvent(Submit.OrganizationPaymentDelete);
invalidate(Dependencies.ORGANIZATION);
await invalidate(Dependencies.PAYMENT_METHODS);
showDelete = false;
} catch (e) {
error = e.message;
@@ -34,7 +34,7 @@
showDelete = false;
}
}
async function removeBackuptMethod() {
async function removeBackupMethod() {
if ($organization?.billingPlan !== BillingPlan.FREE && !hasOtherMethod) return;
showDelete = false;
@@ -45,7 +45,7 @@
message: `The payment method has been removed from ${$organization.name}`
});
trackEvent(Submit.OrganizationBackupPaymentDelete);
invalidate(Dependencies.ORGANIZATION);
await invalidate(Dependencies.PAYMENT_METHODS);
showDelete = false;
} catch (e) {
error = e.message;
@@ -65,10 +65,10 @@
</Confirm>
{:else}
<Confirm
onSubmit={isBackup ? removeBackuptMethod : removeDefaultMethod}
title="Remove payment method"
bind:error
bind:open={showDelete}
bind:error>
title="Remove payment method"
onSubmit={isBackup ? removeBackupMethod : removeDefaultMethod}>
<Typography.Text>
Are you sure you want to remove the payment method from <b>{$organization?.name}</b>?
</Typography.Text>
@@ -34,16 +34,17 @@
IconTrash
} from '@appwrite.io/pink-icons-svelte';
export let organization: Organization;
export let methods: PaymentList;
export let organization: Organization;
export let backupMethod: PaymentMethodData;
export let primaryMethod: PaymentMethodData;
let showPayment = false;
let showEdit = false;
let showDelete = false;
let showReplace = false;
let isSelectedBackup = false;
let backupPaymentMethod: PaymentMethodData;
let defaultPaymentMethod: PaymentMethodData;
async function addPaymentMethod(paymentMethodId: string) {
try {
@@ -56,7 +57,7 @@
message: `A new payment method has been added to ${organization.name}`
});
trackEvent(Submit.OrganizationPaymentUpdate);
invalidate(Dependencies.ORGANIZATION);
await invalidate(Dependencies.PAYMENT_METHODS);
} catch (error) {
addNotification({
type: 'error',
@@ -76,7 +77,7 @@
type: 'success',
message: `A new payment method has been added to ${organization.name}`
});
invalidate(Dependencies.ORGANIZATION);
await invalidate(Dependencies.PAYMENT_METHODS);
} catch (error) {
addNotification({
type: 'error',
@@ -86,27 +87,15 @@
}
}
$: if (organization?.backupPaymentMethodId) {
sdk.forConsole.billing
.getOrganizationPaymentMethod(organization.$id, organization.backupPaymentMethodId)
.then((res) => (backupPaymentMethod = res));
}
$: if (organization?.paymentMethodId) {
sdk.forConsole.billing
.getOrganizationPaymentMethod(organization.$id, organization.paymentMethodId)
.then((res) => (defaultPaymentMethod = res));
}
$: if (!showReplace) {
isSelectedBackup = false;
}
$: hasPaymentError =
defaultPaymentMethod?.lastError ||
defaultPaymentMethod?.expired ||
backupPaymentMethod?.lastError ||
backupPaymentMethod?.expired;
primaryMethod?.lastError ||
primaryMethod?.expired ||
backupMethod?.lastError ||
backupMethod?.expired;
</script>
<CardGrid overflow={false}>
@@ -132,14 +121,14 @@
</svelte:fragment>
<Table.Row.Base {root}>
<CreditCardInfo {root} paymentMethod={defaultPaymentMethod} />
<CreditCardInfo {root} paymentMethod={primaryMethod} />
<Table.Cell column="actions" {root}>
<Popover let:toggle placement="bottom-start" padding="none">
<Button text icon ariaLabel="more options" on:click={toggle}>
<Icon icon={IconDotsHorizontal} size="s" />
</Button>
<ActionMenu.Root slot="tooltip">
{#if defaultPaymentMethod?.userId === $user?.$id}
{#if primaryMethod?.userId === $user?.$id}
<ActionMenu.Item.Button
leadingIcon={IconPencil}
on:click={() => {
@@ -171,14 +160,14 @@
</Table.Row.Base>
{#if organization?.backupPaymentMethodId}
<Table.Row.Base {root}>
<CreditCardInfo {root} isBackup paymentMethod={backupPaymentMethod} />
<CreditCardInfo {root} isBackup paymentMethod={backupMethod} />
<Table.Cell column="actions" {root}>
<Popover let:toggle placement="bottom-start" padding="none">
<Button text icon ariaLabel="more options" on:click={toggle}>
<Icon icon={IconDotsHorizontal} size="s" />
</Button>
<ActionMenu.Root slot="tooltip">
{#if backupPaymentMethod?.userId === $user?.$id}
{#if backupMethod?.userId === $user?.$id}
<ActionMenu.Item.Button
leadingIcon={IconPencil}
on:click={() => {
@@ -318,8 +307,8 @@
{/if}
{#if showEdit && isCloud && hasStripePublicKey}
<EditPaymentModal
selectedPaymentMethod={isSelectedBackup ? backupPaymentMethod : defaultPaymentMethod}
bind:show={showEdit} />
bind:show={showEdit}
selectedPaymentMethod={isSelectedBackup ? backupMethod : primaryMethod} />
{/if}
{#if isCloud && hasStripePublicKey}
<ReplaceCard {organization} {methods} bind:show={showReplace} isBackup={isSelectedBackup} />
@@ -13,17 +13,29 @@
import { PaymentBoxes } from '$lib/components/billing';
import type { PaymentMethod } from '@stripe/stripe-js';
export let organization: Organization;
export let show = false;
export let isBackup = false;
export let methods: PaymentList;
let {
show = $bindable(false),
isBackup = false,
methods,
organization
}: {
show?: boolean;
isBackup?: boolean;
methods: PaymentList;
organization: Organization;
} = $props();
let name: string;
let error: string = null;
let selectedPaymentMethodId: string;
let showState: boolean = false;
let state: string = '';
let paymentMethod: PaymentMethod | null = null;
let name: string | null = $state(null);
let error: string | null = $state(null);
let showState: boolean = $state(false);
let countryState: string | null = $state(null);
let paymentMethod: PaymentMethod | null = $state(null);
let selectedPaymentMethodId: string | null = $state(null);
const filteredMethods = $derived(methods?.paymentMethods.filter((method) => !!method?.last4));
const submitEvent = $derived(
isBackup ? Submit.OrganizationBackupPaymentUpdate : Submit.OrganizationPaymentUpdate
);
onMount(async () => {
if (!organization.paymentMethodId && !organization.backupPaymentMethodId) {
@@ -45,12 +57,12 @@
async function handleSubmit() {
try {
if (selectedPaymentMethodId === '$new') {
if (showState && !state) {
if (showState && !countryState) {
throw Error('Please select a state');
}
let method: PaymentMethodData;
if (showState) {
method = await setPaymentMethod(paymentMethod.id, name, state);
method = await setPaymentMethod(paymentMethod.id, name, countryState);
} else {
const card = await submitStripeCard(name, organization.$id);
if (card && Object.hasOwn(card, 'id')) {
@@ -69,21 +81,17 @@
? await addBackupPaymentMethod(selectedPaymentMethodId)
: await addPaymentMethod(selectedPaymentMethodId);
await invalidate(Dependencies.PAYMENT_METHODS);
addNotification({
type: 'success',
message: `Your ${isBackup ? 'backup' : 'default'} payment method has been updated`
});
invalidate(Dependencies.ORGANIZATION);
trackEvent(
isBackup ? Submit.OrganizationBackupPaymentDelete : Submit.OrganizationPaymentDelete
);
trackEvent(submitEvent);
show = false;
} catch (e) {
error = e.message;
trackError(
e,
isBackup ? Submit.OrganizationBackupPaymentDelete : Submit.OrganizationPaymentDelete
);
} catch (err) {
error = err.message;
trackError(err, submitEvent);
}
}
@@ -93,8 +101,8 @@
organization.$id,
paymentMethodId
);
} catch (e) {
error = e.message;
} catch (err) {
error = err.message;
}
}
@@ -104,23 +112,21 @@
organization.$id,
paymentMethodId
);
} catch (e) {
error = e.message;
} catch (err) {
error = err.message;
}
}
$: filteredMethods = methods?.paymentMethods.filter((method) => !!method?.last4);
</script>
<FakeModal bind:show bind:error onSubmit={handleSubmit} size="big" title="Replace payment method">
<p class="text">Replace the existing payment method for your organization.</p>
<PaymentBoxes
methods={filteredMethods}
bind:name
bind:paymentMethod
bind:showState
bind:state
bind:paymentMethod
methods={filteredMethods}
bind:state={countryState}
bind:group={selectedPaymentMethodId}
defaultMethod={organization?.paymentMethodId}
backupMethod={organization?.backupPaymentMethodId}
@@ -9,6 +9,7 @@
import {
AvatarInitials,
Copy,
type DeleteOperation,
type DeleteOperationState,
Empty,
EmptySearch,
@@ -60,20 +61,22 @@
);
}
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((userId) => {
return sdk.forProject(page.params.region, page.params.project).users.delete({ userId });
});
async function handleDelete(batchDelete: DeleteOperation): Promise<DeleteOperationState> {
const result = await batchDelete((userId) =>
sdk.forProject(page.params.region, page.params.project).users.delete({ userId })
);
try {
await Promise.all(promises);
trackEvent(Submit.UserDelete, { total: selectedRows.length });
} catch (error) {
trackError(error, Submit.UserDelete);
return error;
if (result.error) {
trackError(result.error, Submit.UserDelete);
} else {
trackEvent(Submit.UserDelete, { total: result.deleted.length });
}
} finally {
await invalidate(Dependencies.USERS);
}
return result;
}
</script>
@@ -1,26 +1,23 @@
<script lang="ts">
import { Container } from '$lib/layout';
import type { PageProps } from './$types';
import UpdateMockNumbers from './updateMockNumbers.svelte';
import UpdatePasswordDictionary from './updatePasswordDictionary.svelte';
import UpdatePasswordHistory from './updatePasswordHistory.svelte';
import UpdatePersonalDataCheck from './updatePersonalDataCheck.svelte';
import UpdateSessionAlerts from './updateSessionAlerts.svelte';
import UpdateSessionLength from './updateSessionLength.svelte';
import UpdateSessionsLimit from './updateSessionsLimit.svelte';
import UpdateMembershipPrivacy from './updateMembershipPrivacy.svelte';
import UpdateUsersLimit from './updateUsersLimit.svelte';
import UpdateSessionInvalidation from './updateSessionInvalidation.svelte';
import UpdateSessionLength from './updateSessionLength.svelte';
import UpdateSessionsLimit from './updateSessionsLimit.svelte';
import PasswordPolicies from './passwordPolicies.svelte';
import SessionSecurity from './sessionSecurity.svelte';
let { data }: PageProps = $props();
</script>
<Container>
<UpdateUsersLimit />
<UpdateSessionLength />
<UpdateSessionsLimit />
<UpdatePasswordHistory />
<UpdatePasswordDictionary />
<UpdatePersonalDataCheck />
<UpdateSessionAlerts />
<UpdateSessionInvalidation />
<PasswordPolicies project={data.project} />
<SessionSecurity project={data.project} />
<UpdateMockNumbers />
<UpdateMembershipPrivacy />
</Container>
@@ -0,0 +1,159 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { CardGrid } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button, Form, InputNumber, InputSwitch } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { Typography, Link, Layout } from '@appwrite.io/pink-svelte';
import type { Models } from '@appwrite.io/console';
import { onMount } from 'svelte';
let {
project
}: {
project: Models.Project;
} = $props();
let lastValidLimit = $state(5);
let passwordHistory = $state(5);
let passwordDictionary = $state(false);
let passwordHistoryEnabled = $state(false);
let authPersonalDataCheck = $state(false);
onMount(() => {
// update initial states here in onMount.
const historyValue = project.authPasswordHistory;
if (historyValue && historyValue > 0) {
passwordHistory = historyValue;
lastValidLimit = historyValue;
}
passwordHistoryEnabled = (historyValue ?? 0) !== 0;
passwordDictionary = project.authPasswordDictionary ?? false;
authPersonalDataCheck = project.authPersonalDataCheck ?? false;
});
$effect(() => {
// restore last valid limit when enabling
if (passwordHistoryEnabled && passwordHistory < 1) {
passwordHistory = lastValidLimit;
}
});
const hasChanges = $derived.by(() => {
const dictChanged = passwordDictionary !== (project.authPasswordDictionary ?? false);
const dataCheckChanged = authPersonalDataCheck !== (project.authPersonalDataCheck ?? false);
const historyChanged =
passwordHistoryEnabled !== ((project.authPasswordHistory ?? 0) !== 0);
const limitChanged =
passwordHistoryEnabled &&
Number(passwordHistory) !== (project.authPasswordHistory ?? 0);
return historyChanged || dictChanged || dataCheckChanged || limitChanged;
});
async function updatePasswordPolicies() {
try {
const projectSdk = sdk.forConsole.projects;
await projectSdk.updateAuthPasswordHistory({
projectId: project.$id,
limit: passwordHistoryEnabled ? passwordHistory : 0
});
await projectSdk.updateAuthPasswordDictionary({
projectId: project.$id,
enabled: passwordDictionary
});
await projectSdk.updatePersonalDataCheck({
projectId: project.$id,
enabled: authPersonalDataCheck
});
await invalidate(Dependencies.PROJECT);
addNotification({
type: 'success',
message: 'Updated password policies.'
});
trackEvent(Submit.AuthPasswordHistoryUpdate);
trackEvent(Submit.AuthPasswordDictionaryUpdate);
trackEvent(Submit.AuthPersonalDataCheckUpdate);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.AuthPasswordHistoryUpdate);
}
}
</script>
<Form onSubmit={updatePasswordPolicies}>
<CardGrid gap="xxl">
<svelte:fragment slot="title">Password policies</svelte:fragment>
<svelte:fragment slot="aside">
<InputSwitch
bind:value={passwordHistoryEnabled}
id="passwordHistoryEnabled"
label="Password history">
<svelte:fragment slot="description">
<Layout.Stack gap="m">
<Typography.Text>
Enabling this option prevents users from reusing recent passwords by
comparing the new password with their password history.
</Typography.Text>
{#if passwordHistoryEnabled}
<InputNumber
required
max={20}
min={1}
autofocus
label="Limit"
id="password-history"
bind:value={passwordHistory}
helper="Maximum 20 passwords." />
{/if}
</Layout.Stack>
</svelte:fragment>
</InputSwitch>
<InputSwitch
bind:value={passwordDictionary}
id="passwordDictionary"
label="Password dictionary">
<svelte:fragment slot="description">
<Typography.Text>
Enabling this option prevents users from setting insecure passwords by
comparing the user's password with the <Link.Anchor
target="_blank"
rel="noopener noreferrer"
class="link"
href="https://github.com/danielmiessler/SecLists/blob/master/Passwords/Common-Credentials/10k-most-common.txt"
>10k most commonly used passwords.</Link.Anchor>
</Typography.Text>
</svelte:fragment>
</InputSwitch>
<InputSwitch
bind:value={authPersonalDataCheck}
id="personalDataCheck"
label="Disallow personal data">
<svelte:fragment slot="description">
<Typography.Text>
Do not allow passwords that contain any part of the user's personal data.
This includes the user's <Typography.Code>name</Typography.Code>, <Typography.Code
>email</Typography.Code
>, or <Typography.Code>phone</Typography.Code>.
</Typography.Text>
</svelte:fragment>
</InputSwitch>
</svelte:fragment>
<svelte:fragment slot="actions">
<Button disabled={!hasChanges} submit>Update</Button>
</svelte:fragment>
</CardGrid>
</Form>
@@ -0,0 +1,92 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { CardGrid } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button, Form, InputSwitch } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { Typography } from '@appwrite.io/pink-svelte';
import type { Models } from '@appwrite.io/console';
import { onMount } from 'svelte';
let { project }: { project: Models.Project } = $props();
let authSessionAlerts = $state(false);
let sessionInvalidation = $state(false);
onMount(() => {
authSessionAlerts = project?.authSessionAlerts ?? false;
sessionInvalidation = project?.authInvalidateSessions ?? false;
});
const hasChanges = $derived.by(() => {
const alertsChanged = authSessionAlerts !== (project?.authSessionAlerts ?? false);
const invalidationChanged =
sessionInvalidation !== (project?.authInvalidateSessions ?? false);
return alertsChanged || invalidationChanged;
});
async function updateSessionSecurity() {
try {
await sdk.forConsole.projects.updateSessionAlerts({
projectId: project.$id,
alerts: authSessionAlerts
});
await sdk.forConsole.projects.updateSessionInvalidation({
projectId: project.$id,
enabled: sessionInvalidation
});
await invalidate(Dependencies.PROJECT);
addNotification({
type: 'success',
message: 'Updated session security settings.'
});
trackEvent(Submit.AuthSessionAlertsUpdate);
trackEvent(Submit.AuthInvalidateSession);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.AuthSessionAlertsUpdate);
}
}
</script>
<Form onSubmit={updateSessionSecurity}>
<CardGrid gap="xxl">
<svelte:fragment slot="title">Session security</svelte:fragment>
<svelte:fragment slot="aside">
<InputSwitch
bind:value={authSessionAlerts}
id="authSessionAlerts"
label="Session alerts">
<svelte:fragment slot="description">
<Typography.Text>
Enabling this option will send an email to the users when a new session is
created.
</Typography.Text>
</svelte:fragment>
</InputSwitch>
<InputSwitch
bind:value={sessionInvalidation}
id="invalidateSessions"
label="Invalidate sessions">
<svelte:fragment slot="description">
<Typography.Text>
Enabling this option will clear all existing sessions when the user changes
their password.
</Typography.Text>
</svelte:fragment>
</InputSwitch>
</svelte:fragment>
<svelte:fragment slot="actions">
<Button disabled={!hasChanges} submit>Update</Button>
</svelte:fragment>
</CardGrid>
</Form>
@@ -1,61 +0,0 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { CardGrid } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button, Form, InputSwitch } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { Typography, Link } from '@appwrite.io/pink-svelte';
import { project } from '../../store';
let passwordDictionary = $project?.authPasswordDictionary ?? false;
async function updatePasswordDictionary() {
try {
await sdk.forConsole.projects.updateAuthPasswordDictionary({
projectId: $project.$id,
enabled: passwordDictionary
});
await invalidate(Dependencies.PROJECT);
addNotification({
type: 'success',
message: 'Updated password dictionary check.'
});
trackEvent(Submit.AuthPasswordDictionaryUpdate);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.AuthPasswordDictionaryUpdate);
}
}
</script>
<Form onSubmit={updatePasswordDictionary}>
<CardGrid>
<svelte:fragment slot="title">Password dictionary</svelte:fragment>
<svelte:fragment slot="aside">
<InputSwitch
bind:value={passwordDictionary}
id="passwordDictionary"
label="Password dictionary" />
<Typography.Text>
Enabling this option prevent users from setting insecure passwords by comparing the
user's password with the <Link.Anchor
target="_blank"
rel="noopener noreferrer"
class="link"
href="https://github.com/danielmiessler/SecLists/blob/master/Passwords/Common-Credentials/10k-most-common.txt"
>10k most commonly used passwords.</Link.Anchor>
</Typography.Text>
</svelte:fragment>
<svelte:fragment slot="actions">
<Button disabled={passwordDictionary === $project?.authPasswordDictionary} submit>
Update
</Button>
</svelte:fragment>
</CardGrid>
</Form>
@@ -1,80 +0,0 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { CardGrid } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button, Form, InputNumber, InputSwitch } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { Typography } from '@appwrite.io/pink-svelte';
import { project } from '../../store';
import { tick } from 'svelte';
let passwordHistory = $project?.authPasswordHistory < 1 ? 5 : $project?.authPasswordHistory;
let passwordHistoryEnabled = ($project?.authPasswordHistory ?? 0) !== 0;
let initialPasswordHistoryEnabled = passwordHistoryEnabled;
async function updatePasswordHistoryLimit() {
try {
await sdk.forConsole.projects.updateAuthPasswordHistory({
projectId: $project.$id,
limit: passwordHistoryEnabled ? passwordHistory : 0
});
await invalidate(Dependencies.PROJECT);
initialPasswordHistoryEnabled = passwordHistoryEnabled;
addNotification({
type: 'success',
message: 'Updated password history limit.'
});
trackEvent(Submit.AuthPasswordHistoryUpdate);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.AuthPasswordHistoryUpdate);
}
}
let maxPasswordInputField: InputNumber | null = null;
$: if (passwordHistoryEnabled && maxPasswordInputField) {
tick().then(() => {
maxPasswordInputField.addInputFocus();
});
}
</script>
<Form onSubmit={updatePasswordHistoryLimit}>
<CardGrid>
<svelte:fragment slot="title">Password history</svelte:fragment>
Set the maximum number of passwords saved per user.
<svelte:fragment slot="aside">
<InputSwitch
bind:value={passwordHistoryEnabled}
id="passwordHistoryEnabled"
label="Password history" />
<Typography.Text>
Enabling this option prevents users from reusing recent passwords by comparing the
new password with their password history.
</Typography.Text>
<InputNumber
required
max={20}
min={1}
id="password-history"
label="Limit"
disabled={!passwordHistoryEnabled}
bind:value={passwordHistory}
helper="Maximum 20 passwords." />
</svelte:fragment>
<svelte:fragment slot="actions">
<Button
disabled={(passwordHistory === $project?.authPasswordHistory ||
$project?.authPasswordHistory === 0) &&
initialPasswordHistoryEnabled === passwordHistoryEnabled}
submit>Update</Button>
</svelte:fragment>
</CardGrid>
</Form>
@@ -1,56 +0,0 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { CardGrid } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button, Form, InputSwitch } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { Typography } from '@appwrite.io/pink-svelte';
import { project } from '../../store';
let authPersonalDataCheck = $project?.authPersonalDataCheck ?? false;
async function updatePersonalDataCheck() {
try {
await sdk.forConsole.projects.updatePersonalDataCheck({
projectId: $project.$id,
enabled: authPersonalDataCheck
});
await invalidate(Dependencies.PROJECT);
addNotification({
type: 'success',
message: 'Toggled personal data checks for passwords'
});
trackEvent(Submit.AuthPersonalDataCheckUpdate);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.AuthPersonalDataCheckUpdate);
}
}
</script>
<Form onSubmit={updatePersonalDataCheck}>
<CardGrid>
<svelte:fragment slot="title">Personal data</svelte:fragment>
<svelte:fragment slot="aside">
<InputSwitch
bind:value={authPersonalDataCheck}
id="personalDataCheck"
label="Disallow personal data" />
<Typography.Text>
Do not allow passwords that contain any part of the user's personal data. This
includes the user's <Typography.Code>name</Typography.Code>, <Typography.Code
>email</Typography.Code
>, or <Typography.Code>phone</Typography.Code>.
</Typography.Text>
</svelte:fragment>
<svelte:fragment slot="actions">
<Button disabled={authPersonalDataCheck === $project?.authPersonalDataCheck} submit
>Update</Button>
</svelte:fragment>
</CardGrid>
</Form>
@@ -1,54 +0,0 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { CardGrid } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button, Form, InputSwitch } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { Typography } from '@appwrite.io/pink-svelte';
import { project } from '../../store';
let authSessionAlerts = $project?.authSessionAlerts ?? false;
async function updateSessionAlerts() {
try {
await sdk.forConsole.projects.updateSessionAlerts({
projectId: $project.$id,
alerts: authSessionAlerts
});
await invalidate(Dependencies.PROJECT);
addNotification({
type: 'success',
message: 'Updated session alerts.'
});
trackEvent(Submit.AuthSessionAlertsUpdate);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.AuthSessionAlertsUpdate);
}
}
</script>
<Form onSubmit={updateSessionAlerts}>
<CardGrid>
<svelte:fragment slot="title">Session alerts</svelte:fragment>
<svelte:fragment slot="aside">
<InputSwitch
bind:value={authSessionAlerts}
id="authSessionAlerts"
label="Session alerts" />
<Typography.Text>
Enabling this option will send an email to the users when a new session is created.
</Typography.Text>
</svelte:fragment>
<svelte:fragment slot="actions">
<Button disabled={authSessionAlerts === $project?.authSessionAlerts} submit>
Update
</Button>
</svelte:fragment>
</CardGrid>
</Form>
@@ -1,56 +0,0 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { CardGrid } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button, Form, InputSwitch } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { Typography } from '@appwrite.io/pink-svelte';
import { project } from '../../store';
let sessionInvalidation = $project?.authInvalidateSessions ?? false;
async function updateSessionInvalidation() {
try {
await sdk.forConsole.projects.updateSessionInvalidation({
projectId: $project.$id,
enabled: sessionInvalidation
});
await invalidate(Dependencies.PROJECT);
addNotification({
type: 'success',
message: 'Updated session invalidation check.'
});
trackEvent(Submit.AuthInvalidateSesssion);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.AuthInvalidateSesssion);
}
}
</script>
<Form onSubmit={updateSessionInvalidation}>
<CardGrid>
<svelte:fragment slot="title">Invalidate sessions</svelte:fragment>
<svelte:fragment slot="aside">
<InputSwitch
bind:value={sessionInvalidation}
id="invalidateSessions"
label="Invalidate sessions" />
<Typography.Text>
Enabling this option will clear all existing sessions when the user changes their
password.
</Typography.Text>
</svelte:fragment>
<svelte:fragment slot="actions">
<Button disabled={sessionInvalidation === $project?.authInvalidateSessions} submit>
Update
</Button>
</svelte:fragment>
</CardGrid>
</Form>
@@ -12,6 +12,7 @@
SearchQuery,
PaginationWithLimit,
type DeleteOperationState,
type DeleteOperation,
MultiSelectionTable
} from '$lib/components';
import Create from '../createTeam.svelte';
@@ -44,20 +45,22 @@
);
};
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((teamId) => {
return sdk.forProject(page.params.region, page.params.project).teams.delete({ teamId });
});
async function handleDelete(batchDelete: DeleteOperation): Promise<DeleteOperationState> {
const result = await batchDelete((teamId) =>
sdk.forProject(page.params.region, page.params.project).teams.delete({ teamId })
);
try {
await Promise.all(promises);
trackEvent(Submit.TeamDelete, { total: selectedRows.length });
} catch (error) {
trackError(error, Submit.TeamDelete);
return error;
if (result.error) {
trackError(result.error, Submit.TeamDelete);
} else {
trackEvent(Submit.TeamDelete, { total: result.deleted.length });
}
} finally {
await invalidate(Dependencies.TEAMS);
}
return result;
}
</script>
@@ -6,6 +6,7 @@
AvatarInitials,
PaginationWithLimit,
MultiSelectionTable,
type DeleteOperation,
type DeleteOperationState
} from '$lib/components';
import { Button } from '$lib/elements/forms';
@@ -29,23 +30,25 @@
let showDelete = $state(false);
let selectedMembership: Models.Membership | null = $state(null);
async function handleBulkDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((membershipId) => {
return sdk.forProject(page.params.region, page.params.project).teams.deleteMembership({
async function handleBulkDelete(batchDelete: DeleteOperation): Promise<DeleteOperationState> {
const result = await batchDelete((membershipId) =>
sdk.forProject(page.params.region, page.params.project).teams.deleteMembership({
teamId: page.params.team,
membershipId
});
});
})
);
try {
await Promise.all(promises);
trackEvent(Submit.MembershipUpdate, { total: selectedRows.length });
} catch (error) {
trackError(error, Submit.MembershipUpdate);
return error;
if (result.error) {
trackError(result.error, Submit.MembershipUpdate);
} else {
trackEvent(Submit.MembershipUpdate, { total: result.deleted.length });
}
} finally {
await invalidate(Dependencies.MEMBERSHIPS);
}
return result;
}
</script>
@@ -1,5 +1,10 @@
<script lang="ts">
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import {
type DeleteOperationState,
type DeleteOperation,
Id,
MultiSelectionTable
} from '$lib/components';
import type { PageData } from './$types';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import { sdk } from '$lib/stores/sdk';
@@ -21,22 +26,24 @@
columns: Column[];
} = $props();
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((id) => {
return sdk
async function handleDelete(batchDelete: DeleteOperation): Promise<DeleteOperationState> {
const result = await batchDelete((id) =>
sdk
.forProject(page.params.region, page.params.project)
.users.deleteIdentity({ identityId: id });
});
.users.deleteIdentity({ identityId: id })
);
try {
await Promise.all(promises);
trackEvent(Submit.UserIdentityDelete, { total: selectedRows.length });
} catch (error) {
trackError(error, Submit.UserIdentityDelete);
return error;
if (result.error) {
trackError(result.error, Submit.UserIdentityDelete);
} else {
trackEvent(Submit.UserIdentityDelete, { total: result.deleted.length });
}
} finally {
await invalidate(Dependencies.USER_IDENTITIES);
}
return result;
}
</script>
@@ -4,6 +4,7 @@
import {
AvatarInitials,
type DeleteOperationState,
type DeleteOperation,
MultiSelectionTable
} from '$lib/components';
import { Button } from '$lib/elements/forms';
@@ -23,14 +24,14 @@
let showDelete = $state(false);
let selectedMembership: Models.Membership | null = $state(null);
async function handleBulkDelete(selectedRows: string[]): Promise<DeleteOperationState> {
async function handleBulkDelete(batchDelete: DeleteOperation): Promise<DeleteOperationState> {
// Precompute a lookup map from membershipId to teamId for efficient access
const membershipIdToTeamId: Record<string, string> = {};
for (const membership of data.memberships.memberships) {
membershipIdToTeamId[membership.$id] = membership.teamId;
}
const promises = selectedRows.map((membershipId) =>
const result = await batchDelete((membershipId) =>
sdk.forProject(page.params.region, page.params.project).teams.deleteMembership({
teamId: membershipIdToTeamId[membershipId] || '',
membershipId
@@ -38,14 +39,16 @@
);
try {
await Promise.all(promises);
trackEvent(Submit.MembershipUpdate, { total: selectedRows.length });
} catch (error) {
trackError(error, Submit.MembershipUpdate);
return error;
if (result.error) {
trackError(result.error, Submit.MembershipUpdate);
} else {
trackEvent(Submit.MembershipUpdate, { total: result.deleted.length });
}
} finally {
await invalidate(Dependencies.MEMBERSHIPS);
}
return result;
}
</script>
@@ -1,6 +1,13 @@
<script lang="ts">
import { EmptySearch, Trim } from '$lib/components';
import { Badge, Layout, Table, Typography, Icon } from '@appwrite.io/pink-svelte';
import {
Badge,
Layout,
Table,
Typography,
Icon,
InteractiveText
} from '@appwrite.io/pink-svelte';
import { IconGlobeAlt } from '@appwrite.io/pink-icons-svelte';
import { Button } from '$lib/elements/forms';
import { isValueOfStringEnum } from '$lib/helpers/types';
@@ -36,9 +43,9 @@
<Table.Root
let:root
columns={[
{ id: 'client' },
{ id: 'location', width: 200 },
{ id: 'ip', width: 200 },
{ id: 'client', width: { min: 450 } },
{ id: 'location', width: { min: 200 } },
{ id: 'ip', width: { min: 200 } },
{ id: 'actions', width: 100 }
]}>
<svelte:fragment slot="header" let:root>
@@ -84,7 +91,7 @@
{/if}
</Table.Cell>
<Table.Cell column="ip" {root}>
{session.ip}
<InteractiveText variant="copy" text={session.ip} isVisible />
</Table.Cell>
<Table.Cell column="actions" {root}>
<Button
@@ -1,5 +1,10 @@
<script lang="ts">
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import {
type DeleteOperationState,
type DeleteOperation,
Id,
MultiSelectionTable
} from '$lib/components';
import type { PageData } from './$types';
import { columns } from './store';
import DualTimeView from '$lib/components/dualTimeView.svelte';
@@ -19,22 +24,24 @@
data: PageData;
} = $props();
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((id) => {
return sdk
async function handleDelete(batchDelete: DeleteOperation): Promise<DeleteOperationState> {
const result = await batchDelete((id) =>
sdk
.forProject(page.params.region, page.params.project)
.users.deleteTarget({ userId: page.params.user, targetId: id });
});
.users.deleteTarget({ userId: page.params.user, targetId: id })
);
try {
await Promise.all(promises);
trackEvent(Submit.UserTargetDelete, { total: selectedRows.length });
} catch (error) {
trackError(error, Submit.UserTargetDelete);
return error;
if (result.error) {
trackError(result.error, Submit.UserTargetDelete);
} else {
trackEvent(Submit.UserTargetDelete, { total: result.deleted.length });
}
} finally {
await invalidate(Dependencies.USER_TARGETS);
}
return result;
}
</script>
@@ -14,9 +14,11 @@
import Table from './table.svelte';
import { registerCommands } from '$lib/commandCenter';
import { canWriteDatabases } from '$lib/stores/roles';
import { Icon } from '@appwrite.io/pink-svelte';
import { Icon, Tooltip } from '@appwrite.io/pink-svelte';
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
import EmptySearch from '$lib/components/emptySearch.svelte';
import { isServiceLimited } from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
export let data: PageData;
@@ -30,6 +32,8 @@
);
}
$: isLimited = isServiceLimited('databases', $organization?.billingPlan, data.databases.total);
$: $registerCommands([
{
label: 'Create database',
@@ -37,7 +41,7 @@
showCreate = true;
},
keys: ['c'],
disabled: showCreate || !$canWriteDatabases,
disabled: showCreate || !$canWriteDatabases || isLimited,
icon: IconPlus,
group: 'databases',
rank: 10
@@ -52,10 +56,20 @@
bind:view={data.view}
searchPlaceholder="Search by name or ID">
{#if $canWriteDatabases}
<Button event="create_database" on:click={() => (showCreate = true)}>
<Icon icon={IconPlus} slot="start" size="s" />
Create database
</Button>
<Tooltip disabled={!isLimited}>
<div>
<Button
disabled={isLimited}
event="create_database"
on:click={() => (showCreate = true)}>
<Icon icon={IconPlus} slot="start" size="s" />
Create database
</Button>
</div>
<svelte:fragment slot="tooltip">
You have reached the maximum number of databases for your plan.
</svelte:fragment>
</Tooltip>
{/if}
</ResponsiveContainerHeader>
@@ -3,6 +3,7 @@
Card,
Confirm,
type DeleteOperationState,
type DeleteOperation,
Modal,
MultiSelectionTable
} from '$lib/components';
@@ -18,7 +19,7 @@
import { columns } from './store';
import { database } from '../store';
import type { BackupArchive, BackupPolicy } from '$lib/sdk/backups';
import { Click, trackEvent } from '$lib/actions/analytics';
import { Click, Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { copy } from '$lib/helpers/copy';
import { LabelCard } from '$lib/components/index.js';
import DualTimeView from '$lib/components/dualTimeView.svelte';
@@ -110,39 +111,44 @@
}
}
async function deleteBackups(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((archiveId) => {
return sdk
async function deleteSingleBackup(archiveId: string) {
try {
await sdk
.forProject(page.params.region, page.params.project)
.backups.deleteArchive(archiveId);
});
addNotification({
type: 'success',
message: 'Backup deleted'
});
showDelete = false;
selectedBackup = null;
await invalidate(Dependencies.BACKUPS);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
}
}
async function deleteBackups(batchDelete: DeleteOperation): Promise<DeleteOperationState> {
const result = await batchDelete((archiveId) =>
sdk.forProject(page.params.region, page.params.project).backups.deleteArchive(archiveId)
);
try {
await Promise.all(promises);
if (selectedBackup) {
addNotification({
type: 'success',
message: '1 backup deleted'
});
}
} catch (error) {
if (selectedBackup) {
addNotification({
type: 'error',
message: error.message
});
if (result.error) {
trackError(result.error, Submit.DatabaseBackupDelete);
} else {
return error;
trackEvent(Submit.DatabaseBackupDelete);
}
} finally {
if (selectedBackup) {
showDelete = false;
selectedBackup = null;
}
await invalidate(Dependencies.BACKUPS);
}
return result;
}
async function restoreBackup() {
@@ -299,7 +305,7 @@
bind:open={showDelete}
onSubmit={async () => {
if (!selectedBackup) return;
await deleteBackups([selectedBackup.$id]);
await deleteSingleBackup(selectedBackup.$id);
}}>
<Typography.Text>
Are you sure you want to delete the <b>{getCleanBackupName(selectedBackup)}</b> backup?
@@ -86,7 +86,7 @@
label="Min"
placeholder="Enter size"
bind:value={data.min}
step={0.1}
step="any"
{disabled}
required={editing} />
@@ -95,7 +95,7 @@
label="Max"
placeholder="Enter size"
bind:value={data.max}
step={0.1}
step="any"
{disabled}
required={editing} />
</Layout.Stack>
@@ -109,7 +109,7 @@
bind:value={data.default}
disabled={data.required || data.array || disabled}
nullable={!data.required && !data.array}
step={0.1} />
step="any" />
<RequiredArrayCheckboxes
{editing}
@@ -31,5 +31,5 @@
min={column.min}
max={column.max}
required={column.required}
step={column.type === 'double' ? 0.1 : 1}
step={column.type === 'double' ? 'any' : 1}
leadingIcon={!limited ? IconHashtag : undefined} />
@@ -630,7 +630,6 @@
permissions: row.$permissions
});
invalidate(Dependencies.ROW);
trackEvent(Submit.RowUpdate);
addNotification({
message: 'Row has been updated',
@@ -1105,7 +1104,14 @@
<EditRowCell
{row}
column={rowColumn}
onRowStructureUpdate={updateRowContents}
onRowStructureUpdate={async (row) => {
const success = await updateRowContents(row);
if (success) {
// database update succeeded!
paginatedRows.update(index, row);
}
return success;
}}
noInlineEdit={isRelatedToMany && hasItems}
onChange={(row) => paginatedRows.update(index, row)}
onRevert={(row) => paginatedRows.update(index, row)}
@@ -193,7 +193,9 @@ export const expandTabs = writable(null);
export const spreadsheetRenderKey = writable('initial');
export const paginatedRowsLoading = writable(false);
export const paginatedRows = createSparsePagedDataStore<Models.DefaultRow>(SPREADSHEET_PAGE_LIMIT);
export const paginatedRows = createSparsePagedDataStore<Models.DefaultRow | Models.Row>(
SPREADSHEET_PAGE_LIMIT
);
export const PROHIBITED_ROW_KEYS = [
'$id',
@@ -3,7 +3,12 @@
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Id, MultiSelectionTable, type DeleteOperationState } from '$lib/components';
import {
Id,
MultiSelectionTable,
type DeleteOperation,
type DeleteOperationState
} from '$lib/components';
import { Dependencies } from '$lib/constants';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import { canWriteTables } from '$lib/stores/roles';
@@ -20,23 +25,26 @@
data: PageData;
} = $props();
async function onDelete(selectedTables: string[]): Promise<DeleteOperationState> {
const promises = selectedTables.map((tableId) =>
async function onDelete(batchDelete: DeleteOperation): Promise<DeleteOperationState> {
const result = await batchDelete((tableId) =>
sdk.forProject(page.params.region, page.params.project).tablesDB.deleteTable({
databaseId: page.params.database,
tableId
})
);
try {
await Promise.all(promises);
trackEvent(Submit.TableDelete);
} catch (error) {
trackError(error, Submit.TableDelete);
return error;
if (result.error) {
trackError(result.error, Submit.TableDelete);
} else {
trackEvent(Submit.TableDelete, { total: result.deleted.length });
}
} finally {
await invalidate(Dependencies.TABLES);
subNavigation.update();
}
return result;
}
function getTableHref(table: Models.Table) {
@@ -2,7 +2,7 @@
import { base } from '$app/paths';
import { page } from '$app/state';
import { Id } from '$lib/components';
import { toLocaleDateTime } from '$lib/helpers/date';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import { columns } from './store';
import { IconExclamation } from '@appwrite.io/pink-icons-svelte';
import { Layout, Tooltip, Table, Icon } from '@appwrite.io/pink-svelte';
@@ -72,8 +72,10 @@
{`Last backup: ${lastBackup}`}
</span>
</Tooltip>
{:else if column.type === 'datetime'}
<DualTimeView time={database[column.id]} showDatetime />
{:else}
{toLocaleDateTime(database[column.id])}
{database[column.id]}
{/if}
</Table.Cell>
{/each}
@@ -29,6 +29,7 @@ export const load: PageLoad = async ({ depends, params, url, route, parent }) =>
Query.limit(limit),
Query.offset(offset),
Query.orderDesc(''),
Query.orderDesc('$updatedAt'),
...parsedQueries.values()
],
search: search || undefined
@@ -15,5 +15,12 @@ export const columns = writable<Column[]>([
title: 'Target',
type: 'string',
width: { min: 120, max: 400 }
},
{
id: 'updated',
title: '',
type: 'string',
width: { min: 160, max: 180 }
}
]);
@@ -25,6 +25,7 @@
import { regionalProtocol } from '$routes/(console)/project-[region]-[project]/store';
import DnsRecordsAction from '$lib/components/domains/dnsRecordsAction.svelte';
import ViewLogsModal from '$lib/components/domains/viewLogsModal.svelte';
import { timeFromNowShort } from '$lib/helpers/date';
let {
proxyRules,
@@ -46,6 +47,28 @@
? 'Deployed from ' + proxy.deploymentVcsProviderBranch
: 'Active deployment';
};
function updatedLabel(proxyRule: Models.ProxyRule): string {
if (proxyRule.status === 'verified') {
return '';
}
const timeStr = timeFromNowShort(proxyRule.$updatedAt);
if (timeStr === 'n/a') {
return '';
}
const prefix =
proxyRule.status === 'created'
? 'Checked'
: proxyRule.status === 'verifying'
? 'Updated'
: proxyRule.status === 'unverified'
? 'Failed'
: '';
return prefix + ' ' + timeStr;
}
</script>
<Table.Root columns={[...$columns, { id: 'actions', width: 40 }]} let:root>
@@ -117,6 +140,15 @@
</Layout.Stack>
{:else if column.id === 'target'}
{proxyTarget(proxyRule)}
{:else if column.id === 'updated' && proxyRule.status !== 'verified'}
<Layout.Stack direction="row" justifyContent="flex-end">
<Typography.Text
variant="m-400"
color="--fgcolor-neutral-tertiary"
style="font-size: 0.875rem;">
{updatedLabel(proxyRule)}
</Typography.Text>
</Layout.Stack>
{/if}
</Table.Cell>
{/each}
@@ -1,5 +1,10 @@
<script lang="ts">
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import {
type DeleteOperationState,
type DeleteOperation,
Id,
MultiSelectionTable
} from '$lib/components';
import { toLocaleDateTime } from '$lib/helpers/date';
import type { Column } from '$lib/helpers/types';
import type { Models } from '@appwrite.io/console';
@@ -30,8 +35,8 @@
let open = $state(false);
let selectedLogId: string | null = $state(null);
async function deleteExecutions(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((executionId) =>
async function deleteExecutions(batchDelete: DeleteOperation): Promise<DeleteOperationState> {
const result = await batchDelete((executionId) =>
sdk.forProject(page.params.region, page.params.project).functions.deleteExecution({
functionId: page.params.function,
executionId
@@ -39,14 +44,16 @@
);
try {
await Promise.all(promises);
trackEvent(Submit.ExecutionDelete);
} catch (error) {
trackError(error, Submit.ExecutionDelete);
return error;
if (result.error) {
trackError(result.error, Submit.ExecutionDelete);
} else {
trackEvent(Submit.ExecutionDelete, { total: result.deleted.length });
}
} finally {
await invalidate(Dependencies.EXECUTIONS);
}
return result;
}
</script>
@@ -1,5 +1,10 @@
<script lang="ts">
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import {
type DeleteOperationState,
type DeleteOperation,
Id,
MultiSelectionTable
} from '$lib/components';
import type { PageData } from './$types';
import { type Models } from '@appwrite.io/console';
import type { Column } from '$lib/helpers/types';
@@ -52,22 +57,25 @@
invalidate(Dependencies.DEPLOYMENTS);
}
async function deleteDeployments(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((deploymentId) =>
async function deleteDeployments(batchDelete: DeleteOperation): Promise<DeleteOperationState> {
const result = await batchDelete((deploymentId) =>
sdk.forProject(page.params.region, page.params.project).functions.deleteDeployment({
functionId: page.params.function,
deploymentId
})
);
try {
await Promise.all(promises);
trackEvent(Submit.DeploymentDelete);
} catch (error) {
trackError(error, Submit.DeploymentDelete);
return error;
if (result.error) {
trackError(result.error, Submit.DeploymentDelete);
} else {
trackEvent(Submit.DeploymentDelete, { total: result.deleted.length });
}
} finally {
await invalidate(Dependencies.DEPLOYMENTS);
}
return result;
}
</script>
@@ -3,6 +3,7 @@
import { page } from '$app/state';
import {
type DeleteOperationState,
type DeleteOperation,
Empty,
EmptyFilter,
EmptySearch,
@@ -99,22 +100,24 @@
}
]);
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((id) =>
async function handleDelete(batchDelete: DeleteOperation): Promise<DeleteOperationState> {
const result = await batchDelete((id) =>
sdk
.forProject(page.params.region, page.params.project)
.messaging.delete({ messageId: id })
);
try {
await Promise.all(promises);
trackEvent(Submit.MessagingMessageDelete, { total: selectedRows.length });
} catch (error) {
trackError(error, Submit.MessagingMessageDelete);
return error;
if (result.error) {
trackError(result.error, Submit.MessagingMessageDelete);
} else {
trackEvent(Submit.MessagingMessageDelete, { total: result.deleted.length });
}
} finally {
await invalidate(Dependencies.MESSAGING_MESSAGES);
}
return result;
}
onMount(() => {
@@ -1,7 +1,12 @@
<script lang="ts">
import { base } from '$app/paths';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import {
type DeleteOperationState,
type DeleteOperation,
Id,
MultiSelectionTable
} from '$lib/components';
import type { PageData } from './$types';
import { columns } from './store';
import Provider from '../provider.svelte';
@@ -20,22 +25,24 @@
data: PageData;
} = $props();
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((id) => {
return sdk
async function handleDelete(batchDelete: DeleteOperation): Promise<DeleteOperationState> {
const result = await batchDelete((id) =>
sdk
.forProject(page.params.region, page.params.project)
.messaging.deleteProvider({ providerId: id });
});
.messaging.deleteProvider({ providerId: id })
);
try {
await Promise.all(promises);
trackEvent(Submit.MessagingProviderDelete, { total: selectedRows.length });
} catch (error) {
trackError(error, Submit.MessagingProviderDelete);
return error;
if (result.error) {
trackError(result.error, Submit.MessagingProviderDelete);
} else {
trackEvent(Submit.MessagingProviderDelete, { total: result.deleted.length });
}
} finally {
await invalidate(Dependencies.MESSAGING_PROVIDERS);
}
return result;
}
</script>
@@ -1,7 +1,12 @@
<script lang="ts">
import { base } from '$app/paths';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import {
type DeleteOperationState,
type DeleteOperation,
Id,
MultiSelectionTable
} from '$lib/components';
import type { PageData } from './$types';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
@@ -20,22 +25,24 @@
columns: Column[];
} = $props();
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((id) => {
return sdk
async function handleDelete(batchDelete: DeleteOperation): Promise<DeleteOperationState> {
const result = await batchDelete((id) =>
sdk
.forProject(page.params.region, page.params.project)
.messaging.deleteTopic({ topicId: id });
});
.messaging.deleteTopic({ topicId: id })
);
try {
await Promise.all(promises);
trackEvent(Submit.MessagingTopicDelete, { total: selectedRows.length });
} catch (error) {
trackError(error, Submit.MessagingTopicDelete);
return error;
if (result.error) {
trackError(result.error, Submit.MessagingTopicDelete);
} else {
trackEvent(Submit.MessagingTopicDelete, { total: result.deleted.length });
}
} finally {
await invalidate(Dependencies.MESSAGING_TOPICS);
}
return result;
}
</script>
@@ -2,7 +2,12 @@
import { invalidate } from '$app/navigation';
import { base } from '$app/paths';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import {
type DeleteOperationState,
type DeleteOperation,
Id,
MultiSelectionTable
} from '$lib/components';
import { Dependencies } from '$lib/constants';
import type { PageData } from './$types';
import ProviderType from '../../providerType.svelte';
@@ -32,8 +37,8 @@
return record;
});
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
async function deleteSubscriber(subscriberId: string) {
async function handleDelete(batchDelete: DeleteOperation): Promise<DeleteOperationState> {
const result = await batchDelete(async (subscriberId) => {
await sdk
.forProject(page.params.region, page.params.project)
.messaging.deleteSubscriber({
@@ -44,19 +49,19 @@
const { target } = subscribers[subscriberId];
const { [target.$id]: _, ...rest } = $targetsById;
$targetsById = rest;
}
const promises = selectedRows.map((id) => deleteSubscriber(id));
});
try {
await Promise.all(promises);
trackEvent(Submit.MessagingTopicSubscriberDelete, { total: selectedRows.length });
} catch (error) {
trackError(error, Submit.MessagingTopicSubscriberDelete);
return error;
if (result.error) {
trackError(result.error, Submit.MessagingTopicSubscriberDelete);
} else {
trackEvent(Submit.MessagingTopicSubscriberDelete, { total: result.deleted.length });
}
} finally {
await invalidate(Dependencies.MESSAGING_TOPIC_SUBSCRIBERS);
}
return result;
}
</script>
@@ -67,7 +67,7 @@
allowSelection={$canWriteKeys}
showSuccessNotification={false}
resource={`${capitalize(label)} key`}
onDelete={(selectedRows) => {
onDelete={(_, selectedRows) => {
showDeleteModal = true;
selectedKeys = selectedRows;
}}>
@@ -81,7 +81,11 @@
import { page } from '$app/state';
import type { PageProps } from './$types';
import type { Models } from '@appwrite.io/console';
import { type DeleteOperationState, MultiSelectionTable } from '$lib/components';
import {
type DeleteOperationState,
type DeleteOperation,
MultiSelectionTable
} from '$lib/components';
import { sdk } from '$lib/stores/sdk';
import { Submit, trackError } from '$lib/actions/analytics';
import { invalidate } from '$app/navigation';
@@ -129,24 +133,26 @@
}
async function handlePlatformDelete(
selectedPlatforms: string[]
batchDelete: DeleteOperation
): Promise<DeleteOperationState> {
const promises = selectedPlatforms.map((platformId) => {
return sdk.forConsole.projects.deletePlatform({
const result = await batchDelete((platformId) =>
sdk.forConsole.projects.deletePlatform({
projectId: page.params.project,
platformId
});
});
})
);
try {
await Promise.all(promises);
trackEvent(Submit.PlatformDelete, { total: selectedPlatforms.length });
} catch (error) {
trackError(error, Submit.PlatformDelete);
return error;
if (result.error) {
trackError(result.error, Submit.PlatformDelete);
} else {
trackEvent(Submit.PlatformDelete, { total: result.deleted.length });
}
} finally {
await invalidate(Dependencies.PROJECT);
}
return result;
}
setOverviewAction(Action);
@@ -21,7 +21,11 @@ export const load: PageLoad = async ({ depends, url, route, params, parent }) =>
const { organization } = await parent();
const rules = await sdk.forProject(params.region, params.project).proxy.listRules({
queries: [Query.equal('type', RuleType.API), Query.equal('trigger', RuleTrigger.MANUAL)],
queries: [
Query.equal('type', RuleType.API),
Query.equal('trigger', RuleTrigger.MANUAL),
Query.orderDesc('$updatedAt')
],
search: search || undefined
});
@@ -24,6 +24,7 @@
import { regionalProtocol } from '../../store';
import DnsRecordsAction from '$lib/components/domains/dnsRecordsAction.svelte';
import ViewLogsModal from '$lib/components/domains/viewLogsModal.svelte';
import { timeFromNowShort } from '$lib/helpers/date';
let {
domains,
@@ -45,8 +46,36 @@
type: 'string',
format: 'string',
width: { min: 300, max: 550 }
},
{
id: 'updated',
title: '',
type: 'string',
width: { min: 160, max: 180 }
}
];
function updatedLabel(proxyRule: Models.ProxyRule): string {
if (proxyRule.status === 'verified') {
return '';
}
const timeStr = timeFromNowShort(proxyRule.$updatedAt);
if (timeStr === 'n/a') {
return '';
}
const prefix =
proxyRule.status === 'created'
? 'Checked'
: proxyRule.status === 'verifying'
? 'Updated'
: proxyRule.status === 'unverified'
? 'Failed'
: '';
return prefix + ' ' + timeStr;
}
</script>
<Table.Root columns={[...columns, { id: 'actions', width: 40 }]} let:root>
@@ -116,6 +145,15 @@
{/if}
</Layout.Stack>
</Layout.Stack>
{:else if column.id === 'updated' && proxyRule.status !== 'verified'}
<Layout.Stack direction="row" justifyContent="flex-end">
<Typography.Text
variant="m-400"
color="--fgcolor-neutral-tertiary"
style="font-size: 0.875rem;">
{updatedLabel(proxyRule)}
</Typography.Text>
</Layout.Stack>
{/if}
</Table.Cell>
{/each}
@@ -2,7 +2,7 @@
import { base } from '$app/paths';
import { page } from '$app/state';
import { Empty, Id } from '$lib/components';
import { toLocaleDateTime } from '$lib/helpers/date';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import { Container } from '$lib/layout';
import { Button } from '$lib/elements/forms';
import type { PageData } from './$types';
@@ -58,7 +58,11 @@
{:else if column.id === 'events'}
{webhook.events.length}
{:else if column.type === 'datetime'}
{webhook[column.id] ? toLocaleDateTime(webhook[column.id]) : '-'}
{#if webhook[column.id]}
<DualTimeView time={webhook[column.id]} showDatetime />
{:else}
-
{/if}
{:else if column.id === 'enabled'}
<Layout.Stack direction="row" gap="s" alignItems="normal">
<Status
@@ -1,5 +1,10 @@
<script lang="ts">
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import {
type DeleteOperationState,
type DeleteOperation,
Id,
MultiSelectionTable
} from '$lib/components';
import type { PageData } from './$types';
import { type Models } from '@appwrite.io/console';
import { formatTimeDetailed } from '$lib/helpers/timeConversion';
@@ -37,25 +42,28 @@
let selectedDeployment: Models.Deployment | null = $state(null);
async function deleteDeployments(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((deploymentId) =>
async function deleteDeployments(batchDelete: DeleteOperation): Promise<DeleteOperationState> {
const result = await batchDelete((deploymentId) =>
sdk.forProject(page.params.region, page.params.project).sites.deleteDeployment({
siteId: page.params.site,
deploymentId
})
);
try {
await Promise.all(promises);
trackEvent(Submit.DeploymentDelete);
} catch (error) {
trackError(error, Submit.DeploymentDelete);
return error;
if (result.error) {
trackError(result.error, Submit.DeploymentDelete);
} else {
trackEvent(Submit.DeploymentDelete, { total: result.deleted.length });
}
} finally {
await Promise.all([
invalidate(Dependencies.DEPLOYMENTS),
invalidate(Dependencies.SITE)
]);
}
return result;
}
</script>
@@ -28,6 +28,7 @@ export const load = async ({ params, depends, url, route, parent }) => {
Query.limit(limit),
Query.offset(offset),
Query.orderDesc(''),
Query.orderDesc('$updatedAt'),
...parsedQueries.values()
],
search: search || undefined
@@ -15,5 +15,12 @@ export const columns = writable<Column[]>([
title: 'Target',
type: 'string',
width: { min: 120, max: 400 }
},
{
id: 'updated',
title: '',
type: 'string',
width: { min: 160, max: 180 }
}
]);
@@ -25,6 +25,7 @@
import { regionalProtocol } from '$routes/(console)/project-[region]-[project]/store';
import DnsRecordsAction from '$lib/components/domains/dnsRecordsAction.svelte';
import ViewLogsModal from '$lib/components/domains/viewLogsModal.svelte';
import { timeFromNowShort } from '$lib/helpers/date';
let {
proxyRules,
@@ -46,6 +47,28 @@
? 'Deployed from ' + proxy.deploymentVcsProviderBranch
: 'Active deployment';
};
function updatedLabel(proxyRule: Models.ProxyRule): string {
if (proxyRule.status === 'verified') {
return '';
}
const timeStr = timeFromNowShort(proxyRule.$updatedAt);
if (timeStr === 'n/a') {
return '';
}
const prefix =
proxyRule.status === 'created'
? 'Checked'
: proxyRule.status === 'verifying'
? 'Updated'
: proxyRule.status === 'unverified'
? 'Failed'
: '';
return prefix + ' ' + timeStr;
}
</script>
<Table.Root columns={[...$columns, { id: 'actions', width: 40 }]} let:root>
@@ -117,6 +140,15 @@
</Layout.Stack>
{:else if column.id === 'target'}
{proxyTarget(proxyRule)}
{:else if column.id === 'updated' && proxyRule.status !== 'verified'}
<Layout.Stack direction="row" justifyContent="flex-end">
<Typography.Text
variant="m-400"
color="--fgcolor-neutral-tertiary"
style="font-size: 0.875rem;">
{updatedLabel(proxyRule)}
</Typography.Text>
</Layout.Stack>
{/if}
</Table.Cell>
{/each}
@@ -1,5 +1,10 @@
<script lang="ts">
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import {
type DeleteOperationState,
type DeleteOperation,
Id,
MultiSelectionTable
} from '$lib/components';
import type { Column } from '$lib/helpers/types';
import type { Models } from '@appwrite.io/console';
import { Badge, Table, Typography } from '@appwrite.io/pink-svelte';
@@ -26,8 +31,8 @@
let selectedLogId = $state<string | null>(null);
const filteredColumns = $derived(columns.filter((c) => !c.exclude));
async function deleteLogs(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((logId) =>
async function deleteLogs(batchDelete: DeleteOperation): Promise<DeleteOperationState> {
const result = await batchDelete((logId) =>
sdk.forProject(page.params.region, page.params.project).sites.deleteLog({
siteId: page.params.site,
logId
@@ -35,14 +40,16 @@
);
try {
await Promise.all(promises);
trackEvent(Submit.LogDelete);
} catch (error) {
trackError(error, Submit.LogDelete);
return error;
if (result.error) {
trackError(result.error, Submit.LogDelete);
} else {
trackEvent(Submit.LogDelete, { total: result.deleted.length });
}
} finally {
await invalidate(Dependencies.EXECUTIONS);
}
return result;
}
</script>
@@ -1,7 +1,7 @@
<script lang="ts">
import { base } from '$app/paths';
import { page } from '$app/state';
import { timeFromNow, toLocaleDateTime } from '$lib/helpers/date';
import { timeFromNow } from '$lib/helpers/date';
import { Avatar, Icon, Layout, Popover, Table, Typography } from '@appwrite.io/pink-svelte';
import { columns } from './store';
import type { Models } from '@appwrite.io/console';
@@ -80,7 +80,7 @@
time={site?.latestDeploymentCreatedAt ?? site.$createdAt} />
{/if}
{:else if column.id === '$createdAt'}
{toLocaleDateTime(site[column.id])}
<DualTimeView time={site[column.id]} showDatetime />
{/if}
</Table.Cell>
{/each}
@@ -6,6 +6,7 @@
import {
Avatar,
type DeleteOperationState,
type DeleteOperation,
Empty,
EmptySearch,
MultiSelectionTable,
@@ -65,23 +66,25 @@
showDelete = true;
}
async function handleBulkDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((fileId) => {
return sdk.forProject(page.params.region, page.params.project).storage.deleteFile({
async function handleBulkDelete(batchDelete: DeleteOperation): Promise<DeleteOperationState> {
const result = await batchDelete((fileId) =>
sdk.forProject(page.params.region, page.params.project).storage.deleteFile({
bucketId: page.params.bucket,
fileId
});
});
})
);
try {
await Promise.all(promises);
trackEvent(Submit.FileDelete, { total: selectedRows.length });
} catch (error) {
trackError(error, Submit.FileDelete);
return error;
if (result.error) {
trackError(result.error, Submit.FileDelete);
} else {
trackEvent(Submit.FileDelete, { total: result.deleted.length });
}
} finally {
await invalidate(Dependencies.FILES);
}
return result;
}
const beforeunload = (event: BeforeUnloadEvent) => {
@@ -2,7 +2,7 @@
import { base } from '$app/paths';
import { page } from '$app/state';
import { Id } from '$lib/components';
import { toLocaleDateTime } from '$lib/helpers/date';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import type { PageData } from './$types';
import { columns } from './store';
import { Table } from '@appwrite.io/pink-svelte';
@@ -30,8 +30,10 @@
{/key}
{:else if column.id === 'name'}
{bucket.name}
{:else if column.type === 'datetime'}
<DualTimeView time={bucket[column.id]} showDatetime />
{:else}
{toLocaleDateTime(bucket[column.id])}
{bucket[column.id]}
{/if}
</Table.Cell>
{/each}
@@ -94,6 +94,15 @@
<svelte:fragment>
<Form onSubmit={login}>
<Layout.Stack>
{#if isCloud}
<div style:margin-bottom="var(--gap-s, 8px)">
<Button secondary fullWidth on:click={onGithubLogin} {disabled}>
<span class="icon-github" aria-hidden="true"></span>
<span class="text">Sign in with GitHub</span>
</Button>
</div>
<span class="with-separators eyebrow-heading-3">or</span>
{/if}
<InputEmail
id="email"
label="Email"
@@ -108,13 +117,6 @@
required={true}
bind:value={pass} />
<Button fullWidth submit {disabled}>Sign in</Button>
{#if isCloud}
<span class="with-separators eyebrow-heading-3">or</span>
<Button secondary fullWidth on:click={onGithubLogin} {disabled}>
<span class="icon-github" aria-hidden="true"></span>
<span class="text">Sign in with GitHub</span>
</Button>
{/if}
</Layout.Stack>
</Form>
</svelte:fragment>
@@ -106,9 +106,17 @@
}
function onGithubLogin() {
let successUrl = window.location.origin;
if (page.url.searchParams.has('code')) {
successUrl += `?code=${page.url.searchParams.get('code')}`;
} else if (page.url.searchParams.has('campaign')) {
successUrl += `?campaign=${page.url.searchParams.get('campaign')}`;
}
sdk.forConsole.account.createOAuth2Session({
provider: OAuthProvider.Github,
success: window.location.origin,
success: successUrl,
failure: window.location.origin,
scopes: ['read:user', 'user:email']
});
@@ -124,6 +132,16 @@
<svelte:fragment>
<Form onSubmit={register}>
<Layout.Stack>
{#if isCloud}
<div style:margin-bottom="var(--gap-s, 8px)">
<Button secondary fullWidth on:click={onGithubLogin} {disabled}>
<span class="icon-github" aria-hidden="true"></span>
<span class="text">Sign up with GitHub</span>
</Button>
</div>
<span class="with-separators eyebrow-heading-3">or</span>
{/if}
<InputText
id="name"
label="Name"
@@ -159,18 +177,6 @@
>.</InputChoice>
<Button fullWidth submit disabled={disabled || !terms}>Sign up</Button>
{#if isCloud}
<span class="with-separators eyebrow-heading-3">or</span>
<Button
secondary
fullWidth
on:click={onGithubLogin}
disabled={disabled || !terms}>
<span class="icon-github" aria-hidden="true"></span>
<span class="text">Sign up with GitHub</span>
</Button>
{/if}
</Layout.Stack>
</Form>
</svelte:fragment>