Merge branch 'main' into database-deletion-new-flow.

This commit is contained in:
ItzNotABug
2024-10-03 12:47:59 +05:30
195 changed files with 6713 additions and 1971 deletions
+1
View File
@@ -41,6 +41,7 @@ jobs:
"PUBLIC_CONSOLE_MODE=cloud"
"PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}"
"PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY }}"
"SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}"
publish-cloud-stage:
runs-on: ubuntu-latest
steps:
+6
View File
@@ -150,3 +150,9 @@ dist
# SvelteKit build / generate output
.svelte-kit
# Sentry Config File
.sentryclirc
# IDE specifics
.idea
+3
View File
@@ -22,11 +22,14 @@ ARG PUBLIC_CONSOLE_MODE
ARG PUBLIC_APPWRITE_ENDPOINT
ARG PUBLIC_GROWTH_ENDPOINT
ARG PUBLIC_STRIPE_KEY
ARG SENTRY_AUTH_TOKEN
ENV PUBLIC_APPWRITE_ENDPOINT=$PUBLIC_APPWRITE_ENDPOINT
ENV PUBLIC_GROWTH_ENDPOINT=$PUBLIC_GROWTH_ENDPOINT
ENV PUBLIC_CONSOLE_MODE=$PUBLIC_CONSOLE_MODE
ENV PUBLIC_STRIPE_KEY=$PUBLIC_STRIPE_KEY
ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN
ENV NODE_OPTIONS=--max_old_space_size=8192
RUN pnpm run sync && pnpm run build
+1
View File
@@ -8,6 +8,7 @@ services:
PUBLIC_APPWRITE_ENDPOINT: ${PUBLIC_APPWRITE_ENDPOINT}
PUBLIC_GROWTH_ENDPOINT: ${PUBLIC_GROWTH_ENDPOINT}
PUBLIC_STRIPE_KEY: ${PUBLIC_STRIPE_KEY}
SENTRY_AUTH_TOKEN: ${SENTRY_AUTH_TOKEN}
develop:
watch:
- action: rebuild
-1
View File
@@ -7,7 +7,6 @@ map $sent_http_content_type $expires {
server {
listen 80;
listen [::]:80;
server_name localhost;
# serve compressed file if filename.gz exists
+1350 -224
View File
File diff suppressed because it is too large Load Diff
+16 -15
View File
@@ -19,15 +19,16 @@
"e2e:ui": "playwright test tests/e2e --ui"
},
"dependencies": {
"@appwrite.io/console": "1.0.1",
"@appwrite.io/console": "^1.2.0",
"@appwrite.io/pink": "0.25.0",
"@appwrite.io/pink-icons": "0.25.0",
"@popperjs/core": "^2.11.8",
"@sentry/sveltekit": "^8.31.0",
"@stripe/stripe-js": "^3.5.0",
"ai": "^2.2.37",
"analytics": "^0.8.14",
"cron-parser": "^4.9.0",
"dayjs": "^1.11.12",
"dayjs": "^1.11.13",
"deep-equal": "^2.2.3",
"echarts": "^5.5.1",
"envfile": "^7.1.0",
@@ -41,12 +42,12 @@
"devDependencies": {
"@melt-ui/pp": "^0.3.2",
"@melt-ui/svelte": "^0.83.0",
"@playwright/test": "^1.46.0",
"@sveltejs/adapter-static": "^3.0.4",
"@sveltejs/kit": "^2.5.22",
"@sveltejs/vite-plugin-svelte": "^3.1.1",
"@playwright/test": "^1.47.2",
"@sveltejs/adapter-static": "^3.0.5",
"@sveltejs/kit": "^2.5.28",
"@sveltejs/vite-plugin-svelte": "^3.1.2",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/svelte": "^5.2.1",
"@testing-library/user-event": "^14.5.2",
"@types/deep-equal": "^1.0.4",
@@ -54,22 +55,22 @@
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitest/ui": "^1.6.0",
"eslint": "^8.57.0",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.43.0",
"eslint-plugin-svelte": "^2.44.0",
"jsdom": "^22.1.0",
"kleur": "^4.1.5",
"prettier": "^3.3.3",
"prettier-plugin-svelte": "^3.2.6",
"sass": "^1.77.8",
"svelte": "^4.2.18",
"svelte-check": "^3.8.5",
"sass": "^1.79.3",
"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",
"tslib": "^2.6.3",
"typescript": "^5.5.4",
"vite": "^5.4.0",
"tslib": "^2.7.0",
"typescript": "^5.6.2",
"vite": "^5.4.7",
"vitest": "^1.6.0"
},
"type": "module",
+1 -1
View File
@@ -12,7 +12,7 @@ const config: PlaywrightTestConfig = {
webServer: {
timeout: 120000,
env: {
PUBLIC_APPWRITE_ENDPOINT: 'http://console-tests.appwrite.org/v1',
PUBLIC_APPWRITE_ENDPOINT: 'https://dlbilling.appwrite.org/v1',
PUBLIC_CONSOLE_MODE: 'cloud',
PUBLIC_STRIPE_KEY:
'pk_test_51LT5nsGYD1ySxNCyd7b304wPD8Y1XKKWR6hqo6cu3GIRwgvcVNzoZv4vKt5DfYXL1gRGw4JOqE19afwkJYJq1g3K004eVfpdWn'
+1672 -577
View File
File diff suppressed because it is too large Load Diff
+23 -10
View File
@@ -1,14 +1,27 @@
import * as Sentry from '@sentry/sveltekit';
import { AppwriteException } from '@appwrite.io/console';
import type { HandleClientError } from '@sveltejs/kit';
import { isCloud, isProd } from '$lib/system';
export const handleError: HandleClientError = async ({ error, message, status }) => {
if (error instanceof AppwriteException) {
status = error.code === 0 ? undefined : error.code;
message = error.message;
Sentry.init({
enabled: isCloud && isProd,
dsn: 'https://c7ce178bdedd486480317b72f282fd39@o1063647.ingest.us.sentry.io/4504158071422976',
tracesSampleRate: 1,
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: 1,
integrations: [Sentry.replayIntegration()]
});
export const handleError: HandleClientError = Sentry.handleErrorWithSentry(
async ({ error, message, status }) => {
if (error instanceof AppwriteException) {
status = error.code === 0 ? undefined : error.code;
message = error.message;
}
return {
message,
status
};
}
return {
message,
status
};
};
);
+14
View File
@@ -0,0 +1,14 @@
import { sequence } from '@sveltejs/kit/hooks';
import { handleErrorWithSentry, sentryHandle } from '@sentry/sveltekit';
import * as Sentry from '@sentry/sveltekit';
import { isCloud, isProd } from '$lib/system';
Sentry.init({
enabled: isCloud && isProd,
dsn: 'https://c7ce178bdedd486480317b72f282fd39@o1063647.ingest.us.sentry.io/4504158071422976',
tracesSampleRate: 1.0
});
export const handle = sequence(sentryHandle());
export const handleError = handleErrorWithSentry();
+4 -1
View File
@@ -184,6 +184,7 @@ export enum Submit {
ProjectUpdateSMTP = 'submit_project_update_smtp',
MemberCreate = 'submit_member_create',
MemberDelete = 'submit_member_delete',
MembershipUpdate = 'submit_membership_update',
MembershipUpdateStatus = 'submit_membership_update_status',
ProviderUpdate = 'submit_provider_update',
TeamCreate = 'submit_team_create',
@@ -320,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'
}
@@ -19,7 +19,7 @@
let budgetEnabled = false;
$: currentPlan = $plansInfo.get(billingPlan);
$: extraSeatsCost = (collaborators?.length ?? 0) * (currentPlan?.addons?.member?.price ?? 0);
$: extraSeatsCost = 0; // 0 untile trial period later replace (collaborators?.length ?? 0) * (currentPlan?.addons?.member?.price ?? 0);
$: grossCost = currentPlan.price + extraSeatsCost;
$: estimatedTotal =
couponData?.status === 'active'
@@ -1,7 +1,7 @@
<script lang="ts">
import { BillingPlan } from '$lib/constants';
import { formatNum } from '$lib/helpers/string';
import { plansInfo, tierFree, tierPro, tierScale, type Tier } from '$lib/stores/billing';
import { plansInfo, tierFree, tierPro, type Tier } from '$lib/stores/billing';
import { Card, SecondaryTabs, SecondaryTabsItem } from '..';
let selectedTab: Tier = BillingPlan.FREE;
@@ -23,11 +23,11 @@
on:click={() => (selectedTab = BillingPlan.PRO)}>
{tierPro.name}
</SecondaryTabsItem>
<SecondaryTabsItem
<!-- <SecondaryTabsItem
disabled={selectedTab === BillingPlan.SCALE}
on:click={() => (selectedTab = BillingPlan.SCALE)}>
{tierScale.name}
</SecondaryTabsItem>
</SecondaryTabsItem> -->
</SecondaryTabs>
</div>
+414
View File
@@ -0,0 +1,414 @@
<script lang="ts">
import { Button } from '$lib/elements/forms/index';
import { hideNotification, shouldShowNotification } from '$lib/helpers/notifications';
import { app } from '$lib/stores/app';
import {
type BottomModalAlertItem,
bottomModalAlerts,
dismissBottomModalAlert,
hideAllModalAlerts
} from '$lib/stores/bottom-alerts';
import { onMount } from 'svelte';
import { organization } from '$lib/stores/organization';
import { BillingPlan } from '$lib/constants';
import { upgradeURL } from '$lib/stores/billing';
import { addBottomModalAlerts } from '$routes/(console)/bottomAlerts';
import { project } from '$routes/(console)/project-[project]/store';
import { page } from '$app/stores';
let currentIndex = 0;
let openModalOnMobile = false;
function getPageScope(pathname: string) {
const isProjectPage = pathname.includes('project-[project]');
const isOrganizationPage = pathname.includes('organization-[organization]');
return { isProjectPage, isOrganizationPage };
}
function filterModalAlerts(alerts: BottomModalAlertItem[], pathname: string) {
const { isProjectPage, isOrganizationPage } = getPageScope(pathname);
return alerts
.sort((a, b) => b.importance - a.importance)
.filter((alert) => {
return (
alert.show &&
shouldShowNotification(alert.id) &&
// if no scope > show in projects & org pages.
((!alert.scope && (isProjectPage || isOrganizationPage)) ||
// project scope, show only in project pages
(isProjectPage && alert.scope === 'project') ||
// organization scope, show only in organization pages
(isOrganizationPage && alert.scope === 'organization'))
);
});
}
$: filteredModalAlerts = filterModalAlerts($bottomModalAlerts, $page.route.id);
$: currentModalAlert = filteredModalAlerts[currentIndex] as BottomModalAlertItem;
function handleClose() {
const modalAlert = currentModalAlert;
dismissBottomModalAlert(modalAlert.id);
hideNotification(modalAlert.id, { coolOffPeriod: 24 * 365 });
if (modalAlert.closed) modalAlert.closed();
if (currentIndex === filteredModalAlerts.length - 1 && filteredModalAlerts.length > 1) {
currentIndex = currentIndex - 1;
} else {
currentIndex = currentIndex % filteredModalAlerts.length;
}
}
function showNext() {
currentIndex = (currentIndex + 1) % filteredModalAlerts.length;
}
function showPrevious() {
currentIndex = (currentIndex - 1 + filteredModalAlerts.length) % filteredModalAlerts.length;
}
function showUpgrade() {
const plan = currentModalAlert.plan;
const organizationPlan = $organization.billingPlan;
switch (plan) {
case 'free':
return false;
case 'pro':
return organizationPlan === BillingPlan.FREE;
case 'scale':
return (
organizationPlan === BillingPlan.FREE || organizationPlan === BillingPlan.PRO
);
}
}
onMount(() => {
addBottomModalAlerts();
});
</script>
{#if filteredModalAlerts.length > 0 && currentModalAlert}
{@const shouldShowUpgrade = showUpgrade()}
<div class="main-alert-wrapper is-not-mobile">
<div class="alert-container">
<article class="card">
{#key currentModalAlert.id}
<button class="icon-inline-tag" on:click={() => handleClose()}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4.29289 4.29289C4.68342 3.90237 5.31658 3.90237 5.70711 4.29289L10 8.58579L14.2929 4.29289C14.6834 3.90237 15.3166 3.90237 15.7071 4.29289C16.0976 4.68342 16.0976 5.31658 15.7071 5.70711L11.4142 10L15.7071 14.2929C16.0976 14.6834 16.0976 15.3166 15.7071 15.7071C15.3166 16.0976 14.6834 16.0976 14.2929 15.7071L10 11.4142L5.70711 15.7071C5.31658 16.0976 4.68342 16.0976 4.29289 15.7071C3.90237 15.3166 3.90237 14.6834 4.29289 14.2929L8.58579 10L4.29289 5.70711C3.90237 5.31658 3.90237 4.68342 4.29289 4.29289Z"
fill="#97979B" />
</svg>
</button>
<div class="content-wrapper u-flex-vertical u-gap-16">
{#if $app.themeInUse === 'dark'}
<img
src={currentModalAlert.src.dark}
alt={currentModalAlert.title}
class="showcase-image u-image-object-fit-contain u-block u-only-dark" />
{:else}
<img
src={currentModalAlert.src.light}
alt={currentModalAlert.title}
class="showcase-image u-image-object-fit-contain u-block u-only-light" />
{/if}
{#if filteredModalAlerts.length > 1}
<div class="u-flex u-main-space-between u-cross-baseline">
<span class="inline-tag feature-count-tag">
Feature {currentIndex + 1} of {filteredModalAlerts.length}
</span>
<div class="u-flex u-gap-10">
<button
class="icon-cheveron-left"
on:click={showPrevious}
disabled={currentIndex === 0}
class:active={currentIndex > 0} />
<button
class="icon-cheveron-right"
on:click={showNext}
disabled={currentIndex === filteredModalAlerts.length - 1}
class:active={currentIndex !==
filteredModalAlerts.length - 1} />
</div>
</div>
{/if}
<div class="u-flex-vertical u-gap-4 u-padding-inline-8">
<h3 class="body-text-2 u-bold">{currentModalAlert.title}</h3>
<span class="u-width-fit-content">
{#if currentModalAlert.isHtml}
{@html currentModalAlert.message}
{:else}
{currentModalAlert.message}
{/if}
</span>
</div>
<div
class="buttons u-flex u-flex-vertical-mobile u-gap-4 u-padding-inline-8 u-padding-block-8">
<Button
secondary
class="button"
href={shouldShowUpgrade
? $upgradeURL
: currentModalAlert.cta.link({
organization: $organization,
project: $project
})}
external={!!currentModalAlert.cta.external}
fullWidthMobile>
{currentModalAlert.cta.text}
</Button>
{#if currentModalAlert.learnMore}
<Button
text
class="button"
external
fullWidthMobile
href={currentModalAlert.learnMore.link({
organization: $organization,
project: $project
})}>
{currentModalAlert.learnMore.text}
</Button>
{/if}
</div>
</div>
{/key}
</article>
</div>
</div>
<div class="main-alert-wrapper is-only-mobile" class:closed={!openModalOnMobile}>
{#if openModalOnMobile}
<div class="alert-container">
<article class="card">
{#key currentModalAlert.id}
<button class="icon-inline-tag" on:click={() => handleClose()}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4.29289 4.29289C4.68342 3.90237 5.31658 3.90237 5.70711 4.29289L10 8.58579L14.2929 4.29289C14.6834 3.90237 15.3166 3.90237 15.7071 4.29289C16.0976 4.68342 16.0976 5.31658 15.7071 5.70711L11.4142 10L15.7071 14.2929C16.0976 14.6834 16.0976 15.3166 15.7071 15.7071C15.3166 16.0976 14.6834 16.0976 14.2929 15.7071L10 11.4142L5.70711 15.7071C5.31658 16.0976 4.68342 16.0976 4.29289 15.7071C3.90237 15.3166 3.90237 14.6834 4.29289 14.2929L8.58579 10L4.29289 5.70711C3.90237 5.31658 3.90237 4.68342 4.29289 4.29289Z"
fill="#97979B" />
</svg>
</button>
<div class="content-wrapper u-flex-vertical u-gap-16">
{#if $app.themeInUse === 'dark'}
<img
src={currentModalAlert.src.dark}
alt={currentModalAlert.title}
class="showcase-image u-image-object-fit-contain u-block u-only-dark" />
{:else}
<img
src={currentModalAlert.src.light}
alt={currentModalAlert.title}
class="showcase-image u-image-object-fit-contain u-block u-only-light" />
{/if}
{#if filteredModalAlerts.length > 1}
<div class="u-flex u-main-space-between u-cross-baseline">
<span class="inline-tag feature-count-tag">
Feature {currentIndex + 1} of {filteredModalAlerts.length}
</span>
<div class="u-flex u-gap-10">
<button
class="icon-cheveron-left"
on:click={showPrevious}
disabled={currentIndex === 0}
class:active={currentIndex > 0} />
<button
class="icon-cheveron-right"
on:click={showNext}
disabled={currentIndex ===
filteredModalAlerts.length - 1}
class:active={currentIndex !==
filteredModalAlerts.length - 1} />
</div>
</div>
{/if}
<div class="u-flex-vertical u-gap-8 u-padding-inline-8">
<h3 class="body-text-2 u-bold">{currentModalAlert.title}</h3>
<span class="u-width-fit-content">
{#if currentModalAlert.isHtml}
{@html currentModalAlert.message}
{:else}
{currentModalAlert.message}
{/if}
</span>
</div>
<div
class="buttons u-flex u-flex-vertical-mobile u-gap-4 u-padding-inline-8 u-padding-block-8">
<Button
secondary
class="button"
href={shouldShowUpgrade
? $upgradeURL
: currentModalAlert.cta.link({
organization: $organization,
project: $project
})}
external={!!currentModalAlert.cta.external}
fullWidthMobile
on:click={() => (openModalOnMobile = false)}>
{shouldShowUpgrade
? 'Upgrade plan'
: currentModalAlert.cta.text}
</Button>
{#if currentModalAlert.learnMore}
<Button
text
class="button"
external
fullWidthMobile
on:click={() => (openModalOnMobile = false)}
href={currentModalAlert.learnMore.link({
organization: $organization,
project: $project
})}>
{currentModalAlert.learnMore.text}
</Button>
{/if}
</div>
</div>
{/key}
</article>
</div>
{:else}
<button
class:showing={!openModalOnMobile}
class="card notification-card u-width-full-line"
on:click={() => (openModalOnMobile = true)}>
<div class="u-flex-vertical u-gap-4">
<div class="u-flex u-cross-center u-main-space-between">
<h3 class="body-text-2 u-bold">New features available</h3>
<button on:click={hideAllModalAlerts}>
<span class="icon-x" />
</button>
</div>
<span class="u-width-fit-content">
Explore new features to enhance your projects and improve security.
</span>
</div>
</button>
{/if}
</div>
{/if}
<style>
.card {
padding: 0.5rem;
}
.main-alert-wrapper {
left: 1rem;
z-index: 25;
bottom: 1rem;
position: fixed;
max-width: 289px;
}
.feature-count-tag {
font-size: 12px;
font-weight: 400;
width: fit-content;
margin-inline-start: 0.5rem;
}
.icon-inline-tag {
top: 1rem;
right: 1rem;
background: #fff;
position: absolute;
display: inline-flex;
padding: var(--space-2, 4px);
border-radius: var(--border-radius-S, 8px);
border: hsl(var(--color-neutral-10)) solid 1px;
}
:global(.theme-dark) .icon-inline-tag {
background: #1d1d21;
border: hsl(var(--color-neutral-80)) solid 1px;
}
.u-gap-10 {
gap: 0.625rem;
}
.icon-cheveron-left,
.icon-cheveron-right {
opacity: 0.5;
color: #97979b;
width: var(--icon-size-M, 20px);
height: var(--icon-size-M, 20px);
}
.active {
opacity: 1;
}
@media (max-width: 768px) {
.main-alert-wrapper {
top: 50%;
left: 50%;
display: flex;
min-width: 100%;
min-height: 100%;
max-width: 100vw;
align-items: center;
justify-content: center;
backdrop-filter: blur(6px);
transform: translate(-50%, -50%);
}
.main-alert-wrapper.closed {
backdrop-filter: unset;
}
.notification-card {
padding: var(--space-5, 10px) var(--space-6, 12px);
}
.main-alert-wrapper:has(.card.notification-card) {
bottom: 0;
top: unset;
min-height: auto;
padding-inline: 0.5rem;
}
.alert-container {
max-width: 90vw;
}
}
</style>
+2 -1
View File
@@ -7,6 +7,7 @@
import { isCloud } from '$lib/system';
import CardPlanLimit from './cardPlanLimit.svelte';
export let showEmpty = true;
export let offset = 0;
export let total = 0;
export let event: string = null;
@@ -27,7 +28,7 @@
{#if total > 3 ? total < limit + offset : total % 2 !== 0}
{#if isCloud && serviceId && total >= planLimit}
<CardPlanLimit {service} />
{:else}
{:else if showEmpty}
<Empty on:click target={event}>
<slot name="empty" />
</Empty>
@@ -57,6 +57,9 @@
<style lang="scss">
// TODO: remove once pink is updated
.collapsible-item {
.collapsible-wrapper {
padding-left: 0;
}
.collapsible-wrapper.is-disabled {
cursor: not-allowed;
+1 -1
View File
@@ -8,7 +8,7 @@
</script>
<div class:box={isBox}>
<div class="u-flex u-main-space-between u-cross-start">
<div class="u-flex u-main-space-between u-cross-start" style="padding-block: 0.5rem;">
<div class="u-line-height-1-5 u-flex u-flex-vertical u-gap-2">
<span class="u-flex u-cross-center u-gap-8">
<p class="text u-bold">
+20 -4
View File
@@ -5,14 +5,28 @@
import EmptyDark from '$lib/images/empty-dark.svg';
import { Heading } from '.';
import { trackEvent } from '$lib/actions/analytics';
import { createEventDispatcher } from 'svelte';
export let single = false;
export let noMedia = false;
export let target: string = null;
export let href: string = null;
export let marginTop = false;
export let allowCreate = true;
const dispatch = createEventDispatcher();
function onClick(event) {
if (!allowCreate) {
return;
}
dispatch('click', event);
}
function track() {
if (!allowCreate) {
return;
}
if (target) {
trackEvent(`click_create_${target}`, {
from: 'empty'
@@ -28,8 +42,8 @@
{#if !noMedia}
<button
type="button"
on:click|preventDefault
on:click={track}
on:click={onClick}
aria-label="create {target}">
{#if $app.themeInUse === 'dark'}
<img src={EmptyDark} alt="create" aria-hidden="true" height="242" />
@@ -54,9 +68,11 @@
text
event="empty_documentation"
ariaLabel="create {target}">Documentation</Button>
<Button secondary on:click on:click={track}>
Create {target}
</Button>
{#if allowCreate}
<Button secondary on:click on:click={track}>
Create {target}
</Button>
{/if}
</div>
</slot>
</div>
+8 -2
View File
@@ -6,7 +6,10 @@
feedbackOptions,
feedbackData
} from '$lib/stores/feedback';
import { user } from '$lib/stores/user';
import { organization } from '$lib/stores/organization';
import { addNotification } from '$lib/stores/notifications';
import { page } from '$app/stores';
$: $selectedFeedback = feedbackOptions.find((option) => option.type === $feedback.type);
@@ -15,8 +18,11 @@
await feedback.submitFeedback(
`feedback-${$feedback.type}`,
$feedbackData.message,
$feedbackData.name,
$feedbackData.email
$user.name,
$user.email,
$organization.billingPlan,
$page.url.href,
$feedbackData.value
);
addNotification({
type: 'success',
@@ -1,15 +1,9 @@
<script lang="ts">
import { FormList, InputTextarea, InputText, InputEmail } from '$lib/elements/forms';
import { FormList, InputTextarea } from '$lib/elements/forms';
import { feedbackData } from '$lib/stores/feedback';
</script>
<FormList>
<InputText label="Name" id="name" bind:value={$feedbackData.name} placeholder="Enter name" />
<InputEmail
label="Email"
id="email"
bind:value={$feedbackData.email}
placeholder="Enter email" />
<InputTextarea
id="feedback"
placeholder="Your message here"
+1 -11
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { FormList, InputTextarea, InputText, InputEmail } from '$lib/elements/forms';
import { FormList, InputTextarea } from '$lib/elements/forms';
import { feedbackData } from '$lib/stores/feedback';
import Evaluation from './evaluation.svelte';
</script>
@@ -16,15 +16,5 @@
required
bind:value={$feedbackData.message}
showLabel={false} />
<InputText
label="Name"
id="name"
bind:value={$feedbackData.name}
placeholder="Enter name" />
<InputEmail
label="Email"
id="email"
bind:value={$feedbackData.email}
placeholder="Enter email" />
{/if}
</FormList>
+2
View File
@@ -5,6 +5,7 @@
export let tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
export let size: Size;
export let trimmed = true;
export let trimmedSecondLine = false;
export let id: string = null;
let classes = '';
export { classes as class };
@@ -15,6 +16,7 @@
this={tag}
class={`heading-level-${size} u-min-width-0 ${classes}`}
class:u-trim-1={trimmed}
class:u-trim-2={trimmedSecondLine}
{style}
{id}>
<slot />
+2 -1
View File
@@ -56,7 +56,7 @@ export { default as PaginationWithLimit } from './paginationWithLimit.svelte';
export { default as ClickableList } from './clickableList.svelte';
export { default as ClickableListItem } from './clickableListItem.svelte';
export { default as Id } from './id.svelte';
export { default as ProgressBar } from './progressBar.svelte';
export * from './progressbar';
export { default as ProgressBarBig } from './progressBarBig.svelte';
export { default as CreditCardInfo } from './creditCardInfo.svelte';
export { default as CreditCardBrandImage } from './creditCardBrandImage.svelte';
@@ -75,3 +75,4 @@ export { default as ModalSideCol } from './modalSideCol.svelte';
export { default as EmptyCardImageCloud } from './emptyCardImageCloud.svelte';
export { default as ImagePreview } from './imagePreview.svelte';
export { default as MfaChallengeFormList } from './mfaChallengeFormList.svelte';
export { default as BottomModalAlert } from './bottomModalAlert.svelte';
+20 -3
View File
@@ -1,4 +1,6 @@
<script context="module" lang="ts">
let inputDigitFields: InputDigits;
export async function verify(challenge: Models.MfaChallenge, code: string) {
try {
if (challenge == null) {
@@ -10,6 +12,7 @@
await invalidate(Dependencies.ACCOUNT);
trackEvent(Submit.AccountCreate);
} catch (error) {
inputDigitFields?.clearInputsAndRefocus();
trackError(error, Submit.AccountCreate);
throw error;
}
@@ -24,6 +27,7 @@
import { Dependencies } from '$lib/constants';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { AuthenticationFactor, type Models } from '@appwrite.io/console';
import { addNotification } from '$lib/stores/notifications';
export let factors: Models.MfaFactors & { recoveryCode: boolean };
/** If true, the form will be submitted automatically when the code is entered. */
@@ -40,8 +44,16 @@
async function createChallenge(factor: AuthenticationFactor) {
disabled = true;
challengeType = factor;
challenge = await sdk.forConsole.account.createMfaChallenge(factor);
disabled = false;
try {
challenge = await sdk.forConsole.account.createMfaChallenge(factor);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
} finally {
disabled = false;
}
}
onMount(async () => {
@@ -72,7 +84,12 @@
{:else if challengeType == AuthenticationFactor.Phone}
<p>A 6-digit verification code was sent to your phone, enter it below.</p>
{/if}
<InputDigits bind:value={code} required autofocus {autoSubmit} />
<InputDigits
bind:value={code}
required
autofocus
{autoSubmit}
bind:this={inputDigitFields} />
{/if}
{#if showVerifyButton}
<FormItem>
+5
View File
@@ -3,6 +3,7 @@
import { trackEvent } from '$lib/actions/analytics';
import { Form } from '$lib/elements/forms';
import { disableCommands } from '$lib/commandCenter';
import { beforeNavigate } from '$app/navigation';
export let show = false;
export let size: 'small' | 'big' | 'huge' = null;
@@ -19,6 +20,10 @@
let alert: HTMLElement;
beforeNavigate(() => {
show = false;
});
$: $disableCommands(show);
$: if (error) {
+23 -23
View File
@@ -1,36 +1,36 @@
<script lang="ts">
export let currentValue: string;
export let currentUnit: string;
export let maxValue: string;
export let maxUnit: string;
import { ProgressBar, type ProgressbarData } from '$lib/components/progressbar';
export let currentValue: string | undefined = undefined;
export let currentUnit: string | undefined = undefined;
export let maxValue: string | undefined = undefined;
export let maxUnit: string | undefined = undefined;
export let progressValue: number;
export let progressMax: number;
export let showBar = true;
export let minimum = 0;
export let maximum = 100;
export let progressBarData: Array<ProgressbarData> = [];
$: progress = Math.round((progressValue / progressMax) * 100);
</script>
<section class="progress-bar">
<div class="u-flex u-flex-vertical">
<div class="u-flex u-main-space-between">
<p>
<span class="heading-level-4">{currentValue}</span>
<span class="body-text-1 u-bold">{currentUnit}</span>
{#if currentValue !== undefined && currentUnit !== undefined && progress !== undefined && maxValue !== undefined}
<div class="u-flex u-flex-vertical">
<div class="u-flex u-main-space-between">
<p>
<span class="heading-level-4">{currentValue}</span>
<span class="body-text-1 u-bold">{currentUnit}</span>
</p>
<p class="heading-level-4">{progress}%</p>
</div>
<p class="body-text-2">
{maxValue}
{maxUnit ? maxUnit : ''}
</p>
<p class="heading-level-4">{progress}%</p>
</div>
<p class="body-text-2">
{maxValue}
{maxUnit}
</p>
</div>
{#if showBar}
<div
class="progress-bar-container u-margin-block-start-16"
class:is-warning={progress >= 75 && progress < 100}
class:is-danger={progress >= 100}
style:--graph-size={Math.max(Math.min(progress, maximum), minimum) + '%'} />
{/if}
{#if showBar && progressBarData.length > 0}
<ProgressBar maxSize={progressMax} data={progressBarData} />
{/if}
</section>
@@ -0,0 +1,93 @@
<script lang="ts">
import { tooltip } from '$lib/actions/tooltip';
import type { ProgressbarData, ProgressbarProps } from '$lib/components';
type $$Props = ProgressbarProps;
/**
* The max value of the progressbar
*/
export let maxSize: $$Props['maxSize'];
/**
* The data for the progressbar
*/
export let data: $$Props['data'];
/**
* The remaining value of the progressbar
*/
$: remainder = data.reduce((sum: number, item: ProgressbarData) => sum - item.size, maxSize);
</script>
<section class="progressbar__container">
{#each $$props.data as item}
<div
class="progressbar__content"
style:background-color={item.color}
style:width={`${(item.size / maxSize) * 100}%`}
use:tooltip={{
disabled: !item.tooltip,
allowHTML: true,
content: `<span class="u-bold">${item.tooltip.title}</span> ${item.tooltip.label}`
}}>
</div>
{/each}
{#if remainder > 0}
<div class="progressbar__content" style:width={`${(remainder / maxSize) * 100}%`} />
{/if}
</section>
<style lang="scss">
:root {
--progressbar-border-radius: 0.25rem;
--progressbar-tooltip-label-color: #818186;
--progressbar-tooltip-link-color: #6c6c71;
}
:global(.theme-dark) {
--progressbar-background-color: var(--neutral-800, #2d2d31);
--progressbar-tooltip-background-color: var(--neutral-800, #2d2d31);
--progressbar-tooltip-border-color: var(--neutral-80, #424248);
}
:global(.theme-light) {
--progressbar-background-color: var(--neutral-40, #f4f4f7);
--progressbar-tooltip-background-color: #ffffff;
--progressbar-tooltip-border-color: #ededf0;
}
.progressbar {
&__container {
height: 0.5rem;
background-color: var(--progressbar-background-color);
border-radius: var(--progressbar-border-radius);
display: flex;
flex-direction: row;
gap: 2px;
margin-top: 1rem;
}
&__content {
height: 100%;
min-width: 4px;
display: flex;
justify-content: center;
&::before {
content: '';
height: 2.5rem;
margin-top: -1.25rem;
width: 100%;
}
&:first-child {
border-top-left-radius: var(--progressbar-border-radius);
border-bottom-left-radius: var(--progressbar-border-radius);
}
&:last-child {
border-top-right-radius: var(--progressbar-border-radius);
border-bottom-right-radius: var(--progressbar-border-radius);
}
}
}
</style>
+17
View File
@@ -0,0 +1,17 @@
export type ProgressbarData = {
size: number;
color: string;
tooltip?: {
title: string;
label: string;
// linkTitle?: string;
// linkPath?: string;
};
};
export type ProgressbarProps = {
maxSize: number;
data: Array<ProgressbarData>;
};
export { default as ProgressBar } from './ProgressBar.svelte';
+21
View File
@@ -0,0 +1,21 @@
<div class="u-flex-vertical u-gap-16">
<slot />
</div>
<style>
div {
color: hsl(var(--color-neutral-50));
line-height: 1.25rem;
&:first-child {
color: hsl(var(--color-neutral-70));
}
}
:global(.theme-dark) div {
color: hsl(var(--color-neutral-20));
&:first-child {
color: hsl(var(--color-neutral-10));
}
}
</style>
+19
View File
@@ -0,0 +1,19 @@
<script lang="ts">
import Base from './base.svelte';
</script>
<Base>
<div class="u-flex-vertical u-gap-8">
<p>
By default, all members are assigned a <span class="u-bold">Developer</span> role. You can
change this at any time from your organization settings.
</p>
<p>
<a
class="link"
target="_blank"
rel="noopener noreferrer"
href="https://appwrite.io/docs/advanced/platform/roles">Learn more</a> about roles.
</p>
</div>
</Base>
+17
View File
@@ -0,0 +1,17 @@
<script>
import Base from './base.svelte';
</script>
<Base>
<div class="u-flex-vertical u-gap-8">
<p class="u-bold">Roles</p>
<p>Owner, Developer, Editor, Analyst and Billing.</p>
<p>
<a
class="link"
target="_blank"
rel="noopener noreferrer"
href="https://appwrite.io/docs/advanced/platform/roles">Learn more</a> about roles.
</p>
</div>
</Base>
+62
View File
@@ -0,0 +1,62 @@
<script>
import Base from './base.svelte';
import { upgradeURL } from '$lib/stores/billing';
import { isCloud } from '$lib/system';
import { organization } from '$lib/stores/organization';
import { BillingPlan } from '$lib/constants';
import Button from '$lib/elements/forms/button.svelte';
</script>
<Base>
{#if isCloud}
{#if $organization?.billingPlan !== BillingPlan.FREE}
<div class="u-flex-vertical u-gap-8">
<p>
<span class="u-bold">Roles</span>
{#if $organization?.billingPlan === BillingPlan.FREE}
<span class="inline-tag u-normal u-x-small">Pro plan</span>
{/if}
</p>
<p>Owner, Developer, Editor, Analyst and Billing.</p>
</div>
<p>
<Button link external href="https://appwrite.io/docs/advanced/platform/roles"
>Learn more</Button> about roles.
</p>
{:else}
<div class="u-flex-vertical u-gap-8">
<p>
<span class="u-bold">Roles</span>
{#if $organization?.billingPlan === BillingPlan.FREE}
<span class="inline-tag u-normal u-x-small">Pro plan</span>
{/if}
</p>
<p>
Upgrade to Pro to assign new roles to members such as Owner, Developer, Editor
or Analyst.
</p>
</div>
<p class="u-flex u-main-end u-cross-center u-gap-4">
<Button text external href="https://appwrite.io/docs/advanced/platform/roles"
>Learn more</Button>
<Button secondary external href={$upgradeURL}>Upgrade plan</Button>
</p>
{/if}
{:else}
<div class="u-flex-vertical u-gap-8">
<p>
<span class="u-bold">Roles</span>
<span class="inline-tag u-normal u-x-small">Appwrite Cloud</span>
</p>
<p>
Upgrade to Cloud to assign new roles to members or ask us about our entriprise self
hosted offering.
</p>
</div>
<p class="u-flex u-main-end u-cross-center u-gap-4">
<Button text external href="https://appwrite.io/docs/advanced/platform/roles"
>Learn more</Button>
<Button secondary external href={$upgradeURL}>Upgrade to Cloud</Button>
</p>
{/if}
</Base>
+59
View File
@@ -61,6 +61,65 @@ export enum Dependencies {
MESSAGING_TOPIC_SUBSCRIBERS = 'dependency:messaging_topic_subscribers'
}
export const defaultScopes: string[] = [
'global',
'public',
'home',
'console',
'graphql',
'sessions.write',
'account',
'teams.read',
'teams.write',
'documents.read',
'documents.write',
'files.read',
'files.write',
'projects.read',
'projects.write',
'locale.read',
'avatars.read',
'execution.read',
'execution.write',
'targets.read',
'targets.write',
'subscribers.write',
'subscribers.read',
'assistant.read',
'users.read',
'users.write',
'databases.read',
'databases.write',
'collections.read',
'collections.write',
'buckets.read',
'buckets.write',
'functions.read',
'functions.write',
'platforms.read',
'platforms.write',
'keys.read',
'keys.write',
'webhooks.read',
'webhooks.write',
'rules.read',
'rules.write',
'migrations.read',
'migrations.write',
'vcs.read',
'vcs.write',
'providers.read',
'providers.write',
'messages.read',
'messages.write',
'topics.read',
'topics.write',
'billing.read',
'billing.write'
];
export const defaultRoles: string[] = ['owner'];
export const scopes: {
scope: string;
description: string;
+15
View File
@@ -38,6 +38,21 @@
}
});
/**
* Clears the input fields and moves the focus to the first input.
* Usually used when resetting fields on auth fails, etc.
*/
export function clearInputsAndRefocus() {
value = '';
autoSubmitted = false;
if (element) {
const inputs = element.querySelectorAll('input');
inputs.forEach((input) => (input.value = ''));
if (autofocus) inputs[0].focus();
}
}
onMount(() => {
const interval = setInterval(() => {
const input = element.querySelector('input');
+17
View File
@@ -37,6 +37,13 @@
}
return true;
}
function isFileOverSize(file: File) {
if (maxSize && file.size > maxSize) {
return true;
}
return false;
}
function dropHandler(ev: DragEvent) {
ev.dataTransfer.dropEffect = 'move';
hovering = false;
@@ -47,6 +54,10 @@
error = 'Invalid file extension';
return;
}
if (isFileOverSize(ev.dataTransfer.items[i].getAsFile())) {
error = 'File size exceeds the limit';
return;
}
if (ev.dataTransfer.items[i].kind === 'file') {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(ev.dataTransfer.items[i].getAsFile());
@@ -86,12 +97,18 @@
const fileExtension = file.name.split('.').pop();
return isFileExtensionAllowed(fileExtension);
});
const isOverSize = maxSize && Array.from(target.files).some((file) => isFileOverSize(file));
if (!isValidFiles) {
error = 'Invalid file extension';
target.value = '';
return;
}
if (isOverSize) {
error = 'File size exceeds the limit';
target.value = '';
return;
}
setFiles(target.files);
};
+29 -1
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import { FormItem, FormItemPart, Helper, Label } from '.';
import type { FormItemTag } from './formItem.svelte';
import { Drop } from '$lib/components';
import type { SvelteComponent } from 'svelte';
export let id: string;
export let label: string | undefined = undefined;
@@ -19,10 +21,14 @@
}[];
export let isMultiple = false;
export let fullWidth = false;
export let popover: typeof SvelteComponent<unknown> = null;
export let popoverProps: Record<string, unknown> = {};
let element: HTMLSelectElement;
let error: string;
let show: boolean = false;
const handleInvalid = (event: Event) => {
event.preventDefault();
@@ -54,7 +60,29 @@
<svelte:component this={wrapper} {fullWidth} tag={wrapperTag}>
{#if label}
<Label {required} {hideRequired} {optionalText} hide={!showLabel} for={id}>
{label}
{label}{#if popover}
<Drop isPopover bind:show display="inline-block">
&nbsp;<button
type="button"
on:click={() => (show = !show)}
class="tooltip"
aria-label="input tooltip">
<span
class="icon-info"
aria-hidden="true"
style="font-size: var(--icon-size-small)" />
</button>
<svelte:fragment slot="list">
<div
class="dropped card u-max-width-250"
style:--card-border-radius="var(--border-radius-small)"
style:--p-card-padding=".75rem"
style:box-shadow="var(--shadow-large)">
<svelte:component this={popover} {...popoverProps} />
</div>
</svelte:fragment>
</Drop>
{/if}
</Label>
{/if}
+28 -2
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import { last } from '$lib/helpers/array';
import { onMount } from 'svelte';
import { onMount, SvelteComponent } from 'svelte';
import { FormItem, Helper, Label } from '.';
import { Drop } from '$lib/components';
export let id: string;
export let label: string;
@@ -15,11 +16,14 @@
export let tooltip: string = null;
export let validityRegex: RegExp = null;
export let validityMessage: string = null;
export let popover: typeof SvelteComponent<unknown> = null;
export let popoverProps: Record<string, unknown> = {};
let value = '';
let element: HTMLInputElement;
let hiddenEl: HTMLInputElement;
let error: string;
let show: boolean = false;
onMount(() => {
if (element && autofocus) {
@@ -90,7 +94,29 @@
{required}
on:invalid={handleInvalid} />
<Label {required} {tooltip} hide={!showLabel} for={id}>
{label}
{label}{#if popover}
<Drop isPopover bind:show display="inline-block">
&nbsp;<button
type="button"
on:click={() => (show = !show)}
class="tooltip"
aria-label="input tooltip">
<span
class="icon-info"
aria-hidden="true"
style="font-size: var(--icon-size-small)" />
</button>
<svelte:fragment slot="list">
<div
class="dropped card u-max-width-250"
style:--card-border-radius="var(--border-radius-small)"
style:--p-card-padding=".75rem"
style:box-shadow="var(--shadow-large)">
<svelte:component this={popover} {...popoverProps} />
</div>
</svelte:fragment>
</Drop>
{/if}
</Label>
<div class="input-text-wrapper">
+6
View File
@@ -21,3 +21,9 @@
data-private>
<slot />
</svelte:element>
<style>
:global(.table .button.is-text) {
--p-text-color-default: initial !important;
}
</style>
+5 -1
View File
@@ -45,7 +45,11 @@
};
</script>
<div class="table-with-scroll {classes}" class:u-margin-block-start-16={!noMargin} data-private>
<div
class="table-with-scroll {classes}"
style:border-radius={noStyles ? '0' : ''}
class:u-margin-block-start-16={!noMargin}
data-private>
<div class="table-wrapper" use:hasOverflow={(v) => (isOverflowing = v)}>
<table
class="table"
+7 -1
View File
@@ -36,7 +36,13 @@ export function getQuery(url: URL): string | undefined {
return url.searchParams.get('query') ?? undefined;
}
export type TabElement = { href: string; title: string; event: string; hasChildren?: boolean };
export type TabElement = {
href: string;
title: string;
event: string;
hasChildren?: boolean;
disabled?: boolean;
};
export function isTabSelected(
tab: TabElement,
+108
View File
@@ -0,0 +1,108 @@
import { sdk } from '$lib/stores/sdk';
import { get } from 'svelte/store';
import { user } from '$lib/stores/user';
export type NotificationPrefItem = {
expiry: number;
hideCount: number;
state: 'hidden' | 'shown' | undefined;
};
export type NotificationCoolOffOptions = {
coolOffPeriod?: number;
exponentialBackoff?: boolean;
exponentialBackoffFactor?: number;
};
const userPreferences = () => get(user).prefs;
const notificationPrefs = (): Record<string, NotificationPrefItem> => {
const prefs = userPreferences();
return prefs.notificationPrefs ? prefs.notificationPrefs : {};
};
function updateNotificationPrefs(parsedPrefs: Record<string, NotificationPrefItem>) {
const currentPrefs = userPreferences();
const newPrefs = {
...currentPrefs,
notificationPrefs: parsedPrefs
};
sdk.forConsole.account.updatePrefs(newPrefs);
}
/**
* Hides the notification banner by marking it as 'hidden' and setting an expiry time.
* Supports normal cool-off periods or exponential backoff based on the options passed.
*
* @param {string} id - The ID of the notification.
* @param {NotificationCoolOffOptions} [options] - Configuration for cool-off behavior.
* @param {number} [options.coolOffPeriod=24] - Cool-off period in hours, defaults to 24 hours.
* @param {boolean} [options.exponentialBackoff=false] - If true, the cool-off period doubles with each hide. Consider using a smaller `coolOffPeriod` when this option is enabled.
* @param {number} [options.exponentialBackoffFactor=2] - The factor by which the cool-off period is multiplied with each hide. Default is 2.
*/
export function hideNotification(id: string, options: NotificationCoolOffOptions = {}) {
const {
coolOffPeriod = 24,
exponentialBackoff = false,
exponentialBackoffFactor = 2
} = options;
const parsedBannerPrefs = notificationPrefs();
let expiryTime = Date.now() + coolOffPeriod * 3600000;
let hideCount = parsedBannerPrefs[id]?.hideCount || 0;
if (exponentialBackoff) {
hideCount += 1;
expiryTime =
Date.now() + coolOffPeriod * 3600000 * exponentialBackoffFactor ** (hideCount - 1);
}
parsedBannerPrefs[id] = {
hideCount,
state: 'hidden',
expiry: expiryTime
};
updateNotificationPrefs(parsedBannerPrefs);
}
/**
* Removes the notification preference for the given ID from the user's preferences.
*
* @param {string} id - The ID of the notification to remove from preferences.
*/
export function removeNotificationFromPrefs(id: string) {
const parsedBannerPrefs = notificationPrefs();
if (!parsedBannerPrefs[id]) return;
delete parsedBannerPrefs[id];
updateNotificationPrefs(parsedBannerPrefs);
}
/**
* Checks if the notification banner should be shown based on the expiry time.
*
* @param {string} id - The ID of the notification.
* @returns {boolean} - Returns true if the banner should be shown, false otherwise.
*/
export function shouldShowNotification(id: string): boolean {
const parsedBannerPrefs = notificationPrefs();
const notificationPref = parsedBannerPrefs[id];
if (!notificationPref) return true;
if (Date.now() >= notificationPref.expiry) {
notificationPref.state = 'shown';
updateNotificationPrefs(parsedBannerPrefs);
return true;
}
return false;
}
+5
View File
@@ -14,6 +14,7 @@ export function calculateSize(bytes: number, decimals = 1, base: 1000 | 1024 = 1
}
export function sizeToBytes(value: number, unit: Size, base = 1000) {
if (typeof value !== 'number') return 0;
const index = sizes.indexOf(unit);
return value * Math.pow(base, index);
}
@@ -23,6 +24,10 @@ export function bytesToSize(value: number, unit: Size, base = 1000) {
return value / Math.pow(base, index);
}
export function mbSecondsToGBHours(value: number, base: 1000 | 1024 = 1000) {
return value / base / (60 * 60);
}
export function humanFileSize(
bytes: number,
useBits = false
+1 -1
View File
@@ -70,7 +70,7 @@ export function createTimeUnitPair(initialValue = 0) {
{ name: 'Minutes', value: 60 },
{ name: 'Seconds', value: 1 }
];
return { ...createValueUnitPair(initialValue, units), units };
return { ...createValueUnitPair(initialValue || 0, units), units };
}
export function createByteUnitPair(initialValue = 0, base: 1000 | 1024 = 1000) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

+2 -9
View File
@@ -1,6 +1,4 @@
<script>
import { settings } from '$lib/components/consent.svelte';
import { clickOnEnter } from '$lib/helpers/a11y';
import { isCloud } from '$lib/system';
import { version } from '$routes/(console)/store';
@@ -44,14 +42,9 @@
</li>
{#if isCloud}
<li class="inline-links-item">
<span
style:cursor="pointer"
role="button"
tabindex="0"
on:keyup={clickOnEnter}
on:click={() => settings.set(true)}>
<a href="https://appwrite.io/cookies" target="_blank" rel="noreferrer">
<span class="text">Cookies</span>
</span>
</a>
</li>
{/if}
</ul>
+20 -17
View File
@@ -8,6 +8,7 @@
import { slide } from '$lib/helpers/transition';
import { upgradeURL } from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
import { canSeeDatabases } from '$lib/stores/roles';
import { wizard } from '$lib/stores/wizard';
import { isCloud } from '$lib/system';
import Create from '$routes/(console)/feedbackWizard.svelte';
@@ -95,23 +96,25 @@
<span class="text">Auth</span>
</a>
</li>
<li class="drop-list-item">
<a
class="drop-button"
class:is-selected={$page.url.pathname.startsWith(
`${projectPath}/databases`
)}
on:click={() => trackEvent('click_menu_databases')}
href={`${projectPath}/databases`}
use:tooltip={{
content: 'Databases',
placement: 'right',
disabled: !narrow
}}>
<span class="icon-database" aria-hidden="true" />
<span class="text">Databases</span>
</a>
</li>
{#if $canSeeDatabases}
<li class="drop-list-item">
<a
class="drop-button"
class:is-selected={$page.url.pathname.startsWith(
`${projectPath}/databases`
)}
on:click={() => trackEvent('click_menu_databases')}
href={`${projectPath}/databases`}
use:tooltip={{
content: 'Databases',
placement: 'right',
disabled: !narrow
}}>
<span class="icon-database" aria-hidden="true" />
<span class="text">Databases</span>
</a>
</li>
{/if}
<li class="drop-list-item">
<a
class="drop-button"
+44 -36
View File
@@ -20,6 +20,7 @@
import Delete from './delete.svelte';
import Retry from './wizard/retry.svelte';
import { Pill } from '$lib/elements';
import { canWriteRules } from '$lib/stores/roles';
export let rules: Models.ProxyRuleList;
export let type: ResourceType;
@@ -55,9 +56,11 @@
<slot name="heading" />
</Heading>
<Button on:click={openWizard}>
<span class="icon-plus" aria-hidden="true" /> <span class="text">Create domain</span>
</Button>
{#if $canWriteRules}
<Button on:click={openWizard}>
<span class="icon-plus" aria-hidden="true" /> <span class="text">Create domain</span>
</Button>
{/if}
</div>
{#if rules.total}
<TableScroll>
@@ -65,7 +68,9 @@
<TableCellHead width={150}>Name</TableCellHead>
<TableCellHead width={120}>Verification Status</TableCellHead>
<TableCellHead width={180}>Certificate Status</TableCellHead>
<TableCellHead width={40} />
{#if $canWriteRules}
<TableCellHead width={40} />
{/if}
</TableHeader>
<TableBody>
{#each rules.rules as domain, i}
@@ -134,40 +139,43 @@
</Pill>
{/if}
</TableCell>
<TableCell right>
<DropList
bind:show={showDomainsDropdown[i]}
placement="bottom-start"
noArrow>
<Button
text
round
ariaLabel="more options"
on:click={() => (showDomainsDropdown[i] = !showDomainsDropdown[i])}>
<span class="icon-dots-horizontal" aria-hidden="true" />
</Button>
<svelte:fragment slot="list">
{#if domain.status !== 'verified'}
{#if $canWriteRules}
<TableCell right>
<DropList
bind:show={showDomainsDropdown[i]}
placement="bottom-start"
noArrow>
<Button
text
round
ariaLabel="more options"
on:click={() =>
(showDomainsDropdown[i] = !showDomainsDropdown[i])}>
<span class="icon-dots-horizontal" aria-hidden="true" />
</Button>
<svelte:fragment slot="list">
{#if domain.status !== 'verified'}
<DropListItem
icon="refresh"
on:click={() => openRetry(domain, i)}>
{domain.status === 'unverified'
? 'Retry generation'
: 'Retry verification'}
</DropListItem>
{/if}
<DropListItem
icon="refresh"
on:click={() => openRetry(domain, i)}>
{domain.status === 'unverified'
? 'Retry generation'
: 'Retry verification'}
icon="trash"
on:click={() => {
selectedDomain = domain;
showDelete = true;
showDomainsDropdown[i] = false;
}}>
Delete
</DropListItem>
{/if}
<DropListItem
icon="trash"
on:click={() => {
selectedDomain = domain;
showDelete = true;
showDomainsDropdown[i] = false;
}}>
Delete
</DropListItem>
</svelte:fragment>
</DropList>
</TableCell>
</svelte:fragment>
</DropList>
</TableCell>
{/if}
</TableRow>
{/each}
</TableBody>
+20
View File
@@ -9,6 +9,7 @@ export type PaymentMethodData = {
$updatedAt: string;
providerMethodId: string;
providerUserId: string;
userId: string;
expiryMonth: number;
expiryYear: number;
expired: boolean;
@@ -178,6 +179,11 @@ export type OrganizationUsage = {
bandwidth: Array<Models.Metric>;
executions: Array<Models.Metric>;
executionsTotal: number;
filesStorageTotal: number;
buildsStorageTotal: number;
deploymentsStorageTotal: number;
executionsMBSecondsTotal: number;
buildsMBSecondsTotal: number;
storageTotal: number;
users: Array<Models.Metric>;
usersTotal: number;
@@ -228,6 +234,7 @@ export type Address = {
city: string;
state?: string;
postalCode: string;
userId: string;
};
export type AddressesList = {
@@ -279,6 +286,11 @@ export type PlansInfo = {
export type PlansMap = Map<Tier, Plan>;
export type Roles = {
scopes: string[];
roles: string[];
};
export class Billing {
client: Client;
@@ -360,6 +372,14 @@ export class Billing {
);
}
async getRoles(organizationId: string): Promise<Roles> {
const path = `/organizations/${organizationId}/roles`;
const uri = new URL(this.client.config.endpoint + path);
return await this.client.call('get', uri, {
'content-type': 'application/json'
});
}
async updatePlan(
organizationId: string,
billingPlan: string,
+32
View File
@@ -32,15 +32,43 @@ import { last } from '$lib/helpers/array';
import { sizeToBytes, type Size } from '$lib/helpers/sizeConvertion';
import { user } from './user';
import { browser } from '$app/environment';
import { canSeeBilling } from './roles';
export type Tier = 'tier-0' | 'tier-1' | 'tier-2';
export const roles = [
{
label: 'Owner',
value: 'owner'
},
{
label: 'Developer',
value: 'developer'
},
{
label: 'Editor',
value: 'editor'
},
{
label: 'Analyst',
value: 'analyst'
},
{
label: 'Billing',
value: 'billing'
}
];
export const paymentMethods = derived(page, ($page) => $page.data.paymentMethods as PaymentList);
export const addressList = derived(page, ($page) => $page.data.addressList as AddressesList);
export const plansInfo = derived(page, ($page) => $page.data.plansInfo as PlansMap);
export const daysLeftInTrial = writable<number>(0);
export const readOnly = writable<boolean>(false);
export function getRoleLabel(role: string) {
return roles.find((r) => r.value === role)?.label ?? role;
}
export function tierToPlan(tier: Tier) {
switch (tier) {
case BillingPlan.FREE:
@@ -116,6 +144,7 @@ export const failedInvoice = cachedStore<
return {
load: async (orgId) => {
if (!isCloud) set(null);
if (!get(canSeeBilling)) set(null);
const invoices = await sdk.forConsole.billing.listInvoices(orgId);
const failedInvoices = invoices.invoices.filter((i) => i.status === 'failed');
// const failedInvoices = invoices.invoices;
@@ -306,6 +335,8 @@ export async function paymentExpired(org: Organization) {
org.paymentMethodId
);
if (!payment?.expiryYear) return;
const sessionStorageNotification = sessionStorage.getItem('expiredPaymentNotification');
if (sessionStorageNotification === 'true') return;
const year = new Date().getFullYear();
const month = new Date().getMonth();
const expiredMessage = `The default payment method for <b>${org.name}</b> has expired`;
@@ -343,6 +374,7 @@ export async function paymentExpired(org: Organization) {
]
});
}
sessionStorage.setItem('expiredPaymentNotification', 'true');
}
export function checkForMarkedForDeletion(org: Organization) {
+52
View File
@@ -0,0 +1,52 @@
import { writable } from 'svelte/store';
import type { NotificationCoolOffOptions } from '$lib/helpers/notifications';
import type { Organization } from '$lib/stores/organization';
import type { Models } from '@appwrite.io/console';
type BottomModalAlertAction = {
text: string;
link: (ctx: { organization: Organization; project: Models.Project }) => string;
external?: boolean;
};
export type BottomModalAlertItem = {
id: string;
title: string;
message: string;
src: Record<'dark' | 'light', string>;
cta: BottomModalAlertAction;
learnMore?: BottomModalAlertAction;
plan: 'free' | 'pro' | 'scale' /*| 'enterprise'*/;
show?: boolean;
isHtml?: boolean;
importance?: number;
closed?: () => void;
scope?: 'organization' | 'project';
notificationHideOptions?: NotificationCoolOffOptions;
};
export const bottomModalAlerts = writable<BottomModalAlertItem[]>([]);
export const hideAllModalAlerts = () => {
bottomModalAlerts.update((all) => all.map((t) => ({ ...t, show: false })));
};
export const dismissBottomModalAlert = (id: string) => {
bottomModalAlerts.update((all) => all.filter((t) => t.id !== id));
};
export const showBottomModalAlert = (notification: BottomModalAlertItem) => {
const defaults: Partial<BottomModalAlertItem> = {
show: true,
importance: 5,
isHtml: false,
...notification
};
bottomModalAlerts.update((all) => {
if (all.some((t) => t.id === notification.id)) return all;
return [...all, defaults as BottomModalAlertItem];
});
};
+18
View File
@@ -138,6 +138,24 @@ campaigns
description:
'Get $50 in Cloud credits when you upgrade or create an organization with a Pro plan'
})
.set('NetNinja', {
template: 'card',
title: 'Claim your $50 Net Ninja credits.',
description:
'Get $50 in Cloud credits when you upgrade or create an organization with a Pro plan'
})
.set('CodeWithAntonio', {
template: 'card',
title: 'Claim your $50 Code With Antonio credits.',
description:
'Get $50 in Cloud credits when you upgrade or create an organization with a Pro plan'
})
.set('Hacktoberfest2024', {
template: 'card',
title: 'Claim your $60 Hacktoberfest credits.',
description:
'Get $60 in Cloud credits when you upgrade or create an organization with a Pro plan'
})
.set('VueJS', {
template: 'card',
title: 'Claim your $50 VueJS credits.',
+12 -9
View File
@@ -15,8 +15,6 @@ export type Feedback = {
export type FeedbackData = {
message: string;
name?: string;
email?: string;
value?: number;
};
@@ -47,8 +45,6 @@ export const selectedFeedback = writable<FeedbackOption>();
function createFeedbackDataStore() {
const { set, subscribe, update } = writable<FeedbackData>({
message: '',
name: '',
email: '',
value: null
});
return {
@@ -58,8 +54,6 @@ function createFeedbackDataStore() {
reset: () => {
update((feedbackData) => {
feedbackData.message = '';
feedbackData.name = '';
feedbackData.email = '';
feedbackData.value = null;
return feedbackData;
});
@@ -110,11 +104,16 @@ function createFeedbackStore() {
return feedback;
});
},
// TODO: update growth server to accept `billingPlan` and other keys.
submitFeedback: async (
subject: string,
message: string,
firstname?: string,
name?: string,
email?: string,
// eslint-disable-next-line
// @ts-expect-error
billingPlan?: string,
currentPage?: string,
value?: number
) => {
if (!VARS.GROWTH_ENDPOINT) return;
@@ -127,8 +126,12 @@ function createFeedbackStore() {
subject,
message,
email,
firstname: firstname ? firstname : undefined,
customFields: value ? [{ id: '40655', value }] : undefined
// billingPlan,
firstname: name || 'Unknown',
customFields: [
{ id: '47364', currentPage },
...(value ? [{ id: '40655', value }] : [])
]
})
});
if (response.status >= 400) {
+48
View File
@@ -0,0 +1,48 @@
import { page } from '$app/stores';
import { derived } from 'svelte/store';
export const roles = derived(page, ($page) => $page.data?.roles ?? []);
export const scopes = derived(page, ($page) => $page.data?.scopes ?? []);
export const isDeveloper = derived(roles, ($roles) => $roles.includes('developer'));
export const isBilling = derived(roles, ($roles) => $roles.includes('billing'));
export const isOwner = derived(roles, ($roles) => $roles.includes('owner'));
export const canWriteDatabases = derived(scopes, ($scopes) => $scopes.includes('databases.write'));
export const canWriteProjects = derived(scopes, ($scopes) => $scopes.includes('projects.write'));
export const canWriteFunctions = derived(scopes, ($scopes) => $scopes.includes('functions.write'));
export const canWriteBuckets = derived(scopes, ($scopes) => $scopes.includes('buckets.write'));
export const canWriteProviders = derived(scopes, ($scopes) => $scopes.includes('providers.write'));
export const canWriteMessages = derived(scopes, ($scopes) => $scopes.includes('messages.write'));
export const canWriteWebhooks = derived(scopes, ($scopes) => $scopes.includes('webhooks.write'));
export const canWritePlatforms = derived(scopes, ($scopes) => $scopes.includes('platforms.write'));
export const canWriteTargets = derived(scopes, ($scopes) => $scopes.includes('targets.write'));
export const canWriteUsers = derived(scopes, ($scopes) => $scopes.includes('users.write'));
export const canWriteTeams = derived(scopes, ($scopes) => $scopes.includes('teams.write'));
export const canWriteCollections = derived(scopes, ($scopes) =>
$scopes.includes('collections.write')
);
export const canWriteDocuments = derived(scopes, ($scopes) => $scopes.includes('documents.write'));
export const canWriteExecutions = derived(scopes, ($scopes) =>
$scopes.includes('executions.write')
);
export const canWriteSubscribers = derived(scopes, ($scopes) =>
$scopes.includes('subscribers.write')
);
export const canWriteKeys = derived(scopes, ($scopes) => $scopes.includes('keys.write'));
export const canWriteRules = derived(scopes, ($scopes) => $scopes.includes('rules.write'));
export const canWriteMigrations = derived(scopes, ($scopes) =>
$scopes.includes('migrations.write')
);
export const canWriteVcs = derived(scopes, ($scopes) => $scopes.includes('vcs.write'));
export const canWriteTopics = derived(scopes, ($scopes) => $scopes.includes('topics.write'));
export const canSeeBilling = derived(scopes, ($scopes) => $scopes.includes('billing.read'));
export const canSeeProjects = derived(scopes, function ($scopes) {
return $scopes.includes('projects.read');
});
export const canSeeDatabases = derived(scopes, ($scopes) => $scopes.includes('databases.read'));
export const canSeeFunctions = derived(scopes, ($scopes) => $scopes.includes('functions.read'));
export const canSeeTeams = derived(scopes, ($scopes) => $scopes.includes('teams.read'));
export const canSeeBuckets = derived(scopes, ($scopes) => $scopes.includes('buckets.read'));
export const canSeeMessages = derived(scopes, ($scopes) => $scopes.includes('messages.read'));
+15 -4
View File
@@ -1,6 +1,7 @@
import type { Models } from '@appwrite.io/console';
import { Client, type Models, Storage } from '@appwrite.io/console';
import { writable } from 'svelte/store';
import { sdk } from './sdk';
import { getProjectId } from '$lib/helpers/project';
import { getApiEndpoint } from '$lib/stores/sdk';
type UploaderFile = {
$id: string;
@@ -17,6 +18,15 @@ export type Uploader = {
files: UploaderFile[];
};
const temporaryStorage = () => {
const clientProject = new Client()
.setEndpoint(getApiEndpoint())
.setMode('admin')
.setProject(getProjectId());
return new Storage(clientProject);
};
const createUploader = () => {
const { subscribe, set, update } = writable<Uploader>({
isOpen: false,
@@ -71,7 +81,7 @@ const createUploader = () => {
n.files.unshift(newFile);
return n;
});
const uploadedFile = await sdk.forProject.storage.createFile(
const uploadedFile = await temporaryStorage().createFile(
bucketId,
id ?? 'unique()',
file,
@@ -79,7 +89,7 @@ const createUploader = () => {
(p) => {
newFile.$id = p.$id;
newFile.progress = p.progress;
newFile.completed = p.progress === 100 ? true : false;
newFile.completed = p.progress === 100;
updateFile(p.$id, newFile);
}
);
@@ -91,6 +101,7 @@ const createUploader = () => {
removeFromQueue: (id: string) => {
update((n) => {
n.files = n.files.filter((f) => f.$id !== id);
n.isOpen = n.files.length !== 0;
return n;
});
},
+7 -1
View File
@@ -2,8 +2,14 @@ import { page } from '$app/stores';
import { derived } from 'svelte/store';
import type { Models } from '@appwrite.io/console';
import { browser } from '$app/environment';
import type { NotificationPrefItem } from '$lib/helpers/notifications';
export type Account = Models.User<{ organization?: string } & Record<string, string>>;
export type Account = Models.User<
{
organization?: string;
notificationPrefs: Record<string, NotificationPrefItem>;
} & Record<string, string>
>;
export const user = derived(page, ($page) => {
if (browser) sessionStorage.setItem('account', JSON.stringify($page.data.account));
+2
View File
@@ -23,5 +23,7 @@ export const ENV = {
export const MODE = VARS.CONSOLE_MODE === Mode.CLOUD ? Mode.CLOUD : Mode.SELF_HOSTED;
export const isCloud = MODE === Mode.CLOUD;
export const isSelfHosted = MODE !== Mode.CLOUD;
export const isDev = ENV.DEV;
export const isProd = ENV.PROD;
export const hasStripePublicKey = !!VARS.PUBLIC_STRIPE_KEY;
export const GRACE_PERIOD_OVERRIDE = false;
@@ -50,7 +50,7 @@
const callbackState = {
from: 'github',
to: 'template',
step: '4',
step: '5',
template: $template.id,
templateConfig: JSON.stringify($templateConfig)
};
@@ -44,7 +44,7 @@
}} />
</p>
<p class="text u-margin-block-start-4">
This could include unauthorized users and search engines.
This could include unauthorized users and bots.
</p>
</div>
@@ -0,0 +1,13 @@
import { redirect } from '@sveltejs/kit';
import { base } from '$app/paths';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ parent }) => {
const { organizations, account } = await parent();
const teamId = account.prefs.organization ?? organizations.teams[0]?.$id;
if (teamId) {
redirect(303, `${base}/organization-${teamId}/members`);
}
redirect(303, base);
};
+16 -11
View File
@@ -15,16 +15,16 @@
import { requestedMigration } from '../store';
import Create from './createOrganization.svelte';
import {
showUsageRatesModal,
checkForUsageLimit,
checkPaymentAuthorizationRequired,
calculateTrialDay,
paymentExpired,
checkForMarkedForDeletion,
checkForMandate,
checkForMarkedForDeletion,
checkForMissingPaymentMethod,
checkForNewDevUpgradePro,
plansInfo
checkForUsageLimit,
checkPaymentAuthorizationRequired,
paymentExpired,
plansInfo,
showUsageRatesModal
} from '$lib/stores/billing';
import { goto } from '$app/navigation';
import { CommandCenter, registerCommands, registerSearchers } from '$lib/commandCenter';
@@ -35,7 +35,7 @@
import { openMigrationWizard } from './(migration-wizard)';
import { project } from './project-[project]/store';
import { feedback } from '$lib/stores/feedback';
import { VARS, hasStripePublicKey, isCloud } from '$lib/system';
import { hasStripePublicKey, isCloud, VARS } from '$lib/system';
import { stripe } from '$lib/stores/stripe';
import MobileSupportModal from './wizard/support/mobileSupportModal.svelte';
import { showSupportModal } from './wizard/support/store';
@@ -43,6 +43,8 @@
import { headerAlert } from '$lib/stores/headerAlert';
import { UsageRates } from '$lib/components/billing';
import { base } from '$app/paths';
import { canSeeProjects } from '$lib/stores/roles';
import { BottomModalAlert } from '$lib/components';
function kebabToSentenceCase(str: string) {
return str
@@ -66,9 +68,10 @@
keys: ['g', 'p'],
group: 'navigation',
disabled:
$page.url.pathname.includes('/console/organization-') &&
!$page.url.pathname.endsWith('/members') &&
!$page.url.pathname.endsWith('/settings'),
($page.url.pathname.includes('/console/organization-') &&
!$page.url.pathname.endsWith('/members') &&
!$page.url.pathname.endsWith('/settings')) ||
!$canSeeProjects,
rank: -1
},
{
@@ -83,7 +86,7 @@
{
label: 'Create new organization',
callback: () => {
newOrgModal.set(true);
isCloud ? goto(`${base}/create-organization`) : newOrgModal.set(true);
},
keys: ['c', 'o'],
group: 'organizations'
@@ -335,3 +338,5 @@
{#if isCloud && $showUsageRatesModal}
<UsageRates bind:show={$showUsageRatesModal} org={$organization} />
{/if}
<BottomModalAlert />
+3 -1
View File
@@ -34,6 +34,8 @@ export const load: LayoutLoad = async ({ fetch, depends, parent }) => {
return {
consoleVariables: variables,
version: data?.version ?? null,
plansInfo
plansInfo,
roles: [],
scopes: []
};
};
+5 -6
View File
@@ -8,6 +8,7 @@
import { sdk } from '$lib/stores/sdk';
export let showDelete = false;
let error: string;
async function deleteAccount() {
try {
@@ -19,12 +20,9 @@
message: `Account was deleted `
});
trackEvent(Submit.AccountDelete);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.AccountDelete);
} catch (e) {
error = e.message;
trackError(e, Submit.AccountDelete);
}
}
</script>
@@ -32,6 +30,7 @@
<Modal
title="Delete account"
bind:show={showDelete}
bind:error
onSubmit={deleteAccount}
icon="exclamation"
state="warning"
+5
View File
@@ -7,6 +7,7 @@
import { sdk } from '$lib/stores/sdk';
import { AuthenticatorType, type Models } from '@appwrite.io/console';
import QrFrame from '$lib/images/qr2.svg';
import { addNotification } from '$lib/stores/notifications';
export let showSetup = false;
@@ -28,6 +29,10 @@
await sdk.forConsole.account.updateMfaAuthenticator(AuthenticatorType.Totp, code);
await Promise.all([invalidate(Dependencies.ACCOUNT), invalidate(Dependencies.FACTORS)]);
showSetup = false;
addNotification({
type: 'success',
message: 'Authenticator app connected successfully'
});
trackEvent(Submit.AccountAuthenticatorUpdate);
} catch (e) {
error = e.message;
@@ -78,7 +78,7 @@
content:
'You are limited to 1 free organization per account'
}}>
<Pill>FREE</Pill>
<Pill class="eyebrow-heading-3">FREE</Pill>
</div>
{/if}
{#if organization?.billingTrialStartDate && $daysLeftInTrial > 0 && organization.billingPlan !== BillingPlan.FREE && $plansInfo.get(organization.billingPlan)?.trialDays}
@@ -89,7 +89,7 @@
organization.billingStartDate
)}. ${$daysLeftInTrial} days remaining.`
}}>
<Pill>TRIAL</Pill>
<Pill class="eyebrow-heading-3">TRIAL</Pill>
</div>
{/if}
{/if}
@@ -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"
+11 -6
View File
@@ -109,13 +109,18 @@
<div
class="method u-flex u-flex-vertical-mobile u-gap-16 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-device-mobile" aria-hidden="true" />
</div>
<div class="u-flex-vertical u-gap-4 body-text-2">
<span class="u-bold">Authenticator app</span>
<span
<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">Authenticator app</span>
{#if $factors.totp}
<Pill>connected</Pill>
{/if}
</div>
<span class="body-text-2"
>Use an authentication app to generate two-factor authentication
codes.</span>
</div>
@@ -141,7 +146,7 @@
<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-mail" aria-hidden="true" />
</div>
@@ -185,7 +190,7 @@
<div
class="method u-flex u-flex-vertical-mobile u-gap-16 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-lock-open" aria-hidden="true" />
</div>
@@ -271,7 +271,7 @@
</div>
</div>
{/if}
{#if selectedOrg?.billingPlan !== BillingPlan.FREE}
{#if selectedOrg?.$id && selectedOrg?.billingPlan !== BillingPlan.FREE}
<section
class="card u-margin-block-start-24"
style:--p-card-padding="1.5rem"
@@ -287,7 +287,7 @@
{/if}
</section>
{:else if selectedOrgId}
<div class:u-margin-block-start={campaign?.template === 'card'}>
<div class:u-margin-block-start-24={campaign?.template === 'card'}>
<EstimatedTotalBox
fixedCoupon={!!data?.couponData?.code}
billingPlan={BillingPlan.PRO}
+32
View File
@@ -0,0 +1,32 @@
import { base } from '$app/paths';
import RolesDark from '$lib/images/roles-dark.png';
import RolesLight from '$lib/images/roles-light.png';
import { type BottomModalAlertItem, showBottomModalAlert } from '$lib/stores/bottom-alerts';
const listOfPromotions: BottomModalAlertItem[] = [
{
id: 'memberRoles',
src: {
dark: RolesDark,
light: RolesLight
},
title: 'Roles are available now',
message:
'Enhance your workflow and security by assigning roles to members. <br/><b>Try for free until Jan 1st 2025 on paid plans.</b>',
isHtml: true,
plan: 'pro',
cta: {
text: 'Try now',
link: ({ organization }) => `${base}/organization-${organization.$id}/members`
},
learnMore: {
text: 'Learn more',
link: () => 'https://appwrite.io/docs/advanced/platform/roles'
}
}
];
export function addBottomModalAlerts() {
listOfPromotions.forEach((promotion) => showBottomModalAlert(promotion));
}
@@ -10,6 +10,7 @@
SelectPaymentMethod
} from '$lib/components/billing';
import ValidateCreditModal from '$lib/components/billing/validateCreditModal.svelte';
import Default from '$lib/components/roles/default.svelte';
import { BillingPlan, Dependencies } from '$lib/constants';
import { Button, Form, FormList, InputTags, InputText, Label } from '$lib/elements/forms';
import {
@@ -101,17 +102,6 @@
null
);
} else {
// Create free organization if coming from onboarding
if (previousPage.includes('/console/onboarding') && !anyOrgFree) {
await sdk.forConsole.billing.createOrganization(
ID.unique(),
'Personal Projects',
BillingPlan.FREE,
null,
null
);
}
org = await sdk.forConsole.billing.createOrganization(
ID.unique(),
name,
@@ -136,7 +126,7 @@
collaborators.forEach(async (collaborator) => {
await sdk.forConsole.teams.createMembership(
org.$id,
['owner'],
['developer'],
collaborator,
undefined,
undefined,
@@ -205,7 +195,7 @@
<InputTags
bind:tags={collaborators}
label="Invite members by email"
tooltip="Invited members will have access to all services and payment data within your organization"
popover={Default}
placeholder="Enter email address(es)"
validityRegex={emailRegex}
validityMessage="Invalid email address"
+4 -4
View File
@@ -32,11 +32,11 @@
{
value: BillingPlan.PRO,
label: `${tierToPlan(BillingPlan.PRO).name} - ${formatCurrency($plansInfo.get(BillingPlan.PRO).price)}/month + add-ons`
},
{
value: BillingPlan.SCALE,
label: `${tierToPlan(BillingPlan.SCALE).name} - ${formatCurrency($plansInfo.get(BillingPlan.SCALE).price)}/month + usage`
}
// {
// value: BillingPlan.SCALE,
// label: `${tierToPlan(BillingPlan.SCALE).name} - ${formatCurrency($plansInfo.get(BillingPlan.SCALE).price)}/month + usage`
// }
]
: [];
@@ -8,6 +8,7 @@
import { requestedMigration } from '$routes/store';
import { openMigrationWizard } from '../(migration-wizard)';
import { base } from '$app/paths';
import { isOwner } from '$lib/stores/roles';
export let data;
@@ -22,7 +23,7 @@
goto(`${base}/organization-${data.organization.$id}/members`);
},
keys: ['g', 'm'],
disabled: $page.url.pathname.endsWith('/members'),
disabled: $page.url.pathname.endsWith('/members') || !$isOwner,
group: 'navigation'
},
{
@@ -31,7 +32,7 @@
goto(`${base}/organization-${data.organization.$id}/settings`);
},
keys: ['g', 's'],
disabled: $page.url.pathname.endsWith('/settings'),
disabled: $page.url.pathname.endsWith('/settings') || !$isOwner,
group: 'navigation'
}
]);
@@ -11,26 +11,32 @@ import ProjectsAtRisk from '$lib/components/billing/alerts/projectsAtRisk.svelte
import { get } from 'svelte/store';
import { preferences } from '$lib/stores/preferences';
import type { Organization } from '$lib/stores/organization';
import { defaultRoles, defaultScopes } from '$lib/constants';
export const load: LayoutLoad = async ({ params, depends }) => {
depends(Dependencies.ORGANIZATION);
depends(Dependencies.MEMBERS);
depends(Dependencies.PAYMENT_METHODS);
if (isCloud) {
await failedInvoice.load(params.organization);
if (get(failedInvoice)) {
headerAlert.add({
show: true,
component: ProjectsAtRisk,
id: 'projectsAtRisk',
importance: 1
});
}
}
let roles = isCloud ? [] : defaultScopes;
let scopes = isCloud ? [] : defaultRoles;
try {
if (isCloud) {
const res = await sdk.forConsole.billing.getRoles(params.organization);
roles = res.roles;
scopes = res.scopes;
if (scopes.includes('billing.read')) {
await failedInvoice.load(params.organization);
if (get(failedInvoice)) {
headerAlert.add({
show: true,
component: ProjectsAtRisk,
id: 'projectsAtRisk',
importance: 1
});
}
}
}
const prefs = await sdk.forConsole.account.getPrefs();
if (prefs.organization !== params.organization) {
const newPrefs = { ...prefs, organization: params.organization };
@@ -47,7 +53,9 @@ export const load: LayoutLoad = async ({ params, depends }) => {
header: Header,
breadcrumbs: Breadcrumbs,
organization,
members
members,
roles,
scopes
};
} catch (e) {
const prefs = await sdk.forConsole.account.getPrefs();
@@ -31,6 +31,7 @@
import type { RegionList } from '$lib/sdk/billing';
import { onMount } from 'svelte';
import { organization } from '$lib/stores/organization';
import { canWriteProjects } from '$lib/stores/roles';
export let data;
@@ -79,6 +80,7 @@
}
function handleCreateProject() {
if (!$canWriteProjects) return;
if (isCloud) wizard.start(Create);
else showCreate = true;
}
@@ -89,7 +91,7 @@
showCreate = true;
},
keys: ['c'],
disabled: $readOnly && !GRACE_PERIOD_OVERRIDE,
disabled: ($readOnly && !GRACE_PERIOD_OVERRIDE) || !$canWriteProjects,
group: 'projects',
icon: 'plus'
}
@@ -142,13 +144,15 @@
<Heading tag="h2" size="5">Projects</Heading>
<DropList bind:show={showDropdown} placement="bottom-end">
<Button
on:click={handleCreateProject}
event="create_project"
disabled={$readOnly && !GRACE_PERIOD_OVERRIDE}>
<span class="icon-plus" aria-hidden="true" />
<span class="text">Create project</span>
</Button>
{#if $canWriteProjects}
<Button
on:click={handleCreateProject}
event="create_project"
disabled={$readOnly && !GRACE_PERIOD_OVERRIDE}>
<span class="icon-plus" aria-hidden="true" />
<span class="text">Create project</span>
</Button>
{/if}
<svelte:fragment slot="list">
<DropListItem on:click={() => (showCreate = true)}>Empty project</DropListItem>
<DropListItem on:click={importProject}>
@@ -162,6 +166,7 @@
{#if data.projects.total}
<CardContainer
showEmpty={$canWriteProjects}
total={data.projects.total}
offset={data.offset}
on:click={handleCreateProject}>
@@ -214,6 +219,7 @@
{:else}
<Empty
single
allowCreate={$canWriteProjects}
on:click={handleCreateProject}
target="project"
href="https://appwrite.io/docs/quick-starts"></Empty>
@@ -3,13 +3,17 @@ import { sdk } from '$lib/stores/sdk';
import { getLimit, getPage, pageToOffset } from '$lib/helpers/load';
import { CARD_LIMIT, Dependencies } from '$lib/constants';
import type { PageLoad } from './$types';
import { redirect } from '@sveltejs/kit';
export const load: PageLoad = async ({ params, url, route, depends, parent }) => {
await parent();
const { scopes } = await parent();
depends(Dependencies.ORGANIZATION);
const page = getPage(url);
const limit = getLimit(url, route, CARD_LIMIT);
const offset = pageToOffset(page, limit);
if (!scopes.includes('projects.read') && scopes.includes('billing.read')) {
return redirect(301, `/console/organization-${params.organization}/billing`);
}
return {
offset,
@@ -2,10 +2,15 @@ import { Dependencies } from '$lib/constants';
import type { Address } from '$lib/sdk/billing';
import type { Organization } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ parent, depends }) => {
const { organization } = await parent();
const { organization, scopes } = await parent();
if (!scopes.includes('billing.read')) {
return redirect(301, `/console/organization-${organization.$id}`);
}
depends(Dependencies.PAYMENT_METHODS);
depends(Dependencies.ORGANIZATION);
depends(Dependencies.CREDIT);
@@ -10,6 +10,8 @@
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';
import ReplaceAddress from './replaceAddress.svelte';
@@ -21,6 +23,7 @@
let showCreate = false;
let showEdit = false;
let showReplace = false;
let showRemove = false;
async function addAddress(addressId: string) {
try {
@@ -81,14 +84,16 @@
<span class="icon-dots-horizontal" aria-hidden="true" />
</Button>
<svelte:fragment slot="list">
<DropListItem
icon="pencil"
on:click={() => {
showEdit = true;
showBillingAddressDropdown = false;
}}>
Edit
</DropListItem>
{#if billingAddress.userId === $user.$id}
<DropListItem
icon="pencil"
on:click={() => {
showEdit = true;
showBillingAddressDropdown = false;
}}>
Edit
</DropListItem>
{/if}
<DropListItem
icon="switch-horizontal"
on:click={() => {
@@ -97,6 +102,14 @@
}}>
Replace
</DropListItem>
<DropListItem
icon="trash"
on:click={() => {
showRemove = true;
showBillingAddressDropdown = false;
}}>
Remove
</DropListItem>
</svelte:fragment>
</DropList>
</div>
@@ -163,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';
@@ -22,6 +22,7 @@
import EditPaymentModal from '$routes/(console)/account/payments/editPaymentModal.svelte';
import { tooltip } from '$lib/actions/tooltip';
import PaymentModal from '$lib/components/billing/paymentModal.svelte';
import { user } from '$lib/stores/user';
let showDropdown = false;
let showDropdownBackup = false;
@@ -112,15 +113,17 @@
<span class="icon-dots-horizontal" aria-hidden="true" />
</Button>
<svelte:fragment slot="list">
<DropListItem
icon="pencil"
on:click={() => {
isSelectedBackup = false;
showEdit = true;
showDropdown = false;
}}>
Edit
</DropListItem>
{#if defaultPaymentMethod.userId === $user.$id}
<DropListItem
icon="pencil"
on:click={() => {
isSelectedBackup = false;
showEdit = true;
showDropdown = false;
}}>
Edit
</DropListItem>
{/if}
<DropListItem
icon="switch-horizontal"
on:click={() => {
@@ -208,15 +211,17 @@
<span class="icon-dots-horizontal" aria-hidden="true" />
</Button>
<svelte:fragment slot="list">
<DropListItem
icon="pencil"
on:click={() => {
showEdit = true;
isSelectedBackup = true;
showDropdownBackup = false;
}}>
Edit
</DropListItem>
{#if backupPaymentMethod.userId === $user.$id}
<DropListItem
icon="pencil"
on:click={() => {
showEdit = true;
isSelectedBackup = true;
showDropdownBackup = false;
}}>
Edit
</DropListItem>
{/if}
<DropListItem
icon="switch-horizontal"
on:click={() => {
@@ -320,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}
@@ -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>
@@ -12,6 +12,7 @@
import PlanExcess from '$lib/components/billing/planExcess.svelte';
import PlanSelection from '$lib/components/billing/planSelection.svelte';
import ValidateCreditModal from '$lib/components/billing/validateCreditModal.svelte';
import Default from '$lib/components/roles/default.svelte';
import { BillingPlan, Dependencies, feedbackDowngradeOptions } from '$lib/constants';
import {
Button,
@@ -149,9 +150,9 @@
type: 'success',
isHtml: true,
message: `
<b>${$organization.name}</b> has been changed to ${
tierToPlan(billingPlan).name
} plan.`
<b>${$organization.name}</b> will change to ${
tierToPlan(billingPlan).name
} plan at the end of the current billing cycle.`
});
trackEvent(Submit.OrganizationDowngrade, {
@@ -162,7 +163,7 @@
type: 'error',
message: e.message
});
trackError(e, isUpgrade ? Submit.OrganizationUpgrade : Submit.OrganizationDowngrade);
trackError(e, Submit.OrganizationDowngrade);
}
}
@@ -213,22 +214,12 @@
await invalidate(Dependencies.ORGANIZATION);
await goto(`${base}/organization-${org.$id}`);
if (isUpgrade) {
addNotification({
type: 'success',
message: 'Your organization has been upgraded'
});
} else {
addNotification({
type: 'success',
isHtml: true,
message: `
<b>${$organization.name}</b> will change to ${
tierToPlan(billingPlan).name
} plan at the end of the current billing cycle.`
});
}
trackEvent(isUpgrade ? Submit.OrganizationUpgrade : Submit.OrganizationDowngrade, {
addNotification({
type: 'success',
message: 'Your organization has been upgraded'
});
trackEvent(Submit.OrganizationUpgrade, {
plan: tierToPlan(billingPlan)?.name
});
} catch (e) {
@@ -236,7 +227,7 @@
type: 'error',
message: e.message
});
trackError(e, isUpgrade ? Submit.OrganizationUpgrade : Submit.OrganizationDowngrade);
trackError(e, Submit.OrganizationUpgrade);
}
}
@@ -296,7 +287,7 @@
<InputTags
bind:tags={collaborators}
label="Invite members by email"
tooltip="Invited members will have access to all services and payment data within your organization"
popover={Default}
placeholder="Enter email address(es)"
validityRegex={emailRegex}
validityMessage="Invalid email address"
@@ -324,6 +315,7 @@
bind:value={feedbackDowngradeReason} />
<InputTextarea
id="comment"
required
label="If you need to elaborate, please do so here"
placeholder="Enter feedback"
bind:value={feedbackMessage} />
@@ -1,4 +1,5 @@
<script lang="ts">
import { base } from '$app/paths';
import { page } from '$app/stores';
import { Alert, Modal } from '$lib/components';
import { InputText, InputEmail, Button, FormList } from '$lib/elements/forms';
@@ -10,23 +11,26 @@
import { BillingPlan, Dependencies } from '$lib/constants';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { isCloud } from '$lib/system';
import { plansInfo } from '$lib/stores/billing';
import { formatCurrency } from '$lib/helpers/numbers';
import { roles } from '$lib/stores/billing';
import InputSelect from '$lib/elements/forms/inputSelect.svelte';
import Roles from '$lib/components/roles/roles.svelte';
export let showCreate = false;
const dispatch = createEventDispatcher();
const url = `${$page.url.origin}/invite`;
$: plan = $plansInfo?.get($organization?.billingPlan);
const url = `${$page.url.origin}${base}/invite`;
let email: string, name: string, error: string;
let email: string,
name: string,
error: string,
role: string = 'developer';
async function create() {
try {
const team = await sdk.forConsole.teams.createMembership(
$organization.$id,
['owner'],
[role],
email,
undefined,
undefined,
@@ -60,9 +64,17 @@
<Modal title="Invite member" {error} size="big" bind:show={showCreate} onSubmit={create}>
{#if isCloud}
{#if $organization?.billingPlan === BillingPlan.PRO}
<Alert type="info">
<!-- <Alert type="info">
You can add unlimited organization members on the {plan.name} plan for
<b>{formatCurrency(plan.addons.member.price)} each per billing period</b>.
</Alert> -->
<Alert type="info">
New roles are free until 1st January 2025. <a
class="link"
href="https://appwrite.io/docs/advanced/platform/roles"
target="_blank"
rel="noopener noreferrer">Learn more</a
>.
</Alert>
{/if}
{/if}
@@ -79,6 +91,7 @@
label="Name (optional)"
placeholder="Enter name"
bind:value={name} />
<InputSelect popover={Roles} id="role" label="Role" options={roles} bind:value={role} />
</FormList>
<svelte:fragment slot="footer">
<Button secondary on:click={() => (showCreate = false)}>Cancel</Button>
@@ -32,6 +32,13 @@
organization,
organizationList
} from '$lib/stores/organization';
import {
canSeeBilling,
canSeeProjects,
canSeeTeams,
isBilling,
isOwner
} from '$lib/stores/roles';
import { GRACE_PERIOD_OVERRIDE, isCloud } from '$lib/system';
let areMembersLimited: boolean;
@@ -54,45 +61,41 @@
$: avatars = $members.memberships?.map((m) => m.userName) ?? [];
$: organizationId = $page.params.organization;
$: path = `${base}/organization-${organizationId}`;
$: permanentTabSettings = [
{
href: `${path}/settings`,
event: 'settings',
title: 'Settings'
}
];
$: permanentTabs = [
$: tabs = [
{
href: path,
title: 'Projects',
event: 'projects',
hasChildren: true
hasChildren: true,
disabled: !$canSeeProjects
},
{
href: `${path}/members`,
title: 'Members',
event: 'members',
hasChildren: true
hasChildren: true,
disabled: !$canSeeTeams
},
{
href: `${path}/usage`,
event: 'usage',
title: 'Usage',
hasChildren: true,
disabled: !(isCloud && ($isOwner || $isBilling))
},
{
href: `${path}/billing`,
event: 'billing',
title: 'Billing',
disabled: !(isCloud && $canSeeBilling)
},
{
href: `${path}/settings`,
event: 'settings',
title: 'Settings',
disabled: !$isOwner
}
];
$: tabs = isCloud
? [
...permanentTabs,
{
href: `${path}/usage`,
event: 'usage',
title: 'Usage',
hasChildren: true
},
{
href: `${path}/billing`,
event: 'billing',
title: 'Billing'
},
...permanentTabSettings
]
: [...permanentTabs, ...permanentTabSettings];
].filter((tab) => !tab.disabled);
</script>
{#if $organization?.$id}
@@ -108,7 +111,7 @@
{$organization.name}
</span>
{#if isCloud && $organization?.billingPlan === BillingPlan.FREE}
<Pill>FREE</Pill>
<Pill class="eyebrow-heading-3">FREE</Pill>
{/if}
{#if isCloud && $organization?.billingTrialStartDate && $daysLeftInTrial > 0 && $organization.billingPlan !== BillingPlan.FREE && $plansInfo.get($organization.billingPlan)?.trialDays}
<div
@@ -118,7 +121,7 @@
$organization.billingStartDate
)}. ${$daysLeftInTrial} days remaining.`
}}>
<Pill>TRIAL</Pill>
<Pill class="eyebrow-heading-3">TRIAL</Pill>
</div>
{/if}
</span>
@@ -160,16 +163,19 @@
} plan`,
disabled: !areMembersLimited
}}>
<Button
secondary
on:click={() => newMemberModal.set(true)}
disabled={areMembersLimited}>
<span class="icon-plus" aria-hidden="true" />
<span class="text">Invite</span>
</Button>
{#if $isOwner}
<Button
secondary
on:click={() => newMemberModal.set(true)}
disabled={areMembersLimited}>
<span class="icon-plus" aria-hidden="true" />
<span class="text">Invite</span>
</Button>
{/if}
</div>
</div>
</div></svelte:fragment>
</div>
</svelte:fragment>
<Tabs>
{#each tabs as tab}
<Tab
@@ -1,9 +1,8 @@
<script lang="ts">
import { page } from '$app/stores';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { AvatarInitials, PaginationWithLimit } from '$lib/components';
import { AvatarInitials, DropList, DropListItem, PaginationWithLimit } from '$lib/components';
import { Pill } from '$lib/elements';
import { Button } from '$lib/elements/forms';
import {
TableBody,
TableCell,
@@ -15,17 +14,25 @@
} from '$lib/elements/table';
import { Container, ContainerHeader } from '$lib/layout';
import { addNotification } from '$lib/stores/notifications';
import { members, newMemberModal, organization } from '$lib/stores/organization';
import { newMemberModal, organization } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import type { Models } from '@appwrite.io/console';
import type { PageData } from './$types';
import Delete from '../deleteMember.svelte';
import { base } from '$app/paths';
import { isOwner } from '$lib/stores/roles';
import Edit from './edit.svelte';
import { getRoleLabel } from '$lib/stores/billing';
import { Drop } from '$lib/components';
import Upgrade from '$lib/components/roles/upgrade.svelte';
export let data: PageData;
let selectedMember: Models.Membership;
let showDelete = false;
let showEdit = false;
let showDropdown = [];
let showPopover = false;
const url = `${$page.url.origin}/${base}/`;
const resend = async (member: Models.Membership) => {
try {
@@ -58,7 +65,7 @@
<ContainerHeader
title="Members"
total={data.organizationMembers.total}
buttonText="Invite"
buttonText={$isOwner ? 'Invite' : ''}
buttonMethod={() => newMemberModal.set(true)}
showAlert={false} />
@@ -66,15 +73,43 @@
<TableHeader>
<TableCellHead width={160}>Name</TableCellHead>
<TableCellHead width={120}>Email</TableCellHead>
<div style:--p-col-width={120} class="table-thead-col" role="columnheader">
<span class="u-flex u-cross-baseline">
<span class="eyebrow-heading-3"> Role </span>
<Drop isPopover bind:show={showPopover} display="inline-block">
&nbsp;<button
type="button"
on:click={() => (showPopover = !showPopover)}
class="tooltip"
aria-label="input tooltip">
<span
class="icon-info"
aria-hidden="true"
style="font-size: var(--icon-size-small)" />
</button>
<svelte:fragment slot="list">
<div
class="dropped card u-max-width-300 u-break-word"
style:--card-border-radius="var(--border-radius-small)"
style:--p-card-padding=".75rem"
style:box-shadow="var(--shadow-large)">
<svelte:component this={Upgrade} />
</div>
</svelte:fragment>
</Drop>
</span>
</div>
<TableCellHead width={90}>2FA</TableCellHead>
<TableCellHead width={60} />
<TableCellHead width={30} />
{#if $isOwner}
<TableCellHead width={30} />
{/if}
</TableHeader>
<TableBody
service="members"
total={data.organizationMembers.total}
event="members_list">
{#each data.organizationMembers.memberships as member}
{#each data.organizationMembers.memberships as member, index}
<TableRow>
<TableCell title="Name">
<div class="u-flex u-gap-12 u-cross-center">
@@ -88,6 +123,10 @@
</div>
</TableCell>
<TableCellText title="Email">{member.userEmail}</TableCellText>
<TableCellText title="Role"
>{member.roles
.map((role) => getRoleLabel(role))
.join(', ')}</TableCellText>
<TableCellText title="2FA">
<Pill success={member.mfa}>
{#if member.mfa}
@@ -100,26 +139,53 @@
{/if}
</Pill>
</TableCellText>
<TableCell>
{#if member.invited && !member.joined}
<Button
secondary
event="invite_resend"
on:click={() => resend(member)}>Resend</Button>
{/if}
</TableCell>
<TableCell right>
<button
class="button is-only-icon is-text"
aria-label="Delete item"
disabled={$members.total === 1}
on:click={() => {
selectedMember = member;
showDelete = true;
}}>
<span class="icon-trash" aria-hidden="true" />
</button>
</TableCell>
{#if $isOwner}
<TableCell showOverflow right>
<DropList
bind:show={showDropdown[index]}
placement="bottom-start"
noArrow>
<button
class="button is-only-icon is-text"
aria-label="More options"
on:click|preventDefault={() => {
showDropdown[index] = !showDropdown[index];
}}>
<span class="icon-dots-horizontal" aria-hidden="true" />
</button>
<svelte:fragment slot="list">
<DropListItem
icon="pencil"
on:click={() => {
selectedMember = member;
showEdit = true;
showDropdown[index] = false;
}}>
Edit role
</DropListItem>
{#if member.invited && !member.joined}
<DropListItem
icon="refresh"
on:click={() => {
resend(member);
showDropdown[index] = false;
}}>
Resend
</DropListItem>
{/if}
<DropListItem
icon="trash"
on:click={() => {
selectedMember = member;
showDelete = true;
showDropdown[index] = false;
}}>
Remove
</DropListItem>
</svelte:fragment>
</DropList>
</TableCell>
{/if}
</TableRow>
{/each}
</TableBody>
@@ -134,3 +200,4 @@
</Container>
<Delete {selectedMember} bind:showDelete />
<Edit {selectedMember} bind:showEdit />
@@ -0,0 +1,94 @@
<script lang="ts">
import { Modal } from '$lib/components';
import { Button, FormList } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { createEventDispatcher } from 'svelte';
import { organization } from '$lib/stores/organization';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import InputSelect from '$lib/elements/forms/inputSelect.svelte';
import type { Models } from '@appwrite.io/console';
import Roles from '$lib/components/roles/roles.svelte';
export let showEdit = false;
export let selectedMember: Models.Membership;
const dispatch = createEventDispatcher();
let error: string;
let role = selectedMember?.roles?.[0];
const roles = [
{
label: 'Owner',
value: 'owner'
},
{
label: 'Developer',
value: 'developer'
},
{
label: 'Editor',
value: 'editor'
},
{
label: 'Analyst',
value: 'analyst'
},
{
label: 'Billing',
value: 'billing'
}
];
async function submit() {
try {
const membership = await sdk.forConsole.teams.updateMembership(
$organization.$id,
selectedMember.$id,
[role]
);
await invalidate(Dependencies.ACCOUNT);
await invalidate(Dependencies.ORGANIZATION);
await invalidate(Dependencies.MEMBERS);
showEdit = false;
addNotification({
type: 'success',
message: `Role has been updated`
});
trackEvent(Submit.MembershipUpdate);
dispatch('updated', membership);
} catch (e) {
error = e.message;
trackError(e, Submit.MembershipUpdate);
}
}
$: if (!showEdit) {
error = null;
role = null;
}
$: if (showEdit && !role) {
role = selectedMember.roles?.[0];
}
</script>
<Modal title="Edit role" {error} size="big" bind:show={showEdit} onSubmit={submit}>
<FormList>
<InputSelect
popover={Roles}
id="role"
label="Role"
required
options={roles}
bind:value={role} />
</FormList>
<svelte:fragment slot="footer">
<Button secondary on:click={() => (showEdit = false)}>Cancel</Button>
<Button submit submissionLoader>Update</Button>
</svelte:fragment>
</Modal>
@@ -12,6 +12,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;
@@ -67,6 +69,8 @@
{#if isCloud}
<DownloadDPA />
<Baa />
<Soc2 />
{/if}
<CardGrid danger>
@@ -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,6 +1,7 @@
<script lang="ts">
import { Box, CardGrid, Heading } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { base } from '$app/paths';
import { sdk } from '$lib/stores/sdk';
import { Submit, trackEvent } from '$lib/actions/analytics';
@@ -43,7 +44,7 @@
external
class="u-margin-block-start-16"
on:click={downloadPdf}
href="/legal/dpa.pdf"
href="{base}/legal/dpa.pdf"
event="download_dpa">
<span class="icon-download" aria-hidden="true" />
<span class="text">Download</span>
@@ -9,7 +9,7 @@
} from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
import { Button } from '$lib/elements/forms';
import { bytesToSize, humanFileSize } from '$lib/helpers/sizeConvertion';
import { bytesToSize, humanFileSize, mbSecondsToGBHours } from '$lib/helpers/sizeConvertion';
import { BarChart } from '$lib/charts';
import ProjectBreakdown from './ProjectBreakdown.svelte';
import { formatNum } from '$lib/helpers/string';
@@ -119,37 +119,43 @@
<ProgressBarBig
currentUnit={currentHumanized.unit}
currentValue={currentHumanized.value}
maxUnit="GB"
maxValue={max.toString()}
maxValue={`of ${max.toString()} GB used`}
progressValue={bytesToSize(current, 'GB')}
progressMax={max}
showBar={false} />
<BarChart
options={{
yAxis: {
axisLabel: {
formatter: (value) =>
value
? `${humanFileSize(+value).value} ${
humanFileSize(+value).unit
}`
: '0'
<div style:margin-top="-1.5em">
<BarChart
options={{
yAxis: {
axisLabel: {
formatter: (value) =>
value
? `${humanFileSize(+value).value} ${
humanFileSize(+value).unit
}`
: '0'
}
}
}
}}
series={[
{
name: 'Bandwidth',
data: [
...data.organizationUsage.bandwidth.map((e) => [e.date, e.value])
],
tooltip: {
valueFormatter: (value) =>
`${humanFileSize(+value).value} ${humanFileSize(+value).unit}`
}}
series={[
{
name: 'Bandwidth',
data: [
...data.organizationUsage.bandwidth.map((e) => [
e.date,
e.value
])
],
tooltip: {
valueFormatter: (value) =>
`${humanFileSize(+value).value} ${humanFileSize(+value).unit}`
}
}
}
]} />
<ProjectBreakdown projects={project} metric="bandwidth" {data} />
]} />
</div>
{#if project?.length > 0}
<ProjectBreakdown projects={project} metric="bandwidth" {data} />
{/if}
{:else}
<Card isDashed>
<div class="u-flex u-cross-center u-flex-vertical u-main-center u-flex">
@@ -176,29 +182,33 @@
<ProgressBarBig
currentUnit="Users"
currentValue={formatNum(current)}
maxUnit="Users"
maxValue={formatNum(max)}
maxUnit="users"
maxValue={`out of ${formatNum(max)}`}
progressValue={current}
progressMax={max}
showBar={false} />
<BarChart
options={{
yAxis: {
axisLabel: {
formatter: formatNum
<div style:margin-top="-1.5em">
<BarChart
options={{
yAxis: {
axisLabel: {
formatter: formatNum
}
}
}
}}
series={[
{
name: 'Users',
data: accumulateFromEndingTotal(
data.organizationUsage.users,
data.organizationUsage.usersTotal
)
}
]} />
<ProjectBreakdown projects={project} metric="users" {data} />
}}
series={[
{
name: 'Users',
data: accumulateFromEndingTotal(
data.organizationUsage.users,
data.organizationUsage.usersTotal
)
}
]} />
</div>
{#if project?.length > 0}
<ProjectBreakdown projects={project} metric="users" {data} />
{/if}
{:else}
<Card isDashed>
<div class="u-flex u-cross-center u-flex-vertical u-main-center u-flex">
@@ -227,28 +237,34 @@
<ProgressBarBig
currentUnit="Executions"
currentValue={formatNum(current)}
maxUnit="Executions"
maxValue={formatNum(max)}
maxValue={`of ${formatNum(max)} executions used`}
progressValue={current}
progressMax={max}
showBar={false} />
<BarChart
options={{
yAxis: {
axisLabel: {
formatter: formatNum
<div style:margin-top="-1.5em">
<BarChart
options={{
yAxis: {
axisLabel: {
formatter: formatNum
}
}
}
}}
series={[
{
name: 'Executions',
data: [
...data.organizationUsage.executions.map((e) => [e.date, e.value])
]
}
]} />
<ProjectBreakdown projects={project} metric="executions" {data} />
}}
series={[
{
name: 'Executions',
data: [
...data.organizationUsage.executions.map((e) => [
e.date,
e.value
])
]
}
]} />
</div>
{#if project?.length > 0}
<ProjectBreakdown projects={project} metric="executions" {data} />
{/if}
{:else}
<Card isDashed>
<div class="u-flex u-cross-center u-flex-vertical u-main-center u-flex">
@@ -276,15 +292,102 @@
{@const current = data.organizationUsage.storageTotal}
{@const currentHumanized = humanFileSize(current)}
{@const max = getServiceLimit('storage', tier)}
{@const progressBarStorageDate = [
{
size: bytesToSize(data.organizationUsage.filesStorageTotal, 'GB'),
color: '#85DBD8',
tooltip: {
title: 'File storage',
label: `${Math.round(bytesToSize(data.organizationUsage.filesStorageTotal, 'GB') * 100) / 100}GB`
}
},
{
size: bytesToSize(data.organizationUsage.deploymentsStorageTotal, 'GB'),
color: '#7C67FE',
tooltip: {
title: 'Deployments storage',
label: `${Math.round(bytesToSize(data.organizationUsage.deploymentsStorageTotal, 'GB') * 100) / 100}GB`
}
},
{
size: bytesToSize(data.organizationUsage.buildsStorageTotal, 'GB'),
color: '#FE9567',
tooltip: {
title: 'Builds storage',
label: `${Math.round(bytesToSize(data.organizationUsage.buildsStorageTotal, 'GB') * 100) / 100}GB`
}
}
]}
<ProgressBarBig
currentUnit={currentHumanized.unit}
currentValue={currentHumanized.value}
maxUnit="GB"
maxValue={max.toString()}
maxValue={`of ${max.toString()} GB used`}
progressValue={bytesToSize(current, 'GB')}
progressMax={max}
minimum={1} />
<ProjectBreakdown projects={project} metric="storage" {data} />
progressBarData={progressBarStorageDate} />
{#if project?.length > 0}
<ProjectBreakdown projects={project} metric="storage" {data} />
{/if}
{:else}
<Card isDashed>
<div class="u-flex u-cross-center u-flex-vertical u-main-center u-flex">
<span
class="icon-chart-square-bar text-large"
aria-hidden="true"
style="font-size: 32px;" />
<p class="u-bold">No data to show</p>
</div>
</Card>
{/if}
</svelte:fragment>
</CardGrid>
<CardGrid>
<Heading tag="h6" size="7">GB hours</Heading>
<p class="text">
GB hours represent the memory usage (in gigabytes) of your function executions and
builds, multiplied by the total execution time (in hours).
</p>
<svelte:fragment slot="aside">
{#if data.organizationUsage.storageTotal}
{@const totalGbHours = mbSecondsToGBHours(
data.organizationUsage.executionsMBSecondsTotal +
data.organizationUsage.buildsMBSecondsTotal
)}
{@const progressBarStorageDate = [
{
size: mbSecondsToGBHours(data.organizationUsage.executionsMBSecondsTotal),
color: '#85DBD8',
tooltip: {
title: 'Executions',
label: `${(Math.round(mbSecondsToGBHours(data.organizationUsage.executionsMBSecondsTotal) * 100) / 100).toLocaleString('en-US')} GB hours`
}
},
{
size: mbSecondsToGBHours(data.organizationUsage.buildsMBSecondsTotal),
color: '#FE9567',
tooltip: {
title: 'Deployments',
label: `${(Math.round(mbSecondsToGBHours(data.organizationUsage.buildsMBSecondsTotal) * 100) / 100).toLocaleString('en-US')} GB hours`
}
}
]}
<div class="u-flex u-flex-vertical">
<div class="u-flex u-main-space-between">
<p>
<span class="heading-level-4"
>{(Math.ceil(totalGbHours * 100) / 100).toLocaleString(
'en-US'
)}</span>
<span class="body-text-1 u-bold">{`GB hours`}</span>
</p>
</div>
</div>
<ProgressBarBig
progressMax={totalGbHours}
progressValue={totalGbHours}
progressBarData={progressBarStorageDate} />
{:else}
<Card isDashed>
<div class="u-flex u-cross-center u-flex-vertical u-main-center u-flex">
@@ -19,9 +19,14 @@ export const load: PageLoad = async ({ params, parent }) => {
users: null,
usersTotal: null,
storageTotal: null,
filesStorageTotal: null,
buildsStorageTotal: null,
deploymentsStorageTotal: null,
executions: null,
executionsTotal: null,
projects: null
projects: null,
executionsMBSecondsTotal: null,
buildsMBSecondsTotal: null
}
};
}
@@ -14,6 +14,7 @@
import { humanFileSize } from '$lib/helpers/sizeConvertion';
import type { OrganizationUsage } from '$lib/sdk/billing';
import { base } from '$app/paths';
import { canSeeProjects } from '$lib/stores/roles';
type Metric = 'users' | 'storage' | 'bandwidth' | 'executions';
export let data: PageData;
@@ -58,22 +59,26 @@
<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>
<TableCellHead width={140} />
{#if $canSeeProjects}
<TableCellHead width={140} />
{/if}
</TableHeader>
<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>
<TableCellLink
title="Go to project usage"
href={getProjectUsageLink(project.projectId)}>
View project usage
</TableCellLink>
{#if $canSeeProjects}
<TableCellLink
title="Go to project usage"
href={getProjectUsageLink(project.projectId)}>
View project usage
</TableCellLink>
{/if}
</TableRow>
{/each}
</TableBody>
@@ -1,5 +1,5 @@
<script lang="ts">
import { UploadBox } from '$lib/components';
import { MigrationBox, UploadBox } from '$lib/components';
import { sdk } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import { project, stats } from './store';
@@ -14,9 +14,15 @@
teamSearcher,
userSearcher
} from '$lib/commandCenter/searchers';
import { MigrationBox } from '$lib/components';
import { page } from '$app/stores';
import { base } from '$app/paths';
import {
canSeeBuckets,
canSeeDatabases,
canSeeFunctions,
canSeeMessages,
canWriteProjects
} from '$lib/stores/roles';
onMount(() => {
return sdk.forConsole.client.subscribe(['project', 'console'], (response) => {
@@ -43,7 +49,8 @@
goto(`${base}/project-${$project.$id}/databases`);
},
keys: ['g', 'd'],
group: 'navigation'
group: 'navigation',
disabled: !$canSeeDatabases
},
{
label: 'Go to Functions',
@@ -51,7 +58,8 @@
goto(`${base}/project-${$project.$id}/functions`);
},
keys: ['g', 'f'],
group: 'navigation'
group: 'navigation',
disabled: !$canSeeFunctions
},
{
label: 'Go to Messaging',
@@ -59,7 +67,7 @@
goto(`${base}/project-${$project.$id}/messaging`);
},
keys: ['g', 'm'],
disabled: $page.url.pathname.endsWith('messaging'),
disabled: $page.url.pathname.endsWith('messaging') || !$canSeeMessages,
group: 'navigation'
},
{
@@ -68,7 +76,8 @@
goto(`${base}/project-${$project.$id}/storage`);
},
keys: ['g', 's'],
group: 'navigation'
group: 'navigation',
disabled: !$canSeeBuckets
},
{
label: 'Go to Settings',
@@ -76,7 +85,8 @@
goto(`${base}/project-${$project.$id}/settings`);
},
keys: ['g', 'e'],
group: 'navigation'
group: 'navigation',
disabled: !$canWriteProjects
},
{
label: 'Go to Overview',
@@ -6,6 +6,7 @@ import { preferences } from '$lib/stores/preferences';
import { failedInvoice } from '$lib/stores/billing';
import { isCloud } from '$lib/system';
import type { Organization } from '$lib/stores/organization';
import { defaultRoles, defaultScopes } from '$lib/constants';
export const load: LayoutLoad = async ({ params, depends }) => {
depends(Dependencies.PROJECT);
@@ -16,14 +17,22 @@ 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;
if (isCloud) {
await failedInvoice.load(project.teamId);
const res = await sdk.forConsole.billing.getRoles(project.teamId);
roles = res.roles;
scopes = res.scopes;
if (scopes.includes('billing.read')) {
await failedInvoice.load(project.teamId);
}
}
return {
project,
organization: await (sdk.forConsole.teams.get(project.teamId) as Promise<Organization>)
organization: await (sdk.forConsole.teams.get(project.teamId) as Promise<Organization>),
roles,
scopes
};
} catch (e) {
error(e.code, e.message);

Some files were not shown because too many files have changed in this diff Show More