Merge branch 'main' into feat-prop-1297-login-for-github-education-program
# Conflicts: # src/lib/constants.ts # src/lib/stores/billing.ts
No files matched your search
@@ -1,6 +1,7 @@
|
||||
.DS_Store
|
||||
.envrc
|
||||
.vscode
|
||||
.zed
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
<p class="body-text-2">
|
||||
{maxValue}
|
||||
{maxUnit && maxUnit}
|
||||
{maxUnit ? maxUnit : ''}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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.';
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 474 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 118 KiB |
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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!',
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -31,7 +31,7 @@ function createHeaderAlertStore() {
|
||||
});
|
||||
},
|
||||
get: (): HeaderAlert => {
|
||||
//return higest importance component
|
||||
//return highest importance component
|
||||
let component = {
|
||||
id: '',
|
||||
show: false,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||