Merge branch 'main' into feat-prop-1297-login-for-github-education-program

# Conflicts:
#	src/lib/constants.ts
#	src/lib/stores/billing.ts
This commit is contained in:
ernstmul committed 2024-10-16 09:54:54 +02:00
commit e37064fba3
147 files changed
+5519 -11507

No files matched your search

+1
View File
@@ -1,6 +1,7 @@
.DS_Store
.envrc
.vscode
.zed
node_modules
/build
/.svelte-kit
-10486
View File
File diff suppressed because it is too large. Load diff
+12 -12
View File
@@ -19,11 +19,11 @@
"e2e:ui": "playwright test tests/e2e --ui"
},
"dependencies": {
"@appwrite.io/console": "^1.2.0",
"@appwrite.io/console": "1.4.1",
"@appwrite.io/pink": "0.25.0",
"@appwrite.io/pink-icons": "0.25.0",
"@popperjs/core": "^2.11.8",
"@sentry/sveltekit": "^8.31.0",
"@sentry/sveltekit": "^8.33.1",
"@stripe/stripe-js": "^3.5.0",
"ai": "^2.2.37",
"analytics": "^0.8.14",
@@ -42,13 +42,13 @@
"devDependencies": {
"@melt-ui/pp": "^0.3.2",
"@melt-ui/svelte": "^0.83.0",
"@playwright/test": "^1.47.2",
"@playwright/test": "^1.48.0",
"@sveltejs/adapter-static": "^3.0.5",
"@sveltejs/kit": "^2.5.28",
"@sveltejs/kit": "^2.6.4",
"@sveltejs/vite-plugin-svelte": "^3.1.2",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/svelte": "^5.2.1",
"@testing-library/svelte": "^5.2.3",
"@testing-library/user-event": "^14.5.2",
"@types/deep-equal": "^1.0.4",
"@types/prismjs": "^1.26.4",
@@ -57,20 +57,20 @@
"@vitest/ui": "^1.6.0",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.44.0",
"eslint-plugin-svelte": "^2.44.1",
"jsdom": "^22.1.0",
"kleur": "^4.1.5",
"prettier": "^3.3.3",
"prettier-plugin-svelte": "^3.2.6",
"sass": "^1.79.3",
"prettier-plugin-svelte": "^3.2.7",
"sass": "^1.79.4",
"svelte": "^4.2.19",
"svelte-check": "^3.8.6",
"svelte-jester": "^2.3.2",
"svelte-preprocess": "^6.0.2",
"svelte-sequential-preprocessor": "^2.0.1",
"svelte-preprocess": "^6.0.3",
"svelte-sequential-preprocessor": "^2.0.2",
"tslib": "^2.7.0",
"typescript": "^5.6.2",
"vite": "^5.4.7",
"typescript": "^5.6.3",
"vite": "^5.4.8",
"vitest": "^1.6.0"
},
"type": "module",
+711 -690
View File
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -321,5 +321,7 @@ export enum Submit {
MessagingTopicUpdatePermissions = 'submit_messaging_topic_update_permissions',
MessagingTopicSubscriberAdd = 'submit_messaging_topic_subscriber_add',
MessagingTopicSubscriberDelete = 'submit_messaging_topic_subscriber_delete',
ApplyQuickFilter = 'submit_apply_quick_filter'
ApplyQuickFilter = 'submit_apply_quick_filter',
RequestBAA = 'submit_request_baa',
RequestSoc2 = 'submit_request_soc2'
}
+2
View File
@@ -5,10 +5,12 @@
export let series: BarSeriesOption[];
export let options: EChartsOption = null;
export let formatted: 'days' | 'hours' = 'days';
</script>
<Base
{options}
{formatted}
series={series.map((s) => {
s.type = 'bar';
s.stack = 'total';
+3 -1
View File
@@ -2,7 +2,7 @@
import { registerTheme, use, init } from 'echarts/core';
import { onDestroy, onMount } from 'svelte';
import { app } from '$lib/stores/app';
import { defaultConfig } from './config';
import { dailyFormat, hourlyFormat, defaultConfig } from './config';
import base from './base.json';
import light from './light.json';
import dark from './dark.json';
@@ -16,12 +16,14 @@
export let options: EChartsOption;
export let series: (BarSeriesOption | LineSeriesOption)[];
export let formatted: 'days' | 'hours' = 'days';
let chart: ECharts;
let container: HTMLDivElement;
$: option = {
...defaultConfig,
...(formatted === 'days' ? dailyFormat : hourlyFormat),
...options,
series
};
+29 -3
View File
@@ -1,3 +1,4 @@
import { toLocaleDate, toLocaleDateTime } from '$lib/helpers/date';
import type { EChartsOption } from 'echarts';
export enum Colors {
@@ -10,9 +11,6 @@ export enum Colors {
export const defaultConfig: EChartsOption = {
color: Object.values(Colors),
animation: false,
tooltip: {
trigger: 'axis'
},
grid: {
containLabel: true,
left: 0,
@@ -35,3 +33,31 @@ export const defaultConfig: EChartsOption = {
}
}
};
export const dailyFormat: EChartsOption = {
tooltip: {
trigger: 'axis',
axisPointer: {
label: {
formatter: function (params) {
const date = new Date(params.value);
return toLocaleDate(date.toString());
}
}
}
}
};
export const hourlyFormat: EChartsOption = {
tooltip: {
trigger: 'axis',
axisPointer: {
label: {
formatter: function (params) {
const date = new Date(params.value);
return toLocaleDateTime(date.toString());
}
}
}
}
};
+2
View File
@@ -6,10 +6,12 @@
export let series: LineSeriesOption[];
export let options: EChartsOption = null;
export let formatted: 'days' | 'hours' = 'days';
</script>
<Base
{options}
{formatted}
series={series.map((s) => {
s.type = 'line';
s.stack = 'total';
@@ -0,0 +1,46 @@
<script lang="ts">
import { page } from '$app/stores';
import { BillingPlan } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import { organization } from '$lib/stores/organization';
import { HeaderAlert } from '$lib/layout';
import { isCloud } from '$lib/system';
import { upgradeURL } from '$lib/stores/billing';
import { hideNotification } from '$lib/helpers/notifications';
import { backupsBannerId, showPolicyAlert } from '$lib/stores/database';
function handleClose() {
showPolicyAlert.set(false);
hideNotification(backupsBannerId);
}
</script>
{#if $showPolicyAlert && isCloud && $organization?.$id && $page.url.pathname.match(/\/databases\/database-[^/]+$/)}
{@const isFreePlan = $organization?.billingPlan === BillingPlan.FREE}
{@const subtitle = isFreePlan
? 'Upgrade your plan to ensure your data stays safe and backed up'
: 'Protect your data by quickly adding a backup policy'}
{@const ctaText = isFreePlan ? 'Upgrade plan' : 'Create policy'}
{@const ctaURL = isFreePlan ? $upgradeURL : `${$page.url.pathname}/backups`}
<HeaderAlert type="warning" title="Your database has no backup policy">
<svelte:fragment>{subtitle}</svelte:fragment>
<svelte:fragment slot="buttons">
<div class="u-flex u-gap-16">
<Button
href={ctaURL}
secondary
fullWidthMobile
event={isFreePlan ? 'backup_banner_upgrade' : 'backup_banner_add'}>
<span class="text">{ctaText}</span>
</Button>
<Button text on:click={handleClose} event="backup_banner_close">
<span class="icon-x" aria-hidden="true"></span>
</Button>
</div>
</svelte:fragment>
</HeaderAlert>
{/if}
+257
View File
@@ -0,0 +1,257 @@
<script lang="ts">
import { sdk } from '$lib/stores/sdk';
import { type Payload, Query } from '@appwrite.io/console';
import { onMount } from 'svelte';
import { isCloud, isSelfHosted } from '$lib/system';
import { organization } from '$lib/stores/organization';
import { BillingPlan, Dependencies } from '$lib/constants';
import type { BackupArchive, BackupRestoration } from '$lib/sdk/backups';
import { goto, invalidate } from '$app/navigation';
import { page } from '$app/stores';
import { addNotification } from '$lib/stores/notifications';
import { base } from '$app/paths';
import { getProjectId } from '$lib/helpers/project';
import { toLocaleDate } from '$lib/helpers/date';
const backupRestoreItems: {
archives: Map<string, BackupArchive>;
restorations: Map<string, BackupRestoration>;
} = {
archives: new Map(),
restorations: new Map()
};
const openStates = {
archives: true,
restorations: true
};
$: showBackupRestoreBox =
backupRestoreItems.archives.size > 0 || backupRestoreItems.restorations.size > 0;
let lastDatabaseRestorationId = null;
function showRestoreNotification(newDatabaseId: string, newDatabaseName: string) {
if (newDatabaseId && newDatabaseName && lastDatabaseRestorationId !== newDatabaseId) {
const project = $page.params.project;
lastDatabaseRestorationId = newDatabaseId;
addNotification({
type: 'success',
isHtml: true,
message: `Restoration complete. <b>${newDatabaseName}</b> has been created.`,
buttons: [
{
name: 'View restored data',
method: () => {
goto(`${base}/project-${project}/databases/database-${newDatabaseId}`);
}
}
]
});
}
}
async function fetchBackupRestores() {
try {
const query = [
Query.equal('status', 'pending'),
Query.equal('status', 'uploading'),
Query.equal('status', 'processing')
];
const [archivesResponse, restorationsResponse] = await Promise.all([
sdk.forProject.backups.listArchives([
...query,
// only manual backups
Query.isNull('policyId')
]),
sdk.forProject.backups.listRestorations(query)
]);
// this is a one time op.
backupRestoreItems.archives = new Map(
archivesResponse.archives.map((item) => [item.$id, item])
);
backupRestoreItems.restorations = new Map(
restorationsResponse.restorations.map((item) => [item.$id, item])
);
} catch (e) {
// ignore?
}
}
// fresh fetch.
fetchBackupRestores();
function updateOrAddItem(payload: Payload) {
const { $id, status, $collection, policyId } = payload;
if ($collection === 'archives' && policyId !== null) {
return;
}
if ($collection in backupRestoreItems) {
const collectionMap = backupRestoreItems[$collection];
if (collectionMap.has($id)) {
collectionMap.get($id).status = status;
if (status === 'completed') {
invalidate(Dependencies.BACKUPS);
if ($collection === 'restorations') {
const { newId, newName } =
collectionMap.get($id).options?.['databases']?.['database'][0] || {};
showRestoreNotification(newId, newName);
}
}
} else if (status === 'pending' || status === 'processing' || status === 'uploading') {
collectionMap.set($id, payload);
}
backupRestoreItems[$collection] = collectionMap;
}
}
function graphSize(status: string): number {
switch (status) {
case 'pending':
return 10;
case 'processing':
return 30;
case 'uploading':
return 60;
case 'completed':
case 'failed':
return 100;
default:
return 0;
}
}
function text(status: string, key: string) {
const service = key === 'archives' ? 'backup' : 'restore';
if (status === 'completed') {
return `Database ${service} complete`;
} else if (status === 'failed') {
return `Database ${service} failed`;
} else {
return 'Preparing database...';
}
}
function handleClose(which: string) {
backupRestoreItems[which] = new Map();
if (which === 'restorations') lastDatabaseRestorationId = null;
}
function backupName(item: BackupArchive | BackupRestoration, key: string) {
const attribute = key === 'archives' ? '$createdAt' : 'startedAt';
return toLocaleDate(item[attribute]);
}
onMount(() => {
// fast path: don't subscribe if org is on a free plan or is self-hosted.
if (isSelfHosted || (isCloud && $organization.billingPlan === BillingPlan.FREE)) return;
sdk.forConsole.client.subscribe('console', (response) => {
// nice!
if (!response.channels.includes(`projects.${getProjectId()}`)) return;
if (
response.events.includes('archives.*') ||
response.events.includes('restorations.*')
) {
updateOrAddItem(response.payload);
}
});
});
</script>
{#if showBackupRestoreBox}
<div class="box-holder u-flex u-flex-vertical u-gap-16" style="align-items: end">
{#each Object.keys(backupRestoreItems) as key}
{@const isBackup = key === 'archives'}
{@const items = backupRestoreItems[key]}
{@const titleText = isBackup ? 'Backup status' : 'Restoration status'}
{#if items.size > 0}
<section class="upload-box">
<header class="upload-box-header">
<h4 class="upload-box-title">
<span class="text">{titleText} ({items.size})</span>
</h4>
<button
class="upload-box-button"
class:is-open={openStates[key]}
aria-label="toggle upload box"
on:click={() => {
openStates[key] = !openStates[key];
}}>
<span class="icon-cheveron-up" aria-hidden="true" />
</button>
<button
class="upload-box-button"
aria-label="close backup restore box"
on:click={() => handleClose(key)}>
<span class="icon-x" aria-hidden="true" />
</button>
</header>
<div class="upload-box-content" class:is-open={openStates[key]}>
<ul class="upload-box-list">
{#each [...items.values()] as item (item.$id)}
<li class="upload-box-item">
<section class="progress-bar u-width-full-line">
<div
class="progress-bar-top-line u-flex u-gap-8 u-main-space-between">
<span class="body-text-2">
{text(item.status, key)}
</span>
<span class="backup-name">
{backupName(item, key)}
</span>
</div>
<div
class="progress-bar-container"
class:is-danger={item.status === 'failed'}
style="--graph-size:{graphSize(item.status)}%" />
</section>
</li>
{/each}
</ul>
</div>
</section>
{/if}
{/each}
</div>
{/if}
<style>
.upload-box-title {
font-size: 11px;
}
.upload-box-content {
min-width: 400px;
max-width: 100vw;
}
.upload-box-button {
display: flex;
align-items: center;
justify-content: center;
}
.backup-name {
font-size: 12px;
font-weight: 400;
line-height: 130%;
font-style: normal;
letter-spacing: -0.12px;
color: var(--mid-neutrals-50, #818186);
font-family: var(--font-family-sansSerif, Inter);
}
</style>
@@ -8,6 +8,7 @@
export let billingPlan: Tier;
export let anyOrgFree = false;
export let isNewOrg = false;
export let selfService = true;
let classes: string = '';
export { classes as class };
@@ -22,7 +23,7 @@
<LabelCard
name="plan"
bind:group={billingPlan}
disabled={anyOrgFree}
disabled={anyOrgFree || !selfService}
value={BillingPlan.FREE}
tooltipShow={anyOrgFree}
tooltipText="You are limited to 1 Free organization per account."
@@ -49,9 +50,16 @@
</li>
<li>
<LabelCard name="plan" bind:group={billingPlan} value={BillingPlan.PRO} padding={1.5}>
<svelte:fragment slot="custom">
<div class="u-flex u-flex-vertical u-gap-4 u-width-full-line">
<LabelCard
name="plan"
disabled={!selfService}
bind:group={billingPlan}
value={BillingPlan.PRO}
padding={1.5}>
<svelte:fragment slot="custom" let:disabled>
<div
class="u-flex u-flex-vertical u-gap-4 u-width-full-line"
class:u-opacity-50={disabled}>
<h4 class="body-text-2 u-bold">
{tierPro.name}
{#if $organization?.billingPlan === BillingPlan.PRO && !isNewOrg}
+21 -12
View File
@@ -15,6 +15,7 @@
import { addBottomModalAlerts } from '$routes/(console)/bottomAlerts';
import { project } from '$routes/(console)/project-[project]/store';
import { page } from '$app/stores';
import { trackEvent } from '$lib/actions/analytics';
let currentIndex = 0;
let openModalOnMobile = false;
@@ -50,16 +51,12 @@
$: currentModalAlert = filteredModalAlerts[currentIndex] as BottomModalAlertItem;
function handleClose() {
const modalAlert = currentModalAlert;
dismissBottomModalAlert(modalAlert.id);
hideNotification(modalAlert.id);
if (modalAlert.closed) modalAlert.closed();
if (currentIndex === filteredModalAlerts.length - 1 && filteredModalAlerts.length > 1) {
currentIndex = currentIndex - 1;
} else {
currentIndex = currentIndex % filteredModalAlerts.length;
}
filteredModalAlerts.forEach((alert) => {
const modalAlert = alert;
dismissBottomModalAlert(modalAlert.id);
hideNotification(modalAlert.id, { coolOffPeriod: 24 * 365 });
if (modalAlert.closed) modalAlert.closed();
});
}
function showNext() {
@@ -171,7 +168,13 @@
project: $project
})}
external={!!currentModalAlert.cta.external}
fullWidthMobile>
fullWidthMobile
on:click={() => {
trackEvent('click_promo', {
promo: currentModalAlert.id,
type: shouldShowUpgrade ? 'upgrade' : 'try_now'
});
}}>
{currentModalAlert.cta.text}
</Button>
@@ -277,7 +280,13 @@
})}
external={!!currentModalAlert.cta.external}
fullWidthMobile
on:click={() => (openModalOnMobile = false)}>
on:click={() => {
openModalOnMobile = false;
trackEvent('click_promo', {
promo: currentModalAlert.id,
type: shouldShowUpgrade ? 'upgrade' : 'try_now'
});
}}>
{shouldShowUpgrade
? 'Upgrade plan'
: currentModalAlert.cta.text}
@@ -57,6 +57,9 @@
<style lang="scss">
// TODO: remove once pink is updated
.collapsible-item {
.collapsible-wrapper {
padding-left: 0;
}
.collapsible-wrapper.is-disabled {
cursor: not-allowed;
+1 -1
View File
@@ -8,7 +8,7 @@
</script>
<div class:box={isBox}>
<div class="u-flex u-main-space-between u-cross-start">
<div class="u-flex u-main-space-between u-cross-start" style="padding-block: 0.5rem;">
<div class="u-line-height-1-5 u-flex u-flex-vertical u-gap-2">
<span class="u-flex u-cross-center u-gap-8">
<p class="text u-bold">
+4 -1
View File
@@ -42,7 +42,7 @@
{
name: 'offset',
options: {
offset: [noArrow ? 0 : -arrowSize, noArrow ? 0 : arrowSize / 1.5]
offset: [noArrow ? 0 : arrowSize * 1.75, noArrow ? 0 : arrowSize / 1.5]
}
},
{
@@ -135,10 +135,12 @@
<style global lang="scss">
.drop-arrow.is-popover {
--drop-arrow-pop-over-bg-color: var(--color-neutral-90);
body.theme-light & {
--drop-arrow-pop-over-bg-color: var(--color-neutral-0);
}
}
.drop-arrow,
.drop-arrow::before {
position: absolute;
@@ -148,6 +150,7 @@
--drop-arrow-border: 1px solid hsl(var(--color-neutral-85));
--drop-arrow-bg-color: hsl(var(--drop-arrow-pop-over-bg-color, var(--color-neutral-105)));
body.theme-light & {
--drop-arrow-border: 1px solid hsl(var(--color-neutral-10));
--drop-arrow-bg-color: hsl(var(--drop-arrow-pop-over-bg-color, var(--color-neutral-0)));
+11 -7
View File
@@ -8,6 +8,7 @@
import { createEventDispatcher } from 'svelte';
export let single = false;
export let isCard = true;
export let noMedia = false;
export let target: string = null;
export let href: string = null;
@@ -36,7 +37,7 @@
</script>
{#if single}
<article class="card u-grid u-cross-center u-width-full-line common-section">
<article class:card={isCard} class="u-grid u-cross-center u-width-full-line common-section">
<div
class="u-flex u-flex-vertical u-cross-center u-gap-24 u-width-full-line u-overflow-hidden u-padding-block-8">
{#if !noMedia}
@@ -45,11 +46,13 @@
on:click={track}
on:click={onClick}
aria-label="create {target}">
{#if $app.themeInUse === 'dark'}
<img src={EmptyDark} alt="create" aria-hidden="true" height="242" />
{:else}
<img src={EmptyLight} alt="create" aria-hidden="true" height="242" />
{/if}
<slot name="empty-media">
{#if $app.themeInUse === 'dark'}
<img src={EmptyDark} alt="create" aria-hidden="true" height="242" />
{:else}
<img src={EmptyLight} alt="create" aria-hidden="true" height="242" />
{/if}
</slot>
</button>
{/if}
<slot>
@@ -83,7 +86,8 @@
on:click={track}
aria-label="create"
type="button"
class="card u-grid u-cross-center u-width-full-line dashed"
class:card={isCard}
class="u-grid u-cross-center u-width-full-line dashed"
class:common-section={marginTop}>
<div class="u-flex u-cross-center u-flex-vertical u-main-center u-flex">
<div class="common-section">
+1 -1
View File
@@ -79,7 +79,7 @@
: selected.service?.actions;
// We need to check if the selected action name is still
// on the available list, and if so, change tje action to the
// on the available list, and if so, change the action to the
// appropriate one, as it may contain different attributes
const availableAction = availableActions.find(
(a) => a.name === selected.action?.name
+2
View File
@@ -8,7 +8,9 @@
<div class="grid-item-1-start-start">
<div class="eyebrow-heading-3"><slot name="eyebrow" /></div>
<Heading tag="h2" size="7"><slot name="title" /></Heading>
<div class="u-padding-block-start-4"><slot name="subtitle" /></div>
</div>
<div class="grid-item-1-start-end">
<slot name="status" />
</div>
+1
View File
@@ -12,6 +12,7 @@ export { default as Tiles } from './tiles.svelte';
export { default as Copy } from './copy.svelte';
export { default as CopyInput } from './copyInput.svelte';
export { default as UploadBox } from './uploadBox.svelte';
export { default as BackupRestoreBox } from './backupRestoreBox.svelte';
export { default as List } from './list.svelte';
export { default as ListItem } from './listItem.svelte';
export { default as Empty } from './empty.svelte';
+11 -2
View File
@@ -66,7 +66,7 @@
</script>
{#if $showMigrationBox && migration}
<section class="upload-box is-float">
<section class="upload-box">
<header class="upload-box-header">
<h4 class="upload-box-title">
<span class="text">Importing Data</span>
@@ -93,9 +93,18 @@
{/if}
<style>
.upload-box-title {
font-size: 11px;
}
.upload-box-content {
padding: 1.5rem;
min-width: 400px;
max-width: 100vw;
}
.upload-box-button {
display: flex;
align-items: center;
justify-content: center;
}
</style>
+1 -1
View File
@@ -70,7 +70,7 @@
{/if}
</div>
{#if description.length > 0}
<p class="u-margin-block-start-4">
<p class="modal-description u-margin-block-start-4">
<slot name="description">
{description}
</slot>
+1 -1
View File
@@ -34,7 +34,7 @@
<Modal
title="Label"
description="Labels allow you to grant access to users with the specfied label."
description="Labels allow you to grant access to users with the specified label."
bind:show
on:close={reset}
onSubmit={create}>
+1 -1
View File
@@ -26,7 +26,7 @@
<p class="body-text-2">
{maxValue}
{maxUnit && maxUnit}
{maxUnit ? maxUnit : ''}
</p>
</div>
{/if}
+22 -1
View File
@@ -13,7 +13,7 @@
</script>
{#if $uploader?.isOpen}
<section class="upload-box is-float">
<section class="upload-box">
<header class="upload-box-header">
<h4 class="upload-box-title">
<span class="text">Uploading files</span>
@@ -101,3 +101,24 @@
</div>
</section>
{/if}
<style>
.upload-box-title {
font-size: 11px;
}
.upload-box-button {
display: flex;
align-items: center;
justify-content: center;
}
.upload-box-content {
min-width: 400px;
max-width: 100vw;
}
.file-name {
max-width: 24ch;
}
</style>
+3 -1
View File
@@ -50,6 +50,7 @@ export enum Dependencies {
WEBHOOKS = 'dependency:webhooks',
MIGRATIONS = 'dependency:migrations',
COLLECTIONS = 'dependency:collections',
BACKUPS = 'dependency:backups',
RUNTIMES = 'dependency:runtimes',
CONSOLE_VARIABLES = 'dependency:console_variables',
MESSAGING_PROVIDERS = 'dependency:messaging_providers',
@@ -468,7 +469,8 @@ export enum BillingPlan {
FREE = 'tier-0',
PRO = 'tier-1',
SCALE = 'tier-2',
GITHUB_EDUCATION = 'tier-3'
GITHUB_EDUCATION = 'tier-3',
CUSTOM = 'cont-1'
}
export const feedbackDowngradeOptions = [
@@ -68,7 +68,7 @@
<span
class:icon-cheveron-up={show}
class:icon-cheveron-down={!show}
class="u-position-absolute u-inset-block-start-4 u-inset-inline-end-12"
class="chevron-icon u-position-absolute u-inset-inline-end-12"
aria-hidden="true"></span>
</button>
@@ -86,3 +86,15 @@
{/each}
</svelte:fragment>
</DropList>
<style>
@media (max-width: 768px) {
.chevron-icon {
inset-block-start: 0.25rem !important;
}
.tags-input {
padding-right: 2rem;
}
}
</style>
+165
View File
@@ -0,0 +1,165 @@
export type UserBackupPolicy = {
id?: string;
label: string;
retained: number;
default: boolean;
description: string;
schedule?: string;
checked?: boolean;
selectedTime?: string;
plainTextFrequency?: string;
weeklySelectedDays?: string[];
monthlyBackupFrequency?: string;
};
export const cronExpression = (policy: UserBackupPolicy) => {
const now = new Date();
if (policy.plainTextFrequency === 'hourly') {
const utcMinute = now.getUTCMinutes();
if (!policy.default) {
policy.schedule = `${utcMinute} * * * *`;
}
return;
}
let cronExpression = '';
if (policy.default) {
// default should use utc.
cronExpression = policy.schedule;
} else {
const [localHour, localMinute] = policy.selectedTime.split(':');
now.setHours(parseInt(localHour), parseInt(localMinute), 0);
const utcHour = now.getUTCHours();
const utcMinute = now.getUTCMinutes();
if (policy.plainTextFrequency === 'daily') {
cronExpression = `${utcMinute} ${utcHour} * * *`;
} else if (policy.plainTextFrequency === 'weekly') {
const selectedDays = policy.weeklySelectedDays
?.map(
(dayLabel) =>
backupFrequencies.weekly.find((option) => option.label === dayLabel)?.index
)
.filter((index) => index !== undefined)
.join(',');
// Default to Monday (1)
cronExpression = `${utcMinute} ${utcHour} * * ${selectedDays || '1'}`;
} else if (policy.plainTextFrequency === 'monthly') {
cronExpression = `${utcMinute} ${utcHour} 28 * *`;
}
}
policy.schedule = cronExpression;
};
const generateHourlyOptions = (start: number, end: number) =>
Array.from({ length: end - start + 1 }, (_, i) => ({
value: `${i + start}`,
label: `${i + start}`
}));
const generateWeeklyOptions = () =>
['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'].map(
(day, index) => ({
label: day,
value: day,
index: index,
checked: false
})
);
export const backupFrequencies = {
monthly: [
{ value: 'first', label: 'First day of month', day: '1' },
{ value: 'middle', label: 'Middle of month (15th)', day: '15' },
{ value: 'end', label: 'End of month (28th)', day: '28' }
],
weekly: generateWeeklyOptions(),
hourly: generateHourlyOptions(1, 12)
};
export const backupPolicyDescription = (
frequency: string,
time: string | null = null,
retained: number | null = null,
monthlyBackupFrequency: string,
weeklySelectedDays: string[] | null = null
) => {
let retainedText = '';
const timeFormatted = time ?? '';
if (retained !== null) {
if (retained === 365 * 100) {
retainedText = 'forever';
} else if (retained >= 365) {
const years = Math.floor(retained / 365);
retainedText = `${years} year${years > 1 ? 's' : ''}`;
} else if (retained >= 30) {
const months = Math.floor(retained / 30);
retainedText = `${months} month${months > 1 ? 's' : ''}`;
} else if (retained >= 7) {
const weeks = Math.floor(retained / 7);
retainedText = `${weeks} week${weeks > 1 ? 's' : ''}`;
} else {
retainedText = `${retained} day${retained > 1 ? 's' : ''}`;
}
}
switch (frequency) {
case 'hourly':
return retained !== null
? `Runs every hour and is retained for ${retainedText}.`
: 'A backup will run every hour.';
case 'daily':
return retained !== null
? `Runs every day and is retained for ${retainedText}.`
: `A backup will run daily at ${timeFormatted}.`;
case 'weekly': {
const daysArray = weeklySelectedDays.length ? weeklySelectedDays : ['Monday'];
const dayString =
daysArray.length > 1
? daysArray.slice(0, -1).join(', ') + ' and ' + daysArray.slice(-1)
: daysArray[0];
return retained !== null
? `Runs every ${dayString} and is retained for ${retainedText}.`
: `A backup will run weekly on ${dayString} at ${timeFormatted}.`;
}
case 'monthly': {
const monthDay =
backupFrequencies[frequency]
.find((option) => option.value === monthlyBackupFrequency)
?.label.toLowerCase() || '28th';
let actualDay: string;
switch (monthlyBackupFrequency) {
case 'first':
actualDay = '1st';
break;
case 'middle':
actualDay = '15th';
break;
case 'end':
default:
actualDay = '28th';
break;
}
return retained !== null
? `Runs every ${actualDay} of the month and is retained for ${retainedText}.`
: `A backup will run every month on the ${monthDay} at ${timeFormatted}.`;
}
default:
return 'A backup schedule is not set.';
}
};
+2 -2
View File
@@ -51,7 +51,7 @@ export const toLocaleDateTime = (datetime: string | number) => {
};
/**
* Returns a date string usig the local timezone in ISO format (yyyy-mm-dd)
* Returns a date string using the local timezone in ISO format (yyyy-mm-dd)
*
* @param datetime date string or milliseconds since the epoch
*/
@@ -67,7 +67,7 @@ export const toLocaleDateISO = (datetime: string | number) => {
};
/**
* Returns a time string usig the local timezone in ISO format (hh:mm:ss)
* Returns a time string using the local timezone in ISO format (hh:mm:ss)
*
* @param datetime date string or milliseconds since the epoch
*/
+2 -1
View File
@@ -18,7 +18,8 @@ const userPreferences = () => get(user).prefs;
const notificationPrefs = (): Record<string, NotificationPrefItem> => {
const prefs = userPreferences();
return prefs.notificationPrefs ? prefs.notificationPrefs : {};
// for some reason, the prefs become array as default or on all clear. let's reset.
return Array.isArray(prefs.notificationPrefs) ? {} : prefs.notificationPrefs || {};
};
function updateNotificationPrefs(parsedPrefs: Record<string, NotificationPrefItem>) {
+1
View File
@@ -14,6 +14,7 @@ export function calculateSize(bytes: number, decimals = 1, base: 1000 | 1024 = 1
}
export function sizeToBytes(value: number, unit: Size, base = 1000) {
if (typeof value !== 'number') return 0;
const index = sizes.indexOf(unit);
return value * Math.pow(base, index);
}
+1 -1
View File
@@ -70,7 +70,7 @@ export function createTimeUnitPair(initialValue = 0) {
{ name: 'Minutes', value: 60 },
{ name: 'Seconds', value: 1 }
];
return { ...createValueUnitPair(initialValue, units), units };
return { ...createValueUnitPair(initialValue || 0, units), units };
}
export function createByteUnitPair(initialValue = 0, base: 1000 | 1024 = 1000) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

+4
View File
@@ -78,6 +78,10 @@
onMount(() => {
dispatch('data', { isButtonDisabled, limit, tier });
});
// on free plan, if the only db is deleted,
// `create database` button needs to be enabled again.
$: if (isLimited) dispatch('data', { isButtonDisabled, limit, tier });
</script>
<!-- Show only if on Cloud, alerts are enabled, and it isn't a project limited service -->
+12 -1
View File
@@ -7,7 +7,7 @@
<footer class="main-footer u-cross-center">
<div class="main-footer-start">
<ul class="inline-links is-no-padding-first-and-last u-x-small u-flex-wrap u-row-gap-16">
<ul class="inline-links is-no-padding-first-and-last u-x-small u-flex-wrap u-gap-16">
<li class="inline-links-item" style="line-height: 1.02;">
<div class="u-flex u-cross-center u-gap-8">
{#if isCloud}
@@ -79,7 +79,18 @@
margin-block-start: auto;
}
.main-footer-start .inline-links-item {
padding-inline-end: 1rem;
padding-inline-start: unset;
}
[class^='icon-']:not(.icon-cloud):not(:hover) {
color: hsl(var(--color-neutral-50));
}
@media (max-width: 1200px) {
.main-footer {
gap: 2rem;
}
}
</style>
+17 -1
View File
@@ -10,7 +10,13 @@
class:is-danger={type === 'error'}
class:is-info={type === 'info'}>
<div class="alert-grid">
<span class="icon-info" aria-hidden="true" />
<span
aria-hidden="true"
class:icon-check-circle={type === 'success'}
class:icon-exclamation={type === 'warning'}
class:icon-exclamation-circle={type === 'error'}
class:icon-info={type === 'info' || type === 'default'} />
<div class="alert-content">
{#if title || $$slots.title}
<h6 class="alert-title">
@@ -30,3 +36,13 @@
{/if}
</div>
</section>
<style>
.alert {
padding: 1rem 1rem 0.75rem 1rem;
}
.alert-content {
gap: 0.25rem;
}
</style>
+14 -2
View File
@@ -48,6 +48,10 @@
return queries;
}
function unescapeNewLines(logs: string) {
return logs.replace(/\\n/g, '\n');
}
beforeNavigate((n) => {
if (!$log.show) return;
if (n.type === 'popstate') {
@@ -343,7 +347,11 @@
{/if}
</Alert>
{/if}
<Code withCopy noMargin code={execution.logs} language="sh" />
<Code
withCopy
noMargin
code={unescapeNewLines(execution.logs)}
language="sh" />
{:else}
<Card isDashed isTile>
<p class="text u-text-center">No response was recorded.</p>
@@ -351,7 +359,11 @@
{/if}
{:else if selectedResponse === 'errors'}
{#if execution?.errors}
<Code withCopy noMargin code={execution.errors} language="sh" />
<Code
withCopy
noMargin
code={unescapeNewLines(execution.errors)}
language="sh" />
{:else}
<Card isDashed isTile>
<p class="text u-text-center">No response was recorded.</p>
+1
View File
@@ -101,6 +101,7 @@
<div class="u-margin-block-start-16" />
<div class="chart-container">
<BarChart
formatted={$page.params.period === '24h' ? 'hours' : 'days'}
series={[
{
name: countMetadata.legend,
+332
View File
@@ -0,0 +1,332 @@
import { AppwriteException, Client, type Payload } from '@appwrite.io/console';
export type BackupPolicyList = {
total: number;
policies: BackupPolicy[];
};
export type BackupArchiveList = {
total: number;
archives: BackupArchive[];
};
export type BackupRestorationList = {
total: number;
restorations: BackupRestoration[];
};
export type BackupPolicy = {
$id: string;
name: string;
$createdAt: string;
$updatedAt: string;
services: string[];
resources: string[];
resourceId?: string;
resourceType?: string;
retention: number;
schedule: string;
enabled: boolean;
};
export type BackupArchive = {
$id: string;
$createdAt: string;
$updatedAt: string;
policyId: string;
size: number;
status: string;
startedAt: string;
migrationId: string;
services: string[];
resources: string[];
resourceId?: string;
resourceType?: string;
};
export type BackupRestoration = {
$id: string;
$createdAt: string;
$updatedAt: string;
archiveId: string;
policyId: string;
status: string;
startedAt: string;
migrationId: string;
services: string[];
resources: string[];
options: string;
};
export class Backups {
client: Client;
constructor(client: Client) {
this.client = client;
}
async createArchive(services: string[], resourceId?: string): Promise<BackupArchive> {
if (typeof services === 'undefined') {
throw new AppwriteException('Missing required parameter: "services"');
}
const apiPath = '/backups/archives';
const payload: Payload = {};
if (typeof services !== 'undefined') {
payload['services'] = services;
}
if (typeof resourceId !== 'undefined') {
payload['resourceId'] = resourceId;
}
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('post', uri, apiHeaders, payload);
}
async deleteArchive(archiveId: string): Promise<object> {
if (typeof archiveId === 'undefined') {
throw new AppwriteException('Missing required parameter: "archiveId"');
}
const apiPath = '/backups/archives/{archiveId}'.replace('{archiveId}', archiveId);
const payload: Payload = {};
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('delete', uri, apiHeaders, payload);
}
async listArchives(queries?: string[]): Promise<BackupArchiveList> {
const apiPath = '/backups/archives';
const payload: Payload = {};
if (typeof queries !== 'undefined') {
payload['queries'] = queries;
}
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('get', uri, apiHeaders, payload);
}
async getArchive(archiveId: string): Promise<BackupArchive> {
if (typeof archiveId === 'undefined') {
throw new AppwriteException('Missing required parameter: "archiveId"');
}
const apiPath = '/backups/archives/{archiveId}'.replace('{archiveId}', archiveId);
const payload: Payload = {};
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('get', uri, apiHeaders, payload);
}
async listPolicies(queries?: string[]): Promise<BackupPolicyList> {
const apiPath = '/backups/policies';
const payload: Payload = {};
if (typeof queries !== 'undefined') {
payload['queries'] = queries;
}
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('get', uri, apiHeaders, payload);
}
async createPolicy(
policyId: string,
services: string[],
retention: number,
schedule: string,
name?: string,
resourceId?: string,
enabled?: boolean
): Promise<BackupPolicy> {
if (typeof policyId === 'undefined') {
throw new AppwriteException('Missing required parameter: "policyId"');
}
if (typeof services === 'undefined') {
throw new AppwriteException('Missing required parameter: "services"');
}
if (typeof retention === 'undefined') {
throw new AppwriteException('Missing required parameter: "retention"');
}
if (typeof schedule === 'undefined') {
throw new AppwriteException('Missing required parameter: "schedule"');
}
const apiPath = '/backups/policies';
const payload: Payload = {};
if (typeof policyId !== 'undefined') {
payload['policyId'] = policyId;
}
if (typeof name !== 'undefined') {
payload['name'] = name;
}
if (typeof services !== 'undefined') {
payload['services'] = services;
}
if (typeof resourceId !== 'undefined') {
payload['resourceId'] = resourceId;
}
if (typeof enabled !== 'undefined') {
payload['enabled'] = enabled;
}
if (typeof retention !== 'undefined') {
payload['retention'] = retention;
}
if (typeof schedule !== 'undefined') {
payload['schedule'] = schedule;
}
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('post', uri, apiHeaders, payload);
}
async getPolicy(policyId: string): Promise<BackupPolicy> {
if (typeof policyId === 'undefined') {
throw new AppwriteException('Missing required parameter: "policyId"');
}
const apiPath = '/backups/policies/{policyId}'.replace('{policyId}', policyId);
const payload: Payload = {};
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('get', uri, apiHeaders, payload);
}
async updatePolicy(
policyId: string,
name?: string,
retention?: number,
schedule?: string,
enabled?: boolean
): Promise<BackupPolicy> {
if (typeof policyId === 'undefined') {
throw new AppwriteException('Missing required parameter: "policyId"');
}
const apiPath = '/backups/policies/{policyId}'.replace('{policyId}', policyId);
const payload: Payload = {};
if (typeof name !== 'undefined') {
payload['name'] = name;
}
if (typeof retention !== 'undefined') {
payload['retention'] = retention;
}
if (typeof schedule !== 'undefined') {
payload['schedule'] = schedule;
}
if (typeof enabled !== 'undefined') {
payload['enabled'] = enabled;
}
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('patch', uri, apiHeaders, payload);
}
async deletePolicy(policyId: string): Promise<object> {
if (typeof policyId === 'undefined') {
throw new AppwriteException('Missing required parameter: "policyId"');
}
const apiPath = '/backups/policies/{policyId}'.replace('{policyId}', policyId);
const payload: Payload = {};
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('delete', uri, apiHeaders, payload);
}
async createRestoration(
archiveId: string,
services: string[],
newResourceId?: string,
newResourceName?: string
): Promise<BackupRestoration> {
if (typeof archiveId === 'undefined') {
throw new AppwriteException('Missing required parameter: "archiveId"');
}
if (typeof services === 'undefined') {
throw new AppwriteException('Missing required parameter: "services"');
}
const apiPath = '/backups/restoration';
const payload: Payload = {};
if (typeof archiveId !== 'undefined') {
payload['archiveId'] = archiveId;
}
if (typeof services !== 'undefined') {
payload['services'] = services;
}
if (typeof newResourceId !== 'undefined') {
payload['newResourceId'] = newResourceId;
}
if (typeof newResourceName !== 'undefined') {
payload['newResourceName'] = newResourceName;
}
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('post', uri, apiHeaders, payload);
}
async listRestorations(queries?: string[]): Promise<BackupRestorationList> {
const apiPath = '/backups/restorations';
const payload: Payload = {};
if (typeof queries !== 'undefined') {
payload['queries'] = queries;
}
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('get', uri, apiHeaders, payload);
}
async getRestoration(restorationId: string): Promise<BackupArchive> {
if (typeof restorationId === 'undefined') {
throw new AppwriteException('Missing required parameter: "restorationId"');
}
const apiPath = '/backups/restorations/{restorationId}'.replace(
'{restorationId}',
restorationId
);
const payload: Payload = {};
const uri = new URL(this.client.config.endpoint + apiPath);
const apiHeaders: { [header: string]: string } = {
'content-type': 'application/json'
};
return await this.client.call('get', uri, apiHeaders, payload);
}
}
+4 -1
View File
@@ -146,7 +146,7 @@ export type Aggregation = {
*/
amount: number;
/**
* Price for addional members
* Price for additional members
*/
additionalMembers: number;
/**
@@ -184,6 +184,7 @@ export type OrganizationUsage = {
deploymentsStorageTotal: number;
executionsMBSecondsTotal: number;
buildsMBSecondsTotal: number;
backupsStorageTotal: number;
storageTotal: number;
users: Array<Models.Metric>;
usersTotal: number;
@@ -277,6 +278,8 @@ export type Plan = {
users: AdditionalResource;
};
trialDays: number;
isAvailable: boolean;
selfService: boolean;
};
export type PlansInfo = {
+8 -1
View File
@@ -34,7 +34,7 @@ import { user } from './user';
import { browser } from '$app/environment';
import { canSeeBilling } from './roles';
export type Tier = 'tier-0' | 'tier-1' | 'tier-2' | 'tier-3';
export type Tier = 'tier-0' | 'tier-1' | 'tier-2' | 'tier-3' | 'cont-1';
export const roles = [
{
@@ -79,6 +79,8 @@ export function tierToPlan(tier: Tier) {
return tierScale;
case BillingPlan.GITHUB_EDUCATION:
return tierGitHubEducation;
case BillingPlan.CUSTOM:
return tierCustom;
default:
return tierFree;
}
@@ -189,6 +191,11 @@ export const tierScale: TierData = {
description: 'For scaling teams and agencies that need dedicated support.'
};
export const tierCustom: TierData = {
name: 'Custom',
description: 'Team on a custom contract'
};
export const showUsageRatesModal = writable<boolean>(false);
export function checkForUsageFees(plan: Tier, id: PlanServices) {
+6
View File
@@ -162,6 +162,12 @@ campaigns
description:
'Get $50 in Cloud credits when you upgrade or create an organization with a Pro plan'
})
.set('BandwidthUpdate', {
template: 'card',
title: 'Get everything out of the Cloud. Claim your $30 credits.',
description:
'Get $30 in Cloud credits when you upgrade or create an organization with a Pro plan.'
})
.set('FusionVC', {
template: 'review',
title: 'Welcome to Appwrite!',
+42
View File
@@ -0,0 +1,42 @@
import { derived, writable } from 'svelte/store';
import { page } from '$app/stores';
import { type Models, Query } from '@appwrite.io/console';
import { sdk } from '$lib/stores/sdk';
import { headerAlert } from '$lib/stores/headerAlert';
import BackupDatabase from '$lib/components/backupDatabaseAlert.svelte';
import { shouldShowNotification } from '$lib/helpers/notifications';
export const database = derived(page, ($page) => $page.data?.database as Models.Database);
export const backupsBannerId = 'banner:databaseBackups';
export const showPolicyAlert = writable<boolean>(false);
export async function checkForDatabaseBackupPolicies(database: Models.Database) {
// fast path: return if user dismissed the banner
if (!shouldShowNotification(backupsBannerId)) return;
let total = 0;
try {
const policies = await sdk.forProject.backups.listPolicies([
Query.limit(1),
Query.equal('resourceId', database.$id)
]);
total = policies.total;
} catch (e) {
// ignore, backups not allowed on free plan error.
}
showPolicyAlert.set(total <= 0);
if (!total) {
headerAlert.add({
id: backupsBannerId,
component: BackupDatabase,
show: true,
importance: 1
});
}
}
+18 -6
View File
@@ -1,6 +1,6 @@
import { browser } from '$app/environment';
import { VARS } from '$lib/system';
import { writable } from 'svelte/store';
import { get, writable } from 'svelte/store';
import type { SvelteComponent } from 'svelte';
import FeedbackGeneral from '$lib/components/feedback/feedbackGeneral.svelte';
import FeedbackNps from '$lib/components/feedback/feedbackNPS.svelte';
@@ -11,6 +11,7 @@ export type Feedback = {
notification: boolean;
type: 'nps' | 'general';
show: boolean;
source: string;
};
export type FeedbackData = {
@@ -69,13 +70,15 @@ function createFeedbackStore() {
visualized: browser ? (parseInt(localStorage.getItem('feedbackVisualized')) ?? 0) : 0,
notification: false,
type: 'general',
show: false
show: false,
source: 'n/a'
});
return {
subscribe,
update,
toggleFeedback: () => {
toggleFeedback: (source: string = 'n/a') => {
update((feedback) => {
feedback.source = source;
feedback.show = !feedback.show;
return feedback;
});
@@ -104,7 +107,8 @@ function createFeedbackStore() {
return feedback;
});
},
// TODO: update growth server to accept `billingPlan` and other keys.
// TODO: update growth server to accept `billingPlan`.
// TODO: update growth server to accept `source` key to know the feedback source area.
submitFeedback: async (
subject: string,
message: string,
@@ -129,15 +133,23 @@ function createFeedbackStore() {
// billingPlan,
firstname: name || 'Unknown',
customFields: [
{ id: '47364', currentPage },
{ id: '47364', value: currentPage },
...(value ? [{ id: '40655', value }] : [])
]
],
metaFields: {
source: get(feedback).source
}
})
});
// reset the state
get(feedback).source = 'n/a';
if (response.status >= 400) {
throw new Error('Failed to submit feedback');
}
}
};
}
export const feedback = createFeedbackStore();
+1 -1
View File
@@ -31,7 +31,7 @@ function createHeaderAlertStore() {
});
},
get: (): HeaderAlert => {
//return higest importance component
//return highest importance component
let component = {
id: '',
show: false,
+2
View File
@@ -22,6 +22,7 @@ import {
Vcs
} from '@appwrite.io/console';
import { Billing } from '../sdk/billing';
import { Backups } from '../sdk/backups';
import { Sources } from '$lib/sdk/sources';
export function getApiEndpoint(): string {
@@ -41,6 +42,7 @@ const sdkForProject = {
client: clientProject,
account: new Account(clientProject),
avatars: new Avatars(clientProject),
backups: new Backups(clientProject),
databases: new Databases(clientProject),
functions: new Functions(clientProject),
health: new Health(clientProject),
+3 -3
View File
@@ -36,7 +36,7 @@ export async function initializeStripe() {
// Set up the options for the stripe elements
const options = {
clientSecret: clientSecret,
appearance: get(app).themeInUse === 'dark' ? apperanceDark : apperanceLight
appearance: get(app).themeInUse === 'dark' ? appearanceDark : appearanceLight
};
// Set up Elements and then create form
elements = get(stripe).elements(options);
@@ -184,7 +184,7 @@ export async function confirmSetup(
}
}
const apperanceLight = {
const appearanceLight = {
variables: {
colorPrimary: '#606a7b',
colorText: 'rgb(107, 107, 112)',
@@ -213,7 +213,7 @@ const apperanceLight = {
}
};
const apperanceDark = {
const appearanceDark = {
variables: {
colorPrimary: '#606a7b',
colorText: 'rgb(195, 195, 198)',
+2 -2
View File
@@ -159,7 +159,7 @@
</div>
<div class="body-text-2">
{runtimeDetail.name}
{#if runtimeDetail.name.toLowerCase() === 'go'}
{#if runtimeDetail.name.toLowerCase() === 'deno'}
<span class="inline-tag">New</span>
{/if}
</div>
@@ -192,7 +192,7 @@
<Button
text
class="u-margin-block-start-24 u-margin-inline-start-auto"
href={`${base}/project-${$page.params.project}/functions/templates?useCase=Starter`}>
href={`${base}/project-${$page.params.project}/functions/templates?useCase=starter`}>
All starter templates <span class="icon-cheveron-right" />
</Button>
<div class="u-sep-block-start common-section" />
@@ -36,7 +36,7 @@
/**
*
* @param field {string} The field to update.
* Representes the path to the field in the formData object e.g. 'users.root'
* Represents the path to the field in the formData object e.g. 'users.root'
*/
function handleInputChange(field: FormKeys) {
return (event: Event) => {
+7
View File
@@ -9,6 +9,7 @@
import { app } from '$lib/stores/app';
import { log } from '$lib/stores/logs';
import { newOrgModal, organization } from '$lib/stores/organization';
import { database, checkForDatabaseBackupPolicies } from '$lib/stores/database';
import { wizard } from '$lib/stores/wizard';
import { afterUpdate, onMount } from 'svelte';
import { loading } from '$routes/store';
@@ -268,6 +269,12 @@
}
}
database.subscribe(async (database) => {
if (!database) return;
// the component checks `isCloud` internally.
await checkForDatabaseBackupPolicies(database);
});
organization.subscribe(async (org) => {
if (!org) return;
if (isCloud) {
@@ -10,7 +10,6 @@ export const load: PageLoad = async ({ depends }) => {
sdk.forConsole.billing.listPaymentMethods(),
sdk.forConsole.billing.listAddresses()
]);
return {
paymentMethods,
addressList
@@ -18,7 +18,6 @@
import EditAddressModal from './editAddressModal.svelte';
import type { Address } from '$lib/sdk/billing';
import { organizationList, type Organization } from '$lib/stores/organization';
import { tooltip } from '$lib/actions/tooltip';
import { base } from '$app/paths';
import { Pill } from '$lib/elements';
@@ -29,6 +28,7 @@
let showDelete = false;
let showDropdown = [];
let countryList: Models.CountryList;
let showLinked = [];
onMount(async () => {
countryList = await sdk.forProject.locale.listCountries();
@@ -76,26 +76,28 @@
</TableCell>
<TableCell style="vertical-align: top;">
{#if linkedOrgs?.length > 0}
<div
use:tooltip={{
interactive: true,
allowHTML: true,
trigger: 'click',
content: `
<div class="u-flex u-flex-vertical u-gap-8">
<p class="text">This billing address is linked to the following organizations:</p>
${linkedOrgs
.map(
(org) =>
`<a href="${base}/organization-${org.$id}/billing" class="link">${org.name}</a>`
)
.join('')}
</div>`
}}>
<Pill button>
<DropList bind:show={showLinked[i]} width="20" scrollable>
<Pill
button
on:click={() => (showLinked[i] = !showLinked[i])}>
<span class="icon-info" /> linked to organization
</Pill>
</div>
<svelte:fragment slot="list">
<p class="u-break-word">
This billing address is linked to the following
organizations:
</p>
<div class="u-flex u-flex-vertical u-gap-4">
{#each linkedOrgs as org}
<a
class="u-underline u-trim"
href={`${base}/console/organization-${org.$id}/billing`}>
{org.name}
</a>
{/each}
</div>
</svelte:fragment>
</DropList>
{/if}
</TableCell>
<TableCell style="vertical-align: top;">
@@ -20,7 +20,6 @@
import { paymentMethods } from '$lib/stores/billing';
import type { PaymentMethodData } from '$lib/sdk/billing';
import { organizationList, type Organization } from '$lib/stores/organization';
import { tooltip } from '$lib/actions/tooltip';
import { base } from '$app/paths';
import EditPaymentModal from './editPaymentModal.svelte';
import DeletePaymentModal from './deletePaymentModal.svelte';
@@ -34,6 +33,7 @@
let showDelete = false;
let showEdit = false;
let isLinked = false;
let showLinked = [];
$: orgList = $organizationList.teams as unknown as Organization[];
@@ -68,27 +68,34 @@
<CreditCardInfo {paymentMethod}>
<div class="u-flex u-gap-16 u-cross-center">
{#if linkedOrgs?.length > 0}
<div
use:tooltip={{
interactive: true,
allowHTML: true,
trigger: 'click',
content: `
<div class="u-flex u-flex-vertical u-gap-8">
<p class="text">This payment method is linked to the following organizations:</p>
${linkedOrgs
.map(
(org) =>
`<a href="${base}/organization-${org.$id}/billing" class="link">${org.name}</a>`
)
.join('')}
</div>`
}}>
<Pill button>
<DropList
bind:show={showLinked[i]}
width="20"
scrollable>
<Pill
button
on:click={() =>
(showLinked[i] = !showLinked[i])}>
<span class="icon-info" /> linked to organization
</Pill>
</div>
<svelte:fragment slot="list">
<p class="u-break-word">
This payment method is linked to the
following organizations:
</p>
<div class="u-flex u-flex-vertical u-gap-4">
{#each linkedOrgs as org}
<a
class="u-underline u-trim"
href={`${base}/console/organization-${org.$id}/billing`}>
{org.name}
</a>
{/each}
</div>
</svelte:fragment>
</DropList>
{/if}
<DropList
bind:show={showDropdown[i]}
placement="bottom-start"
@@ -19,7 +19,7 @@
async function updateEmail() {
try {
await sdk.forConsole.account.updateEmail(email, emailPassword);
await invalidate(Dependencies.ACCOUNT);
await Promise.all([invalidate(Dependencies.ACCOUNT), invalidate(Dependencies.FACTORS)]);
addNotification({
message: 'Email has been updated',
type: 'success'
+78 -1
View File
@@ -14,6 +14,8 @@
import type { Models } from '@appwrite.io/console';
import MfaRegenerateCodes from './mfaRegenerateCodes.svelte';
import { Pill } from '$lib/elements';
import { page } from '$app/stores';
import { onMount } from 'svelte';
let showSetup: boolean = false;
let showDelete: boolean = false;
@@ -26,6 +28,56 @@
.map(([factor, _]) => factor);
$: enabledMethods = enabledFactors.filter((factor) => factor !== 'recoveryCode');
$: cleanUrl = $page.url.origin + $page.url.pathname;
let creatingVerification = false;
async function createVerification() {
creatingVerification = true;
try {
await sdk.forConsole.account.createVerification(cleanUrl);
addNotification({
message: 'Verification email has been sent',
type: 'success'
});
} catch (error) {
addNotification({
message: error.message,
type: 'error'
});
} finally {
creatingVerification = false;
}
}
async function updateEmailVerification() {
const searchParams = $page.url.searchParams;
const userId = searchParams.get('userId');
const secret = searchParams.get('secret');
history.replaceState(null, '', cleanUrl);
if (userId && secret) {
try {
await sdk.forConsole.account.updateVerification(userId, secret);
addNotification({
message: 'Email verified successfully',
type: 'success'
});
await Promise.all([
invalidate(Dependencies.ACCOUNT),
invalidate(Dependencies.FACTORS)
]);
} catch (error) {
addNotification({
message: error.message,
type: 'error'
});
}
}
}
async function updateMfa() {
try {
await sdk.forConsole.account.updateMFA(!$user.mfa);
@@ -77,6 +129,10 @@
$: if (!showRecoveryCodes) {
codes = null;
}
onMount(() => {
updateEmailVerification();
});
</script>
<CardGrid>
@@ -159,13 +215,34 @@
</div>
</div>
</div>
{:else if !$user.emailVerification}
<div
class="u-flex u-main-space-between u-sep-block-end"
style="padding-block-end: 16px">
<div class="u-flex u-gap-8 u-cross-baseline">
<div class="avatar is-size-x-small">
<span class="icon-mail" aria-hidden="true" />
</div>
<div class="u-flex-vertical u-gap-4">
<div class="u-flex u-gap-4 u-cross-center">
<span class="body-text-2 u-bold">Email</span>
<Pill>unverified</Pill>
</div>
<span>Verify your email to receive login MFA codes.</span>
</div>
</div>
<Button
secondary
disabled={creatingVerification}
on:click={() => createVerification()}>Verify</Button>
</div>
{/if}
{#if $factors.phone}
<div
class="u-flex u-main-space-between u-sep-block-end"
style="padding-block-end: 16px">
<div class="u-flex u-gap-8">
<div class="u-flex u-gap-8 u-cross-baseline">
<div class="avatar is-size-x-small">
<span class="icon-send" aria-hidden="true" />
</div>
+24 -1
View File
@@ -1,11 +1,14 @@
import { base } from '$app/paths';
import RolesDark from '$lib/images/roles-dark.png';
import RolesLight from '$lib/images/roles-light.png';
import BackupsDark from '$lib/images/backups/promo/backups-dark.png';
import BackupsLight from '$lib/images/backups/promo/backups-light.png';
import { type BottomModalAlertItem, showBottomModalAlert } from '$lib/stores/bottom-alerts';
const listOfPromotions: BottomModalAlertItem[] = [
{
id: 'memberRoles',
id: 'modal:memberRoles',
src: {
dark: RolesDark,
light: RolesLight
@@ -24,6 +27,26 @@ const listOfPromotions: BottomModalAlertItem[] = [
text: 'Learn more',
link: () => 'https://appwrite.io/docs/advanced/platform/roles'
}
},
{
id: 'modal:databaseBackups',
src: {
dark: BackupsDark,
light: BackupsLight
},
title: 'Database Backups are available now',
message: 'Protect your data and ensure quick recovery with our new backups',
plan: 'pro',
scope: 'project',
importance: 8,
cta: {
text: 'Try now',
link: ({ project }) => `${base}/project-${project.$id}/databases`
},
learnMore: {
text: 'Learn more',
link: () => 'http://appwrite.io/docs/products/databases/backups'
}
}
];
@@ -17,8 +17,8 @@ export const load: LayoutLoad = async ({ params, depends }) => {
depends(Dependencies.ORGANIZATION);
depends(Dependencies.MEMBERS);
depends(Dependencies.PAYMENT_METHODS);
let roles = isCloud ? [] : defaultScopes;
let scopes = isCloud ? [] : defaultRoles;
let roles = isCloud ? [] : defaultRoles;
let scopes = isCloud ? [] : defaultScopes;
try {
if (isCloud) {
@@ -132,7 +132,7 @@
}
});
function findRegion(project: Partial<Models.Project & { region: string }>) {
function findRegion(project: Models.Project) {
const region = regions.regions.find((region) => region.$id === project.region);
return region;
}
@@ -10,6 +10,7 @@
import { addNotification } from '$lib/stores/notifications';
import { organization } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import RemoveAddress from './removeAddress.svelte';
import { user } from '$lib/stores/user';
import AddressModal from '$routes/(console)/account/payments/addressModal.svelte';
import EditAddressModal from '$routes/(console)/account/payments/editAddressModal.svelte';
@@ -22,6 +23,7 @@
let showCreate = false;
let showEdit = false;
let showReplace = false;
let showRemove = false;
async function addAddress(addressId: string) {
try {
@@ -100,6 +102,14 @@
}}>
Replace
</DropListItem>
<DropListItem
icon="trash"
on:click={() => {
showRemove = true;
showBillingAddressDropdown = false;
}}>
Remove
</DropListItem>
</svelte:fragment>
</DropList>
</div>
@@ -166,3 +176,6 @@
{#if showReplace}
<ReplaceAddress bind:show={showReplace} />
{/if}
{#if showRemove}
<RemoveAddress bind:show={showRemove} />
{/if}
@@ -6,17 +6,17 @@
import { sdk } from '$lib/stores/sdk';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { organization } from '$lib/stores/organization';
import { Dependencies } from '$lib/constants';
import { BillingPlan, Dependencies } from '$lib/constants';
export let showDelete = false;
export let isBackup = false;
export let disabled = false;
export let hasOtherMethod = false;
let error: string;
async function removeDefaultMethod() {
if (!$organization.paymentMethodId || !$organization.backupPaymentMethodId) return;
showDelete = false;
if ($organization?.billingPlan !== BillingPlan.FREE && !hasOtherMethod) return;
try {
await sdk.forConsole.billing.removeOrganizationPaymentMethod($organization.$id);
@@ -26,13 +26,16 @@
});
trackEvent(Submit.OrganizationPaymentDelete);
invalidate(Dependencies.ORGANIZATION);
showDelete = false;
} catch (e) {
error = e.message;
trackError(e, Submit.OrganizationPaymentDelete);
} finally {
showDelete = false;
}
}
async function removeBackuptMethod() {
if (!$organization.paymentMethodId || !$organization.backupPaymentMethodId) return;
if ($organization?.billingPlan !== BillingPlan.FREE && !hasOtherMethod) return;
showDelete = false;
try {
@@ -43,6 +46,7 @@
});
trackEvent(Submit.OrganizationBackupPaymentDelete);
invalidate(Dependencies.ORGANIZATION);
showDelete = false;
} catch (e) {
error = e.message;
trackError(e, Submit.OrganizationBackupPaymentDelete);
@@ -10,7 +10,7 @@
DropListItem,
Heading
} from '$lib/components';
import { Dependencies } from '$lib/constants';
import { BillingPlan, Dependencies } from '$lib/constants';
import { addNotification } from '$lib/stores/notifications';
import { organization } from '$lib/stores/organization';
import { Button } from '$lib/elements/forms';
@@ -325,10 +325,12 @@
<ReplaceCard bind:show={showReplace} isBackup={isSelectedBackup} />
{/if}
{#if showDelete && isCloud && hasStripePublicKey}
{@const hasOtherMethod = isSelectedBackup
? !!$organization?.paymentMethodId
: !!$organization?.backupPaymentMethodId}
<DeleteOrgPayment
bind:showDelete
{hasOtherMethod}
isBackup={isSelectedBackup}
disabled={isSelectedBackup
? !$organization?.paymentMethodId
: !$organization?.backupPaymentMethodId} />
disabled={$organization?.billingPlan !== BillingPlan.FREE && !hasOtherMethod} />
{/if}
@@ -17,6 +17,8 @@
let currentInvoice: Invoice;
let extraMembers = 0;
let currentPlan;
const today = new Date();
onMount(async () => {
const invoices = await sdk.forConsole.billing.listInvoices($organization.$id, [
@@ -26,9 +28,10 @@
currentInvoice = invoices.invoices[0];
const members = await sdk.forConsole.teams.listMemberships($organization.$id, []);
extraMembers = members.total > 1 ? members.total - 1 : 0;
currentPlan = await sdk.forConsole.billing.getPlan($organization?.$id);
});
$: currentPlan = $plansInfo?.get($organization?.billingPlan);
$: extraUsage = (currentInvoice?.amount ?? 0) - (currentPlan?.price ?? 0);
$: extraAddons = currentInvoice?.usage?.length ?? 0;
$: isTrial =
@@ -84,7 +87,8 @@
<h5 class="body-text-2 u-stretch">Additional members</h5>
<div>
{formatCurrency(
extraMembers * currentPlan.addons.member.price
extraMembers *
(currentPlan?.addons?.member?.price ?? 0)
)}
</div>
</div>
@@ -0,0 +1,47 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { Modal } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { organization } from '$lib/stores/organization';
import { Dependencies } from '$lib/constants';
export let show = false;
let error: string;
async function removeAddress() {
try {
await sdk.forConsole.billing.removeBillingAddress($organization.$id);
addNotification({
type: 'success',
message: `The billing address has been removed from ${$organization.name}`
});
trackEvent(Submit.OrganizationBillingAddressDelete);
invalidate(Dependencies.ORGANIZATION);
show = false;
} catch (e) {
error = e.message;
trackError(e, Submit.OrganizationBillingAddressDelete);
}
}
</script>
<Modal
bind:show
bind:error
onSubmit={removeAddress}
icon="exclamation"
state="warning"
headerDivider={false}
title="Remove billing address">
<p data-private>
Are you sure you want to remove the billing address from <b>{$organization?.name}</b>?
</p>
<svelte:fragment slot="footer">
<Button text on:click={() => (show = false)}>Cancel</Button>
<Button secondary submit>Remove</Button>
</svelte:fragment>
</Modal>
@@ -73,6 +73,7 @@
let feedbackDowngradeReason: string;
let feedbackMessage: string;
let selfService: boolean;
onMount(async () => {
if ($page.url.searchParams.has('code')) {
@@ -99,6 +100,9 @@
} else {
billingPlan = BillingPlan.PRO;
}
const currentPlan = await sdk.forConsole.billing.getPlan($organization?.$id);
selfService = currentPlan.selfService;
});
async function loadPaymentMethods() {
@@ -252,8 +256,14 @@
For more details on our plans, visit our
<Button href="https://appwrite.io/pricing" external link>pricing page</Button>.
</p>
{#if !selfService}
<Alert class="u-position-relative u-margin-block-start-16" type="info"
>Your contract is not eligible for manual changes. Please reach out to schedule
a call or setup a dialog.</Alert>
{/if}
<PlanSelection
bind:billingPlan
bind:selfService
anyOrgFree={!!anyOrgFree}
class={anyOrgFree && billingPlan !== BillingPlan.FREE
? 'u-margin-block-start-16'
@@ -323,14 +333,14 @@
{/if}
</Form>
<svelte:fragment slot="aside">
{#if billingPlan !== BillingPlan.FREE && $organization.billingPlan !== billingPlan}
{#if billingPlan !== BillingPlan.FREE && $organization.billingPlan !== billingPlan && $organization.billingPlan !== BillingPlan.CUSTOM}
<EstimatedTotalBox
{billingPlan}
{collaborators}
bind:couponData
bind:billingBudget
{isDowngrade} />
{:else}
{:else if $organization.billingPlan !== BillingPlan.CUSTOM}
<PlanComparisonBox downgrade={isDowngrade} />
{/if}
</svelte:fragment>
@@ -341,7 +351,7 @@
<Button
fullWidthMobile
on:click={() => formComponent.triggerSubmit()}
disabled={$isSubmitting || isButtonDisabled}>
disabled={$isSubmitting || isButtonDisabled || !selfService}>
Change plan
</Button>
</WizardSecondaryFooter>
@@ -5,6 +5,7 @@
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { members, organization } from '$lib/stores/organization';
import { projects } from '../store';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { onMount } from 'svelte';
@@ -12,6 +13,8 @@
import DownloadDPA from './downloadDPA.svelte';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { isCloud } from '$lib/system';
import Baa from './BAA.svelte';
import Soc2 from './Soc2.svelte';
export let data;
let name: string;
@@ -40,6 +43,8 @@
}
$: avatars = $members.memberships.map((team) => team.userName);
$: orgProjects = `${$projects.total} ${$projects.total === 1 ? 'project' : 'projects'}`;
$: orgMembers = `${$organization.total} ${$organization.total === 1 ? 'member' : 'members'}`;
</script>
<Container>
@@ -67,6 +72,8 @@
{#if isCloud}
<DownloadDPA />
<Baa />
<Soc2 />
{/if}
<CardGrid danger>
@@ -85,7 +92,7 @@
<svelte:fragment slot="title">
<h6 class="u-bold u-trim-1" data-private>{$organization.name}</h6>
</svelte:fragment>
<p>{$organization.total} members</p>
<p>{orgMembers}, {orgProjects}</p>
</BoxAvatar>
</svelte:fragment>
@@ -0,0 +1,36 @@
<script lang="ts">
import { Box, CardGrid, Heading } from '$lib/components';
import { Button } from '$lib/elements/forms';
import BaaModal from './BAAModal.svelte';
let show = false;
</script>
<CardGrid>
<div>
<Heading tag="h6" size="7">BAA</Heading>
</div>
<p class="text">After requesting a BAA, we will contact you via email for the next steps.</p>
<svelte:fragment slot="aside">
<Box>
<h6>
<b>Business Associate Agreement (BAA)</b>
</h6>
<p class="text u-margin-block-start-8">
A Business Associate Agreement (BAA) is a HIPAA-required document ensuring outside
services handling patient information for a healthcare organization follow privacy
rules.
</p>
<Button
secondary
external
class="u-margin-block-start-16"
on:click={() => (show = true)}
event="request_baa">
<span class="text">Request BAA</span>
</Button>
</Box>
</svelte:fragment>
</CardGrid>
<BaaModal bind:show />
@@ -0,0 +1,130 @@
<script lang="ts">
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Modal } from '$lib/components';
import { Button, FormList, InputEmail, InputSelect, InputText } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { organization } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import { user } from '$lib/stores/user';
import { VARS } from '$lib/system';
import { onMount } from 'svelte';
export let show = false;
let email = '';
let employees: string = null;
let employeesOptions = [
{
value: '1-5',
label: '1-5'
},
{
value: '6-10',
label: '6-10'
},
{
value: '11-50',
label: '11-50'
},
{
value: '50+',
label: '50+'
}
];
let country = '';
let countryOptions = [];
let role = '';
let error: string;
onMount(async () => {
const countryList = await sdk.forProject.locale.listCountries();
const locale = await sdk.forProject.locale.get();
if (locale.countryCode) {
country = locale.countryCode;
}
countryOptions = countryList.countries.map((country) => {
return {
value: country.code,
label: country.name
};
});
email = $user.email;
});
async function handleSubmit() {
const response = await fetch(`${VARS.GROWTH_ENDPOINT}/support`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
subject: 'support',
email: email,
firstName: $user?.name ?? '',
message: 'BAA',
tags: ['cloud'],
customFields: [
{ id: '41612', value: 'BAA' },
{ id: '48493', value: $user?.name ?? '' },
{ id: '48492', value: $organization?.$id ?? '' },
{ id: '48490', value: $user?.$id ?? '' }
],
metaFields: {
employees: employees,
country: country,
role: role
}
})
});
trackEvent(Submit.RequestBAA);
if (response.status !== 200) {
trackError(new Error(response.status.toString()), Submit.RequestBAA);
error = 'There was an error submitting your request. Please try again later.';
} else {
show = false;
addNotification({
message: `Your request was sent, we will get in contact with you at ${email} in a few working days`,
type: 'success'
});
}
}
</script>
<Modal
bind:error
bind:show
onSubmit={handleSubmit}
size="big"
title="Request BAA"
headerDivider={false}>
<FormList>
<InputEmail label="Email" placeholder="Enter email" id="email" bind:value={email} />
<InputSelect
label="Number of employees"
id="employees"
placeholder="Select number of employees"
required
options={employeesOptions}
bind:value={employees} />
<InputSelect
label="Country"
id="country"
options={countryOptions}
placeholder="Select country"
required
bind:value={country} />
<InputText
label="Your role"
placeholder="Enter your role"
id="role"
bind:value={role}
required />
<InputText label="Website" placeholder="Enter website" id="website" />
</FormList>
<svelte:fragment slot="footer">
<Button submit>
<span class="text">Send request</span>
</Button>
</svelte:fragment>
</Modal>
@@ -0,0 +1,36 @@
<script lang="ts">
import { Box, CardGrid, Heading } from '$lib/components';
import { Button } from '$lib/elements/forms';
import Soc2Modal from './Soc2Modal.svelte';
let show = false;
</script>
<CardGrid>
<div>
<Heading tag="h6" size="7">SOC-2</Heading>
</div>
<p class="text">After requesting SOC-2, we will contact you via email for the next steps.</p>
<svelte:fragment slot="aside">
<Box>
<h6>
<b>Service Organization Control Type 2 (SOC-2)</b>
</h6>
<p class="text u-margin-block-start-8">
SOC-2 is a framework for managing and protecting sensitive information, ensuring
compliance with trust service criteria such as security, availability, processing
integrity, confidentiality, and privacy.
</p>
<Button
secondary
external
class="u-margin-block-start-16"
on:click={() => (show = true)}
event="request_soc-2">
<span class="text">Request SOC-2</span>
</Button>
</Box>
</svelte:fragment>
</CardGrid>
<Soc2Modal bind:show />
@@ -0,0 +1,132 @@
<script lang="ts">
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Modal } from '$lib/components';
import { FormList, InputEmail, InputSelect, InputText } from '$lib/elements/forms';
import Button from '$lib/elements/forms/button.svelte';
import { addNotification } from '$lib/stores/notifications';
import { organization } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import { user } from '$lib/stores/user';
import { VARS } from '$lib/system';
import { onMount } from 'svelte';
export let show = false;
let email = '';
let employees: string = null;
let employeesOptions = [
{
value: '1-5',
label: '1-5'
},
{
value: '6-10',
label: '6-10'
},
{
value: '11-50',
label: '11-50'
},
{
value: '50+',
label: '50+'
}
];
let country = '';
let countryOptions = [];
let role = '';
let error: string;
onMount(async () => {
const countryList = await sdk.forProject.locale.listCountries();
const locale = await sdk.forProject.locale.get();
if (locale.countryCode) {
country = locale.countryCode;
}
countryOptions = countryList.countries.map((country) => {
return {
value: country.code,
label: country.name
};
});
email = $user.email;
});
async function handleSubmit() {
const response = await fetch(`${VARS.GROWTH_ENDPOINT}/support`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
subject: 'support',
email: email,
firstName: $user?.name ?? '',
message: 'SOC-2',
tags: ['cloud'],
customFields: [
{ id: '41612', value: 'SOC-2' },
{ id: '48493', value: $user?.name ?? '' },
{ id: '48492', value: $organization?.$id ?? '' },
{ id: '48490', value: $user?.$id ?? '' }
],
metaFields: {
employees: employees,
country: country,
role: role
}
})
});
trackEvent(Submit.RequestSoc2);
if (response.status !== 200) {
trackError(new Error(response.status.toString()), Submit.RequestSoc2);
error = 'There was an error submitting your request. Please try again later.';
} else {
show = false;
addNotification({
message: `Your request was sent, we will get in contact with you at ${email} in a few working days`,
type: 'success'
});
}
}
</script>
<Modal
bind:error
bind:show
onSubmit={handleSubmit}
size="big"
headerDivider={false}
title="Request SOC-2">
<FormList>
<InputEmail label="Email" placeholder="Enter email" id="email" bind:value={email} />
<InputSelect
label="Number of employees"
id="employees"
placeholder="Select number of employees"
required
options={employeesOptions}
bind:value={employees} />
<InputSelect
label="Country"
id="country"
options={countryOptions}
placeholder="Select country"
required
bind:value={country} />
<InputText
label="Your role"
placeholder="Enter your role"
id="role"
bind:value={role}
required />
<InputText label="Website" placeholder="Enter website" id="website" />
</FormList>
<svelte:fragment slot="footer">
<Button submit>
<span class="text">Send request</span>
</Button>
</svelte:fragment>
</Modal>
@@ -1,9 +1,9 @@
<script lang="ts">
import { Modal } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { Alert, Modal, SecondaryTabs, SecondaryTabsItem } from '$lib/components';
import { Button, FormList, InputText } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { organization, organizationList } from '$lib/stores/organization';
import { members, organization, organizationList } from '$lib/stores/organization';
import { goto, invalidate } from '$app/navigation';
import { base } from '$app/paths';
import { Dependencies } from '$lib/constants';
@@ -12,11 +12,28 @@
import { toLocaleDate } from '$lib/helpers/date';
import { isCloud } from '$lib/system';
import type { InvoiceList } from '$lib/sdk/billing';
import {
TableBody,
TableCell,
TableCellHead,
TableHeader,
TableRow,
TableScroll
} from '$lib/elements/table';
import { formatCurrency } from '$lib/helpers/numbers';
import { tierToPlan } from '$lib/stores/billing';
export let showDelete = false;
export let invoices: InvoiceList;
let error: string = null;
let selectedTab = 'projects';
let organizationName: string = null;
/* enable overflow-x */
const columnWidth = 120;
const columnWidthSmall = columnWidth / 4;
async function deleteOrg() {
try {
if (isCloud) {
@@ -46,34 +63,167 @@
}
}
const tabs = [
{
name: 'projects',
label: { desktop: 'Projects', mobile: 'Projects' },
total: $projects.total
},
{
name: 'members',
label: { desktop: 'Total Members', mobile: 'Members' },
total: $members.total
}
];
$: tabData =
selectedTab === 'projects'
? {
headers: ['Project', '', 'Last Updated'],
rows: $projects.projects.map((project) => ({
cells: [project.name, '', toLocaleDate(project.$updatedAt)]
}))
}
: {
headers: ['Member', '', 'Last Activity'],
rows: $members.memberships.map((member) => ({
cells: [member.userName, '', toLocaleDate(member.$updatedAt)]
}))
};
$: upcomingInvoice = invoices?.invoices.find((i) => i.status === 'upcoming' && i.amount > 0);
$: if (!showDelete) {
// reset on close.
organizationName = '';
}
</script>
<Modal
title="Delete organization"
onSubmit={deleteOrg}
bind:show={showDelete}
bind:error
icon="exclamation"
state="warning"
headerDivider={false}>
{#if upcomingInvoice}
<p data-private>
The organization <b>{$organization.name}</b> will be flagged for deletion.
</p>
<div class="max-height-dialog">
<Modal
title="Delete organization"
onSubmit={deleteOrg}
bind:show={showDelete}
bind:error
icon="exclamation"
state="warning"
headerDivider={false}>
{#if upcomingInvoice}
<Alert type="warning">
<span slot="title">
You have a pending {formatCurrency(upcomingInvoice.grossAmount)} invoice for your
{tierToPlan(upcomingInvoice.plan).name} plan
</span>
<p>
By proceeding, your invoice will be processed within the hour. Upon successful
payment, your organization will be deleted.
</p>
</Alert>
{/if}
<p data-private>
All existing projects will be paused and the organization will be deleted once your
upcoming invoice is processed on {toLocaleDate($organization.billingNextInvoiceDate)}.
{#if $projects.total > 0}
The following projects and all data associated with <b>{$organization.name}</b> will
be permanently deleted. <b>This action is irreversible</b>.
{:else}
All data associated with <b>{$organization.name}</b> will be permanently deleted.
<b>This action is irreversible</b>.
{/if}
</p>
{:else}
<p data-private>
Are you sure you want to delete <b>{$organization.name}</b>? All projects ({$projects.total})
and data associated with this organization will be deleted. This action is irreversible.
</p>
{/if}
<svelte:fragment slot="footer">
<Button text on:click={() => (showDelete = false)}>Cancel</Button>
<Button secondary submit>Delete</Button>
</svelte:fragment>
</Modal>
{#if $projects.total > 0}
<div class="box is-only-desktop">
<SecondaryTabs large stretch class="u-sep-block-end u-padding-8">
{#each tabs as { name, label, total }}
<SecondaryTabsItem
center
fullWidth
disabled={selectedTab === name}
on:click={() => (selectedTab = name)}>
{label.desktop} ({total})
</SecondaryTabsItem>
{/each}
</SecondaryTabs>
<TableScroll dense noMargin>
<TableHeader>
{#each tabData.headers as header}
<TableCellHead width={columnWidth}>{header}</TableCellHead>
{/each}
</TableHeader>
<TableBody>
{#each tabData.rows as row}
<TableRow>
{#each row.cells as cell}
<TableCell width={columnWidth}>{cell}</TableCell>
{/each}
</TableRow>
{/each}
</TableBody>
</TableScroll>
</div>
<div class="box is-not-desktop">
<SecondaryTabs large stretch class="u-sep-block-end u-padding-8">
{#each tabs as { name, label, total }}
<SecondaryTabsItem
center
fullWidth
disabled={selectedTab === name}
on:click={() => (selectedTab = name)}>
{label.mobile} ({total})
</SecondaryTabsItem>
{/each}
</SecondaryTabs>
<TableScroll dense noMargin>
<TableHeader>
{#each tabData.headers as header, index}
<TableCellHead width={index === 1 ? columnWidthSmall : columnWidth}
>{header}</TableCellHead>
{/each}
</TableHeader>
<TableBody>
{#each tabData.rows as row}
<TableRow>
{#each row.cells as cell, index}
<TableCell width={index === 1 ? columnWidthSmall : columnWidth}
>{cell}</TableCell>
{/each}
</TableRow>
{/each}
</TableBody>
</TableScroll>
</div>
{/if}
<FormList>
<InputText
label={`Confirm the organization name to continue`}
placeholder="Enter {$organization.name} to continue"
id="organization-name"
required
bind:value={organizationName} />
</FormList>
<svelte:fragment slot="footer">
<Button text on:click={() => (showDelete = false)}>Cancel</Button>
<Button
secondary
submit
disabled={!error && (!organizationName || organizationName !== $organization.name)}>
Delete
</Button>
</svelte:fragment>
</Modal>
</div>
<style>
.box {
padding: unset;
border-radius: var(--border-radius-medium, 8px);
}
:global(.max-height-dialog dialog) {
max-height: 650px;
}
</style>
@@ -237,7 +237,7 @@
<ProgressBarBig
currentUnit="Executions"
currentValue={formatNum(current)}
maxValue={`of ${formatNum(max)} Executions used`}
maxValue={`of ${formatNum(max)} executions used`}
progressValue={current}
progressMax={max}
showBar={false} />
@@ -283,8 +283,7 @@
<Heading tag="h6" size="7">Storage</Heading>
<p class="text">
Calculated for all your files, deployments, builds and databases. While in beta, only
file storage is counted against your plan limits.
Calculated for all your files, deployments, builds, databases and backups.
</p>
<svelte:fragment slot="aside">
@@ -301,6 +300,14 @@
label: `${Math.round(bytesToSize(data.organizationUsage.filesStorageTotal, 'GB') * 100) / 100}GB`
}
},
{
size: bytesToSize(data.organizationUsage.backupsStorageTotal, 'GB'),
color: '#68A3FE',
tooltip: {
title: 'Backups storage',
label: `${Math.round(bytesToSize(data.organizationUsage.backupsStorageTotal, 'GB') * 100) / 100}MB`
}
},
{
size: bytesToSize(data.organizationUsage.deploymentsStorageTotal, 'GB'),
color: '#7C67FE',
@@ -22,6 +22,7 @@ export const load: PageLoad = async ({ params, parent }) => {
filesStorageTotal: null,
buildsStorageTotal: null,
deploymentsStorageTotal: null,
backupsStorageTotal: null,
executions: null,
executionsTotal: null,
projects: null,
@@ -59,7 +59,7 @@
<svelte:fragment slot="title">Project breakdown</svelte:fragment>
<TableScroll noMargin>
<TableHeader>
<TableCellHead width={185}>Project</TableCellHead>
<TableCellHead width={185} style="padding-left: 0;">Project</TableCellHead>
<TableCellHead width={100}>Usage</TableCellHead>
{#if $canSeeProjects}
<TableCellHead width={140} />
@@ -68,7 +68,7 @@
<TableBody>
{#each groupByProject(metric).sort((a, b) => b.usage - a.usage) as project}
<TableRow>
<TableCellText title="Project">
<TableCellText title="Project" style="padding-left: 0;">
{getProjectName(project.projectId)}
</TableCellText>
<TableCellText title="Usage">{format(project.usage)}</TableCellText>
@@ -1,5 +1,5 @@
<script lang="ts">
import { MigrationBox, UploadBox } from '$lib/components';
import { BackupRestoreBox, MigrationBox, UploadBox } from '$lib/components';
import { sdk } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import { project, stats } from './store';
@@ -103,5 +103,29 @@
<slot />
<UploadBox />
<MigrationBox />
<div class="layout-level-progress-bars">
<UploadBox />
<MigrationBox />
<BackupRestoreBox />
</div>
<style>
.layout-level-progress-bars {
gap: 1rem;
display: flex;
flex-direction: column;
right: 0;
bottom: 0;
position: fixed;
padding: 1.5rem;
}
@media (max-width: 768px) {
.layout-level-progress-bars {
position: relative;
align-items: center;
}
}
</style>
@@ -17,8 +17,8 @@ export const load: LayoutLoad = async ({ params, depends }) => {
const newPrefs = { ...prefs, organization: project.teamId };
sdk.forConsole.account.updatePrefs(newPrefs);
preferences.loadTeamPrefs(project.teamId);
let roles = isCloud ? [] : defaultScopes;
let scopes = isCloud ? [] : defaultRoles;
let roles = isCloud ? [] : defaultRoles;
let scopes = isCloud ? [] : defaultScopes;
if (isCloud) {
const res = await sdk.forConsole.billing.getRoles(project.teamId);
roles = res.roles;
@@ -22,7 +22,6 @@
let numbers: Models.MockNumber[] = $project?.authMockNumbers ?? [];
let initialNumbers = [];
let projectId: string = $project.$id;
$: initialNumbers = $project?.authMockNumbers?.map((num) => ({ ...num })) ?? [];
$: isSubmitDisabled = JSON.stringify(numbers) === JSON.stringify(initialNumbers);
@@ -39,7 +38,7 @@
async function updateMockNumbers() {
try {
await sdk.forConsole.projects.updateMockNumbers(projectId, numbers);
await sdk.forConsole.projects.updateMockNumbers($project.$id, numbers);
await invalidate(Dependencies.PROJECT);
addNotification({
type: 'success',
@@ -8,13 +8,12 @@
import { sdk } from '$lib/stores/sdk';
import { project } from '../../store';
const projectId = $project.$id;
let passwordDictionary = $project.authPasswordDictionary ?? false;
let passwordDictionary = $project?.authPasswordDictionary ?? false;
async function updatePasswordDictionary() {
try {
await sdk.forConsole.projects.updateAuthPasswordDictionary(
projectId,
$project.$id,
passwordDictionary
);
await invalidate(Dependencies.PROJECT);
@@ -56,7 +55,7 @@
</svelte:fragment>
<svelte:fragment slot="actions">
<Button disabled={passwordDictionary === $project.authPasswordDictionary} submit
<Button disabled={passwordDictionary === $project?.authPasswordDictionary} submit
>Update</Button>
</svelte:fragment>
</CardGrid>
@@ -9,15 +9,14 @@
import { sdk } from '$lib/stores/sdk';
import { project } from '../../store';
const projectId = $project.$id;
let passwordHistory = $project.authPasswordHistory < 1 ? 5 : $project.authPasswordHistory;
let passwordHistoryEnabled = ($project.authPasswordHistory ?? 0) != 0;
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,
passwordHistoryEnabled ? passwordHistory : 0
);
await invalidate(Dependencies.PROJECT);
@@ -67,8 +66,8 @@
<svelte:fragment slot="actions">
<Button
disabled={(passwordHistory === $project.authPasswordHistory ||
$project.authPasswordHistory === 0) &&
disabled={(passwordHistory === $project?.authPasswordHistory ||
$project?.authPasswordHistory === 0) &&
initialPasswordHistoryEnabled === passwordHistoryEnabled}
submit>Update</Button>
</svelte:fragment>
@@ -9,7 +9,7 @@
import { sdk } from '$lib/stores/sdk';
import { project } from '../../store';
let authPersonalDataCheck = $project.authPersonalDataCheck ?? false;
let authPersonalDataCheck = $project?.authPersonalDataCheck ?? false;
async function updatePersonalDataCheck() {
try {
@@ -50,7 +50,7 @@
</svelte:fragment>
<svelte:fragment slot="actions">
<Button disabled={authPersonalDataCheck === $project.authPersonalDataCheck} submit
<Button disabled={authPersonalDataCheck === $project?.authPersonalDataCheck} submit
>Update</Button>
</svelte:fragment>
</CardGrid>
@@ -9,7 +9,7 @@
import { sdk } from '$lib/stores/sdk';
import { project } from '../../store';
let authSessionAlerts = $project.authSessionAlerts ?? false;
let authSessionAlerts = $project?.authSessionAlerts ?? false;
async function updateSessionAlerts() {
try {
@@ -46,7 +46,7 @@
</svelte:fragment>
<svelte:fragment slot="actions">
<Button disabled={authSessionAlerts === $project.authSessionAlerts} submit>
<Button disabled={authSessionAlerts === $project?.authSessionAlerts} submit>
Update
</Button>
</svelte:fragment>
@@ -9,13 +9,12 @@
import { sdk } from '$lib/stores/sdk';
import { project } from '../../store';
const projectId = $project.$id;
const { value, unit, baseValue, units } = createTimeUnitPair($project.authDuration);
const { value, unit, baseValue, units } = createTimeUnitPair($project?.authDuration);
const options = units.map((v) => ({ label: v.name, value: v.name }));
async function updateSessionLength() {
try {
await sdk.forConsole.projects.updateAuthDuration(projectId, $baseValue);
await sdk.forConsole.projects.updateAuthDuration($project.$id, $baseValue);
await invalidate(Dependencies.PROJECT);
addNotification({
@@ -8,13 +8,11 @@
import { sdk } from '$lib/stores/sdk';
import { project } from '../../store';
const projectId = $project.$id;
let maxSessions = $project.authSessionsLimit;
let maxSessions = $project?.authSessionsLimit;
async function updateSessionsLimit() {
try {
await sdk.forConsole.projects.updateAuthSessionsLimit(projectId, maxSessions);
await sdk.forConsole.projects.updateAuthSessionsLimit($project.$id, maxSessions);
await invalidate(Dependencies.PROJECT);
addNotification({
@@ -43,7 +41,7 @@
</svelte:fragment>
<svelte:fragment slot="actions">
<Button disabled={maxSessions === $project.authSessionsLimit} submit>Update</Button>
<Button disabled={maxSessions === $project?.authSessionsLimit} submit>Update</Button>
</svelte:fragment>
</CardGrid>
</Form>
@@ -10,15 +10,13 @@
import { sdk } from '$lib/stores/sdk';
import { project } from '../../store';
const projectId = $project.$id;
let isLimited = $project.authLimit !== 0;
let newLimit = isLimited ? $project.authLimit : 100;
let isLimited = $project?.authLimit !== 0;
let newLimit = isLimited ? $project?.authLimit : 100;
$: btnDisabled = (function isBtnDisabled() {
if (
(!isLimited && $project.authLimit === 0) ||
(isLimited && $project.authLimit === newLimit)
(!isLimited && $project?.authLimit === 0) ||
(isLimited && $project?.authLimit === newLimit)
) {
return true;
}
@@ -28,7 +26,7 @@
async function updateLimit() {
try {
await sdk.forConsole.projects.updateAuthLimit(projectId, isLimited ? newLimit : 0);
await sdk.forConsole.projects.updateAuthLimit($project?.$id, isLimited ? newLimit : 0);
await invalidate(Dependencies.PROJECT);
addNotification({
type: 'success',
@@ -33,11 +33,9 @@
<script lang="ts">
import { base } from '$app/paths';
import { page } from '$app/stores';
import { Alert, CardGrid, Collapsible, CollapsibleItem, Heading } from '$lib/components';
import { Container } from '$lib/layout';
import { sdk } from '$lib/stores/sdk';
import { project } from '../../store';
import { addNotification } from '$lib/stores/notifications';
import { onMount } from 'svelte';
import EmailVerificationTemplate from './emailVerificationTemplate.svelte';
@@ -63,7 +61,7 @@
EmailTemplateLocale
} from '@appwrite.io/console';
const projectId = $page.params.project;
export let data;
let emailOpen = 'verification';
$: emailVerificationOpen = emailOpen === 'verification';
@@ -86,7 +84,7 @@
async function openEmail(type: string) {
type === emailOpen ? (emailOpen = null) : (emailOpen = type);
$emailTemplate = await loadEmailTemplate(projectId, type, 'en');
$emailTemplate = await loadEmailTemplate(data.project.$id, type, 'en');
$baseEmailTemplate = { ...$emailTemplate };
}
@@ -105,7 +103,7 @@
</div>
</div>
{#if !$project.smtpEnabled}
{#if !data.project.smtpEnabled}
<div class="u-margin-block-start-24">
<Alert
isStandalone
@@ -114,7 +112,7 @@
buttons={[
{
slot: 'SMTP settings',
href: `${base}/project-${$project.$id}/settings/smtp`
href: `${base}/project-${data.project.$id}/settings/smtp`
}
]}>
<svelte:fragment slot="title">
@@ -216,7 +214,7 @@
</Collapsible>
</svelte:fragment>
<svelte:fragment slot="actions">
<Button href={`${base}/project-${$project.$id}/settings/smtp`} secondary>
<Button href={`${base}/project-${data.project.$id}/settings/smtp`} secondary>
SMTP settings
</Button>
</svelte:fragment>
@@ -1,7 +1,6 @@
import { sdk } from '$lib/stores/sdk';
import type { PageLoad } from './$types';
export const load: PageLoad = async () => {
export const load = async () => {
const codes = await sdk.forProject.locale.listCodes();
return {
localeCodes: codes.localeCodes
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff. Show more