Merge branch 'main' of github.com:appwrite/console into fix-page-breaks-on-redirection

This commit is contained in:
Arman
2024-09-27 11:05:29 +02:00
134 changed files with 3332 additions and 1665 deletions
-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
+15 -15
View File
@@ -23,12 +23,12 @@
"@appwrite.io/pink": "0.25.0",
"@appwrite.io/pink-icons": "0.25.0",
"@popperjs/core": "^2.11.8",
"@sentry/sveltekit": "^8.26.0",
"@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",
@@ -42,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",
@@ -55,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'
+910 -876
View File
File diff suppressed because it is too large Load Diff
+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',
@@ -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'
+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);
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>
+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>
+1
View File
@@ -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';
@@ -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;
}
@@ -81,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>
+2 -2
View File
@@ -14,7 +14,7 @@
</script>
<section class="progress-bar">
{#if currentValue !== undefined && currentUnit !== undefined && progress !== undefined && maxValue !== undefined && maxUnit !== undefined}
{#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>
@@ -26,7 +26,7 @@
<p class="body-text-2">
{maxValue}
{maxUnit}
{maxUnit && maxUnit}
</p>
</div>
{/if}
@@ -64,6 +64,7 @@
display: flex;
flex-direction: row;
gap: 2px;
margin-top: 1rem;
}
&__content {
+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');
+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>
+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;
}
+4
View File
@@ -24,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
Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

+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>
+17
View File
@@ -9,6 +9,7 @@ export type PaymentMethodData = {
$updatedAt: string;
providerMethodId: string;
providerUserId: string;
userId: string;
expiryMonth: number;
expiryYear: number;
expired: boolean;
@@ -181,6 +182,8 @@ export type OrganizationUsage = {
filesStorageTotal: number;
buildsStorageTotal: number;
deploymentsStorageTotal: number;
executionsMBSecondsTotal: number;
buildsMBSecondsTotal: number;
storageTotal: number;
users: Array<Models.Metric>;
usersTotal: number;
@@ -231,6 +234,7 @@ export type Address = {
city: string;
state?: string;
postalCode: string;
userId: string;
};
export type AddressesList = {
@@ -282,6 +286,11 @@ export type PlansInfo = {
export type PlansMap = Map<Tier, Plan>;
export type Roles = {
scopes: string[];
roles: string[];
};
export class Billing {
client: Client;
@@ -363,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,
+29
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;
+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'));
+13 -3
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,
+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));
@@ -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);
};
+15 -10
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
},
{
@@ -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: []
};
};
@@ -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}
@@ -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"
@@ -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,7 @@
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 AddressModal from '$routes/(console)/account/payments/addressModal.svelte';
import EditAddressModal from '$routes/(console)/account/payments/editAddressModal.svelte';
import ReplaceAddress from './replaceAddress.svelte';
@@ -81,14 +82,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={() => {
@@ -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={() => {
@@ -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,
@@ -286,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"
@@ -11,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}${base}/invite`;
$: plan = $plansInfo?.get($organization?.billingPlan);
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,
@@ -61,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}
@@ -80,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>
@@ -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,36 +119,40 @@
<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}`
}
}
}
]} />
]} />
</div>
{#if project?.length > 0}
<ProjectBreakdown projects={project} metric="bandwidth" {data} />
{/if}
@@ -178,28 +182,30 @@
<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
)
}
]} />
}}
series={[
{
name: 'Users',
data: accumulateFromEndingTotal(
data.organizationUsage.users,
data.organizationUsage.usersTotal
)
}
]} />
</div>
{#if project?.length > 0}
<ProjectBreakdown projects={project} metric="users" {data} />
{/if}
@@ -231,27 +237,31 @@
<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])
]
}
]} />
}}
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}
@@ -311,8 +321,7 @@
<ProgressBarBig
currentUnit={currentHumanized.unit}
currentValue={currentHumanized.value}
maxUnit="GB"
maxValue={max.toString()}
maxValue={`of ${max.toString()} GB used`}
progressValue={bytesToSize(current, 'GB')}
progressMax={max}
progressBarData={progressBarStorageDate} />
@@ -332,6 +341,66 @@
{/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">
<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>
<TotalMembers members={data?.organizationMembers} />
<p class="text common-section u-color-text-gray">
@@ -24,7 +24,9 @@ export const load: PageLoad = async ({ params, parent }) => {
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;
@@ -60,7 +61,9 @@
<TableHeader>
<TableCellHead width={185}>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}
@@ -69,11 +72,13 @@
{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);
@@ -5,6 +5,7 @@
import { addSubPanel, registerCommands, updateCommandGroupRanks } from '$lib/commandCenter';
import { TeamsPanel, UsersPanel } from '$lib/commandCenter/panels';
import { readOnly } from '$lib/stores/billing';
import { canWriteTeams, canWriteUsers } from '$lib/stores/roles';
import { GRACE_PERIOD_OVERRIDE } from '$lib/system';
import { project } from '../store';
import { showCreateUser } from './+page.svelte';
@@ -23,7 +24,7 @@
group: 'users',
icon: 'plus',
rank: $page.url.pathname.endsWith('auth') ? 10 : 0,
disabled: $readOnly && !GRACE_PERIOD_OVERRIDE
disabled: ($readOnly && !GRACE_PERIOD_OVERRIDE) || !$canWriteUsers
},
{
label: 'Create team',
@@ -38,7 +39,7 @@
group: 'teams',
icon: 'plus',
rank: $page.url.pathname.endsWith('teams') ? 10 : 0,
disabled: $readOnly && !GRACE_PERIOD_OVERRIDE
disabled: ($readOnly && !GRACE_PERIOD_OVERRIDE) || !$canWriteTeams
},
{
label: 'Go to teams',
@@ -68,7 +69,7 @@
},
group: 'navigation',
rank: 1,
disabled: $page.url.pathname.endsWith('security')
disabled: $page.url.pathname.endsWith('security') || !$canWriteUsers
},
{
label: 'Go to settings',
@@ -78,7 +79,7 @@
},
group: 'navigation',
rank: 1,
disabled: $page.url.pathname.endsWith('settings')
disabled: $page.url.pathname.endsWith('settings') || !$canWriteUsers
},
{
label: 'Find users',
@@ -32,6 +32,7 @@
import type { PageData } from './$types';
import Create from './createUser.svelte';
import { tooltip } from '$lib/actions/tooltip';
import { canWriteUsers } from '$lib/stores/roles';
export let data: PageData;
@@ -44,19 +45,22 @@
<Container>
<ContainerHeader title="Users" isFlex={false} total={data.users.total} let:isButtonDisabled>
<SearchQuery search={data.search} placeholder="Search by name, email, phone, or ID">
<div
use:tooltip={{
content: `Upgrade to add more users`,
disabled: !isButtonDisabled
}}>
<Button
on:click={() => ($showCreateUser = true)}
event="create_user"
disabled={isButtonDisabled}>
<span class="icon-plus" aria-hidden="true" />
<span class="text">Create user</span>
</Button>
</div></SearchQuery>
{#if $canWriteUsers}
<div
use:tooltip={{
content: `Upgrade to add more users`,
disabled: !isButtonDisabled
}}>
<Button
on:click={() => ($showCreateUser = true)}
event="create_user"
disabled={isButtonDisabled}>
<span class="icon-plus" aria-hidden="true" />
<span class="text">Create user</span>
</Button>
</div>
{/if}
</SearchQuery>
</ContainerHeader>
{#if data.users.total}
<Table>
@@ -151,6 +155,7 @@
single
href="https://appwrite.io/docs/references/cloud/server-nodejs/users"
target="user"
allowCreate={$canWriteUsers}
on:click={() => showCreateUser.set(true)} />
{/if}
</Container>
@@ -4,6 +4,7 @@
import { Tab, Tabs } from '$lib/components';
import { isTabSelected } from '$lib/helpers/load';
import { Cover, CoverTitle } from '$lib/layout';
import { canWriteProjects } from '$lib/stores/roles';
const projectId = $page.params.project;
const path = `${base}/project-${projectId}/auth`;
@@ -23,13 +24,15 @@
{
href: `${path}/security`,
title: 'Security',
event: 'security'
event: 'security',
disabled: !$canWriteProjects
},
{
href: `${path}/templates`,
title: 'Templates',
hasChildren: false,
event: 'templates'
event: 'templates',
disabled: !$canWriteProjects
},
{
href: `${path}/usage`,
@@ -40,9 +43,10 @@
{
href: `${path}/settings`,
title: 'Settings',
event: 'settings'
event: 'settings',
disabled: !$canWriteProjects
}
];
].filter((tab) => !tab.disabled);
</script>
<Cover>
@@ -32,6 +32,7 @@
import { writable } from 'svelte/store';
import { readOnly } from '$lib/stores/billing';
import { isCloud } from '$lib/system';
import { canWriteTeams } from '$lib/stores/roles';
export let data: PageData;
@@ -49,19 +50,21 @@
buttonDisabled={isCloud && $readOnly}
let:isButtonDisabled>
<SearchQuery search={data.search} placeholder="Search by name">
<div
use:tooltip={{
content: `Upgrade to add more teams`,
disabled: !isButtonDisabled
}}>
<Button
on:click={() => ($showCreateTeam = true)}
event="create_team"
disabled={isButtonDisabled}>
<span class="icon-plus" aria-hidden="true" />
<span class="text">Create team</span>
</Button>
</div>
{#if $canWriteTeams}
<div
use:tooltip={{
content: `Upgrade to add more teams`,
disabled: !isButtonDisabled
}}>
<Button
on:click={() => ($showCreateTeam = true)}
event="create_team"
disabled={isButtonDisabled}>
<span class="icon-plus" aria-hidden="true" />
<span class="text">Create team</span>
</Button>
</div>
{/if}
</SearchQuery>
</ContainerHeader>
@@ -109,6 +112,7 @@
{:else}
<Empty
single
allowCreate={$canWriteTeams}
on:click={() => ($showCreateTeam = true)}
href="https://appwrite.io/docs/references/cloud/client-web/teams"
target="team" />
@@ -14,6 +14,7 @@
import Table from './table.svelte';
import { registerCommands } from '$lib/commandCenter';
import { tooltip } from '$lib/actions/tooltip';
import { canWriteDatabases } from '$lib/stores/roles';
export let data: PageData;
@@ -33,7 +34,7 @@
showCreate = true;
},
keys: ['c'],
disabled: showCreate || isCreationDisabled,
disabled: showCreate || isCreationDisabled || !$canWriteDatabases,
icon: 'plus',
group: 'databases',
rank: 10
@@ -55,19 +56,21 @@
view={data.view}
hideColumns={!data.databases.total}
hideView={!data.databases.total} />
<div
use:tooltip={{
content: `Upgrade to add more databases`,
disabled: !isCreationDisabled
}}>
<Button
on:click={() => (showCreate = true)}
event="create_database"
disabled={isCreationDisabled}>
<span class="icon-plus" aria-hidden="true" />
<span class="text">Create database</span>
</Button>
</div>
{#if $canWriteDatabases}
<div
use:tooltip={{
content: `Upgrade to add more databases`,
disabled: !isCreationDisabled
}}>
<Button
on:click={() => (showCreate = true)}
event="create_database"
disabled={isCreationDisabled}>
<span class="icon-plus" aria-hidden="true" />
<span class="text">Create database</span>
</Button>
</div>
{/if}
</div>
</svelte:fragment>
</ContainerHeader>
@@ -89,6 +92,7 @@
single
href="https://appwrite.io/docs/products/databases/databases"
target="database"
allowCreate={$canWriteDatabases}
on:click={() => (showCreate = true)} />
{/if}
</Container>
@@ -14,6 +14,7 @@
import CreateCollection from './createCollection.svelte';
import { showCreate } from './store';
import { CollectionsPanel } from '$lib/commandCenter/panels';
import { canWriteCollections, canWriteDatabases } from '$lib/stores/roles';
const project = $page.params.project;
const databaseId = $page.params.database;
@@ -36,7 +37,7 @@
}
},
keys: $page.url.pathname.endsWith(databaseId) ? ['c'] : ['c', 'c'],
disabled: $page.url.pathname.includes('collection-'),
disabled: $page.url.pathname.includes('collection-') || !$canWriteCollections,
group: 'collections',
icon: 'plus'
},
@@ -68,7 +69,8 @@
},
disabled:
$page.url.pathname.includes('/settings') ||
$page.url.pathname.includes('collection-'),
$page.url.pathname.includes('collection-') ||
!$canWriteDatabases,
keys: ['g', 's'],
group: 'collections'
},
@@ -6,6 +6,7 @@
import Table from './table.svelte';
import Grid from './grid.svelte';
import type { PageData } from './$types';
import { canWriteCollections } from '$lib/stores/roles';
export let data: PageData;
</script>
@@ -17,10 +18,12 @@
view={data.view}
hideColumns={!data.collections.total}
hideView={!data.collections.total}>
<Button on:click={() => ($showCreate = true)} event="create_collection">
<span class="icon-plus" aria-hidden="true" />
<span class="text">Create collection</span>
</Button>
{#if $canWriteCollections}
<Button on:click={() => ($showCreate = true)} event="create_collection">
<span class="icon-plus" aria-hidden="true" />
<span class="text">Create collection</span>
</Button>
{/if}
</GridHeader>
{#if data.collections.total}
@@ -38,6 +41,7 @@
{:else}
<Empty
single
allowCreate={$canWriteCollections}
href="https://appwrite.io/docs/products/databases/collections"
target="collection"
on:click={() => ($showCreate = true)} />
@@ -32,6 +32,7 @@
import { wizard } from '$lib/stores/wizard';
import CreateDocument from './createDocument.svelte';
import { base } from '$app/paths';
import { canWriteCollections } from '$lib/stores/roles';
let unsubscribe: { (): void };
@@ -69,7 +70,8 @@
addSubPanel(CreateAttributePanel);
},
icon: 'plus',
group: 'attributes'
group: 'attributes',
disabled: !$canWriteCollections
},
{
label: 'Go to documents',
@@ -134,7 +136,7 @@
`${base}/project-${$project?.$id}/databases/database-${$database?.$id}/collection-${$collection?.$id}/settings`
);
},
disabled: $page.url.pathname.endsWith('settings'),
disabled: $page.url.pathname.endsWith('settings') || !$canWriteCollections,
group: 'collections'
},
{
@@ -147,7 +149,8 @@
group: 'collections',
disabled:
$page.url.pathname.endsWith('display-name') ||
$page.url.pathname.endsWith('settings'),
$page.url.pathname.endsWith('settings') ||
!$canWriteCollections,
icon: 'eye'
},
{
@@ -160,7 +163,8 @@
group: 'collections',
disabled:
$page.url.pathname.endsWith('permissions') ||
$page.url.pathname.endsWith('settings'),
$page.url.pathname.endsWith('settings') ||
!$canWriteCollections,
icon: 'puzzle'
},
{
@@ -173,7 +177,8 @@
group: 'collections',
disabled:
$page.url.pathname.endsWith('document-security') ||
$page.url.pathname.endsWith('settings'),
$page.url.pathname.endsWith('settings') ||
!$canWriteCollections,
icon: 'lock-closed'
},
{
@@ -183,7 +188,8 @@
initCreateIndex();
},
icon: 'plus',
group: 'indexes'
group: 'indexes',
disabled: !$canWriteCollections
}
]);
@@ -7,6 +7,7 @@
import type { ColumnType } from '$lib/helpers/types';
import { Container } from '$lib/layout';
import { preferences } from '$lib/stores/preferences';
import { canWriteCollections, canWriteDocuments } from '$lib/stores/roles';
import { wizard } from '$lib/stores/wizard';
import type { PageData } from './$types';
import CreateAttributeDropdown from './attributes/createAttributeDropdown.svelte';
@@ -36,6 +37,7 @@
);
function openWizard() {
if (!$canWriteDocuments) return;
wizard.start(Create);
}
@@ -113,13 +115,18 @@
</EmptySearch>
{:else}
<Empty
allowCreate={$canWriteDocuments}
single
href="https://appwrite.io/docs/products/databases/documents"
target="document"
on:click={openWizard} />
{/if}
{:else}
<Empty single target="attribute" on:click={() => (showCreateDropdown = true)}>
<Empty
allowCreate={$canWriteCollections}
single
target="attribute"
on:click={() => (showCreateDropdown = true)}>
<div class="u-text-center">
<Heading size="7" tag="h2">Create an attribute to get started.</Heading>
<p class="body-text-2 u-bold u-margin-block-start-4">
@@ -133,19 +140,21 @@
text
event="empty_documentation"
ariaLabel={`create {target}`}>Documentation</Button>
<CreateAttributeDropdown
bind:showCreateDropdown
bind:showCreate={showCreateAttribute}
bind:selectedOption={selectedAttribute}>
<Button
secondary
event="create_attribute"
on:click={() => {
showCreateDropdown = !showCreateDropdown;
}}>
Create attribute
</Button>
</CreateAttributeDropdown>
{#if $canWriteCollections}
<CreateAttributeDropdown
bind:showCreateDropdown
bind:showCreate={showCreateAttribute}
bind:selectedOption={selectedAttribute}>
<Button
secondary
event="create_attribute"
on:click={() => {
showCreateDropdown = !showCreateDropdown;
}}>
Create attribute
</Button>
</CreateAttributeDropdown>
{/if}
</div>
</Empty>
{/if}
@@ -14,6 +14,7 @@
TableRow
} from '$lib/elements/table';
import { Container } from '$lib/layout';
import { canWriteCollections } from '$lib/stores/roles';
import Create from '../createAttribute.svelte';
import { isRelationship } from '../document-[document]/attributes/store';
import FailedModal from '../failedModal.svelte';
@@ -51,7 +52,9 @@
<div class="u-flex u-gap-12 common-section u-main-space-between">
<Heading tag="h2" size="5">Attributes</Heading>
<CreateAttributeDropdown bind:showCreateDropdown bind:selectedOption bind:showCreate />
{#if $canWriteCollections}
<CreateAttributeDropdown bind:showCreateDropdown bind:selectedOption bind:showCreate />
{/if}
</div>
{#if $attributes.length}
@@ -194,7 +197,11 @@
<p class="text">Total results: {$attributes.length}</p>
</div>
{:else}
<Empty single target="attribute" on:click={() => (showEmptyCreateDropdown = true)}>
<Empty
allowCreate={$canWriteCollections}
single
target="attribute"
on:click={() => (showEmptyCreateDropdown = true)}>
<div class="u-text-center">
<Heading size="7" tag="h2">Create your first attribute to get started.</Heading>
<p class="body-text-2 u-bold u-margin-block-start-4">
@@ -208,19 +215,21 @@
text
event="empty_documentation"
ariaLabel={`create {target}`}>Documentation</Button>
<CreateAttributeDropdown
bind:showCreateDropdown={showEmptyCreateDropdown}
bind:selectedOption
bind:showCreate>
<Button
secondary
event="create_attribute"
on:click={() => {
showEmptyCreateDropdown = !showEmptyCreateDropdown;
}}>
Create attribute
</Button>
</CreateAttributeDropdown>
{#if $canWriteCollections}
<CreateAttributeDropdown
bind:showCreateDropdown={showEmptyCreateDropdown}
bind:selectedOption
bind:showCreate>
<Button
secondary
event="create_attribute"
on:click={() => {
showEmptyCreateDropdown = !showEmptyCreateDropdown;
}}>
Create attribute
</Button>
</CreateAttributeDropdown>
{/if}
</div>
</Empty>
{/if}
@@ -26,7 +26,7 @@
await sdk.forProject.databases.updateBooleanAttribute(
databaseId,
collectionId,
data.key,
originalKey,
data.required,
data.default,
data.key !== originalKey ? data.key : undefined
@@ -27,7 +27,7 @@
await sdk.forProject.databases.updateDatetimeAttribute(
databaseId,
collectionId,
data.key,
originalKey,
data.required,
data.default,
data.key !== originalKey ? data.key : undefined
@@ -52,12 +52,16 @@
}
}
$: if (showEdit) {
currentAttr ??= { ...selectedAttribute };
originalKey = currentAttr.key;
error = null;
} else {
currentAttr = null;
$: onShow(showEdit);
function onShow(show: boolean) {
if (show) {
currentAttr ??= { ...selectedAttribute };
originalKey = currentAttr.key;
error = null;
} else {
currentAttr = null;
}
}
</script>
@@ -28,7 +28,7 @@
await sdk.forProject.databases.updateEmailAttribute(
databaseId,
collectionId,
data.key,
originalKey,
data.required,
data.default,
data.key !== originalKey ? data.key : undefined
@@ -28,7 +28,7 @@
await sdk.forProject.databases.updateEnumAttribute(
databaseId,
collectionId,
data.key,
originalKey,
data.elements,
data.required,
data.default,
@@ -29,7 +29,7 @@
await sdk.forProject.databases.updateFloatAttribute(
databaseId,
collectionId,
data.key,
originalKey,
data.required,
data.min,
data.max,
@@ -29,7 +29,7 @@
await sdk.forProject.databases.updateIntegerAttribute(
databaseId,
collectionId,
data.key,
originalKey,
data.required,
data.min,
data.max,
@@ -26,7 +26,7 @@
await sdk.forProject.databases.updateIpAttribute(
databaseId,
collectionId,
data.key,
originalKey,
data.required,
data.default,
data.key !== originalKey ? data.key : undefined
@@ -27,7 +27,7 @@
await sdk.forProject.databases.updateUrlAttribute(
databaseId,
collectionId,
data.key,
originalKey,
data.required,
data.default,
data.key !== originalKey ? data.key : undefined
@@ -4,6 +4,7 @@
import { Id, Tab, Tabs } from '$lib/components';
import { isTabSelected } from '$lib/helpers/load';
import { Cover, CoverTitle } from '$lib/layout';
import { canWriteCollections } from '$lib/stores/roles';
import { collection } from './store';
$: projectId = $page.params.project;
@@ -42,9 +43,10 @@
{
href: `${path}/settings`,
title: 'Settings',
event: 'settings'
event: 'settings',
disabled: !$canWriteCollections
}
];
].filter((tab) => !tab.disabled);
</script>
<Cover>
@@ -21,6 +21,7 @@
import CreateAttributeDropdown from '../attributes/createAttributeDropdown.svelte';
import type { Option } from '../attributes/store';
import FailedModal from '../failedModal.svelte';
import { canWriteCollections } from '$lib/stores/roles';
let showDropdown = [];
let selectedIndex: Models.Index = null;
@@ -38,13 +39,15 @@
<div class="u-flex u-gap-12 common-section u-main-space-between">
<Heading tag="h2" size="5">Indexes</Heading>
<Button
event="create_index"
disabled={!$collection?.attributes?.length}
on:click={() => (showCreateIndex = true)}>
<span class="icon-plus" aria-hidden="true" />
<span class="text">Create index</span>
</Button>
{#if $canWriteCollections}
<Button
event="create_index"
disabled={!$collection?.attributes?.length}
on:click={() => (showCreateIndex = true)}>
<span class="icon-plus" aria-hidden="true" />
<span class="text">Create index</span>
</Button>
{/if}
</div>
{#if $collection?.attributes?.length}
{#if $indexes.length}
@@ -130,13 +133,18 @@
</div>
{:else}
<Empty
allowCreate={$canWriteCollections}
single
href="https://appwrite.io/docs/products/databases/collections#indexes"
target="index"
on:click={() => (showCreateIndex = true)} />
{/if}
{:else}
<Empty single target="attribute" on:click={() => (showCreateDropdown = true)}>
<Empty
single
target="attribute"
allowCreate={$canWriteCollections}
on:click={() => (showCreateDropdown = true)}>
<div class="u-text-center">
<Heading size="7" tag="h2">Create an attribute to get started.</Heading>
<p class="body-text-2 u-bold u-margin-block-start-4">
@@ -150,19 +158,21 @@
text
event="empty_documentation"
ariaLabel={`create {target}`}>Documentation</Button>
<CreateAttributeDropdown
bind:showCreateDropdown
bind:showCreate={showCreateAttribute}
bind:selectedOption={selectedAttribute}>
<Button
secondary
event="create_attribute"
on:click={() => {
showCreateDropdown = !showCreateDropdown;
}}>
Create attribute
</Button>
</CreateAttributeDropdown>
{#if $canWriteCollections}
<CreateAttributeDropdown
bind:showCreateDropdown
bind:showCreate={showCreateAttribute}
bind:selectedOption={selectedAttribute}>
<Button
secondary
event="create_attribute"
on:click={() => {
showCreateDropdown = !showCreateDropdown;
}}>
Create attribute
</Button>
</CreateAttributeDropdown>
{/if}
</div>
</Empty>
{/if}
@@ -3,6 +3,7 @@
import { page } from '$app/stores';
import { CardContainer, GridItem1, Id } from '$lib/components';
import { Pill } from '$lib/elements';
import { canWriteCollections } from '$lib/stores/roles';
import type { PageData } from './$types';
export let data: PageData;
export let showCreate = false;
@@ -11,6 +12,7 @@
</script>
<CardContainer
showEmpty={$canWriteCollections}
total={data.collections.total}
on:click={() => (showCreate = true)}
event="collection">
@@ -4,6 +4,7 @@
import { Id, Tab, Tabs } from '$lib/components';
import { isTabSelected } from '$lib/helpers/load';
import { Cover, CoverTitle } from '$lib/layout';
import { canWriteDatabases } from '$lib/stores/roles';
import { database } from './store';
const projectId = $page.params.project;
@@ -25,9 +26,10 @@
{
href: `${path}/settings`,
event: 'settings',
title: 'Settings'
title: 'Settings',
disabled: !$canWriteDatabases
}
];
].filter((tab) => !tab.disabled);
</script>
<Cover>
@@ -20,6 +20,7 @@
} from '$lib/elements/table';
import { toLocaleDateTime } from '$lib/helpers/date';
import { addNotification } from '$lib/stores/notifications';
import { canWriteCollections } from '$lib/stores/roles';
import { sdk } from '$lib/stores/sdk';
import type { PageData } from './$types';
import { columns } from './store';
@@ -61,9 +62,11 @@
<TableScroll>
<TableHeader>
<TableCellHeadCheck
bind:selected
pageItemsIds={data.collections.collections.map((c) => c.$id)} />
{#if $canWriteCollections}
<TableCellHeadCheck
bind:selected
pageItemsIds={data.collections.collections.map((c) => c.$id)} />
{/if}
{#each $columns as column}
{#if column.show}
<TableCellHead width={column.width}>{column.title}</TableCellHead>
@@ -74,7 +77,9 @@
{#each data.collections.collections as collection}
<TableRowLink
href={`${base}/project-${projectId}/databases/database-${databaseId}/collection-${collection.$id}`}>
<TableCellCheck bind:selectedIds={selected} id={collection.$id} />
{#if $canWriteCollections}
<TableCellCheck bind:selectedIds={selected} id={collection.$id} />
{/if}
{#each $columns as column}
{#if column.show}
{#if column.id === '$id'}
@@ -2,6 +2,7 @@
import { base } from '$app/paths';
import { page } from '$app/stores';
import { CardContainer, GridItem1, Id } from '$lib/components';
import { canWriteDatabases } from '$lib/stores/roles';
import type { PageData } from './$types';
export let data: PageData;
export let showCreate = false;
@@ -9,6 +10,7 @@
</script>
<CardContainer
showEmpty={$canWriteDatabases}
total={data.databases.total}
on:click={() => (showCreate = true)}
event="database"
@@ -20,6 +20,7 @@
} from '$lib/elements/table';
import { toLocaleDateTime } from '$lib/helpers/date';
import { addNotification } from '$lib/stores/notifications';
import { canWriteDatabases } from '$lib/stores/roles';
import { sdk } from '$lib/stores/sdk';
import type { PageData } from './$types';
import { columns } from './store';
@@ -58,9 +59,11 @@
<TableScroll>
<TableHeader>
<TableCellHeadCheck
bind:selected
pageItemsIds={data.databases.databases.map((c) => c.$id)} />
{#if $canWriteDatabases}
<TableCellHeadCheck
bind:selected
pageItemsIds={data.databases.databases.map((c) => c.$id)} />
{/if}
{#each $columns as column}
{#if column.show}
<TableCellHead width={column.width}>{column.title}</TableCellHead>
@@ -70,7 +73,9 @@
<TableBody>
{#each data.databases.databases as database}
<TableRowLink href={`${base}/project-${projectId}/databases/database-${database.$id}`}>
<TableCellCheck bind:selectedIds={selected} id={database.$id} />
{#if $canWriteDatabases}
<TableCellCheck bind:selectedIds={selected} id={database.$id} />
{/if}
{#each $columns as column}
{#if column.show}
{#if column.id === '$id'}
@@ -1,6 +1,7 @@
<script lang="ts">
import { addSubPanel, registerCommands } from '$lib/commandCenter';
import { FunctionsPanel } from '$lib/commandCenter/panels';
import { canSeeFunctions } from '$lib/stores/roles';
$registerCommands([
{
@@ -9,7 +10,8 @@
addSubPanel(FunctionsPanel);
},
group: 'functions',
rank: -1
rank: -1,
disabled: !$canSeeFunctions
}
]);
</script>
@@ -26,6 +26,7 @@
import { parseExpression } from 'cron-parser';
import { onMount } from 'svelte';
import { functionsList } from './store';
import { canWriteFunctions } from '$lib/stores/roles';
import type { Models } from '@appwrite.io/console';
export let data;
@@ -73,7 +74,8 @@
keys: ['c'],
disabled:
$wizard.show ||
isServiceLimited('functions', $organization?.billingPlan, $functionsList?.total),
isServiceLimited('functions', $organization?.billingPlan, $functionsList?.total) ||
!$canWriteFunctions,
icon: 'plus',
group: 'functions'
}
@@ -85,7 +87,7 @@
<Container>
<ContainerHeader
title="Functions"
buttonText="Create function"
buttonText={$canWriteFunctions ? 'Create function' : ''}
buttonEvent="create_function"
buttonMethod={openWizard}
total={data.functions.total} />
@@ -93,6 +95,7 @@
{#if data.functions.total}
<CardContainer
{offset}
showEmpty={$canWriteFunctions}
event="functions"
total={data.functions.total}
on:click={openWizard}
@@ -136,6 +139,7 @@
{:else}
<Empty
single
allowCreate={$canWriteFunctions}
href="https://appwrite.io/docs/products/functions"
target="function"
on:click={openWizard} />
@@ -9,6 +9,7 @@
import { project } from '../../store';
import type { Models } from '@appwrite.io/console';
import { base } from '$app/paths';
import { canWriteFunctions } from '$lib/stores/roles';
onMount(() => {
let previousStatus = null;
@@ -47,7 +48,8 @@
},
keys: $page.url.pathname.endsWith($func.$id) ? ['c'] : ['c', 'd'],
group: 'functions',
icon: 'plus'
icon: 'plus',
disabled: !$canWriteFunctions
},
{
label: 'Permissions',
@@ -58,7 +60,8 @@
scrollBy({ top: -100 });
},
icon: 'search',
group: 'functions'
group: 'functions',
disabled: !$canWriteFunctions
},
{
label: 'Events',
@@ -69,7 +72,8 @@
scrollBy({ top: -100 });
},
icon: 'calendar',
group: 'functions'
group: 'functions',
disabled: !$canWriteFunctions
},
{
label: 'Variables',
@@ -79,7 +83,8 @@
);
},
icon: 'list',
group: 'functions'
group: 'functions',
disabled: !$canWriteFunctions
},
{
label: 'Timeout',
@@ -89,7 +94,8 @@
);
},
icon: 'x-circle',
group: 'functions'
group: 'functions',
disabled: !$canWriteFunctions
},
{
label: 'Schedule',
@@ -100,7 +106,8 @@
scrollBy({ top: -100 });
},
icon: 'clock',
group: 'functions'
group: 'functions',
disabled: !$canWriteFunctions
},
{
label: 'Go to deployments',
@@ -140,7 +147,7 @@
keys: ['g', 's'],
group: 'navigation',
rank: 10,
disabled: $page.url.pathname.endsWith('settings')
disabled: $page.url.pathname.endsWith('settings') || !$canWriteFunctions
}
]);
</script>
@@ -27,6 +27,7 @@
import QuickFilters from './quickFilters.svelte';
import { Pill } from '$lib/elements';
import { onMount } from 'svelte';
import { canWriteFunctions } from '$lib/stores/roles';
export let data;
@@ -184,15 +185,17 @@
href={`${base}/project-${$page.params.project}/functions/function-${$page.params.function}/deployment-${activeDeployment.$id}`}>
Build logs
</Button>
<Button
text
class="u-margin-inline-end-16"
on:click={() => {
selectedDeployment = activeDeployment;
showRedeploy = true;
}}>
Redeploy
</Button>
{#if $canWriteFunctions}
<Button
text
class="u-margin-inline-end-16"
on:click={() => {
selectedDeployment = activeDeployment;
showRedeploy = true;
}}>
Redeploy
</Button>
{/if}
<Button
secondary
href={`${base}/project-${$page.params.project}/functions/function-${$func.$id}/executions/execute-function`}
@@ -2,6 +2,7 @@
import { beforeNavigate } from '$app/navigation';
import { DropList, DropListItem } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { canWriteFunctions } from '$lib/stores/roles';
import CreateCli from './createCli.svelte';
import CreateGit from './createGit.svelte';
import CreateManual from './createManual.svelte';
@@ -24,14 +25,16 @@
</script>
<DropList bind:show placement="bottom-end">
<Button {secondary} {round} on:click={() => (show = !show)} event="create_deployment">
<slot>
{#if !secondary}
<span class="icon-plus" aria-hidden="true" />
{/if}
<span class="text">Create deployment</span>
</slot>
</Button>
{#if $canWriteFunctions}
<Button {secondary} {round} on:click={() => (show = !show)} event="create_deployment">
<slot>
{#if !secondary}
<span class="icon-plus" aria-hidden="true" />
{/if}
<span class="text">Create deployment</span>
</slot>
</Button>
{/if}
<svelte:fragment slot="list">
<DropListItem
on:click={() => {
@@ -389,12 +389,14 @@
<DeploymentSource {deployment} />
</span>
</div>
<div class="u-flex-vertical u-gap-8">
<p class="u-color-text-offline">Domains</p>
<span>
<DeploymentDomains domain={$proxyRuleList} />
</span>
</div>
{#if $proxyRuleList?.rules?.length}
<div class="u-flex-vertical u-gap-8">
<p class="u-color-text-offline">Domains</p>
<span>
<DeploymentDomains domain={$proxyRuleList} />
</span>
</div>
{/if}
<div class="u-flex-vertical u-gap-8">
<p class="u-color-text-offline">Updated</p>
<span>
@@ -4,6 +4,7 @@
import { Id, Tab, Tabs } from '$lib/components';
import { isTabSelected } from '$lib/helpers/load';
import { Cover, CoverTitle } from '$lib/layout';
import { canWriteFunctions } from '$lib/stores/roles';
import { func } from './store';
const projectId = $page.params.project;
@@ -36,9 +37,10 @@
{
href: `${path}/settings`,
event: 'settings',
title: 'Settings'
title: 'Settings',
disabled: !$canWriteFunctions
}
];
].filter((tab) => !tab.disabled);
</script>
<Cover>
@@ -16,6 +16,7 @@
import { app } from '$lib/stores/app';
import { isServiceLimited } from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
import { canWriteFunctions } from '$lib/stores/roles';
import { connectTemplate } from '$lib/wizards/functions/cover.svelte';
import type { Models } from '@appwrite.io/console';
import { functionsList } from '../store';
@@ -226,15 +227,16 @@
text>
<span class="text">View details</span>
</Button>
<ContainerButton
title="functions"
disabled={buttonDisabled}
buttonType="secondary"
buttonMethod={() => connectTemplate(template)}
showIcon={false}
buttonText="Create function"
buttonEvent="create_function" />
{#if $canWriteFunctions}
<ContainerButton
title="functions"
disabled={buttonDisabled}
buttonType="secondary"
buttonMethod={() => connectTemplate(template)}
showIcon={false}
buttonText="Create function"
buttonEvent="create_function" />
{/if}
</div>
</article>
</li>
@@ -12,6 +12,7 @@
import { isServiceLimited } from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
import { functionsList } from '../../store';
import { canWriteFunctions } from '$lib/stores/roles';
$: buttonDisabled =
isCloud && isServiceLimited('functions', $organization?.billingPlan, $functionsList?.total);
@@ -77,13 +78,15 @@
View source
<span class="icon-external-link" />
</Button>
<ContainerButton
title="functions"
disabled={buttonDisabled}
buttonMethod={() => connectTemplate($template)}
showIcon={false}
buttonText="Create function"
buttonEvent="create_function" />
{#if $canWriteFunctions}
<ContainerButton
title="functions"
disabled={buttonDisabled}
buttonMethod={() => connectTemplate($template)}
showIcon={false}
buttonText="Create function"
buttonEvent="create_function" />
{/if}
</div>
</Card>
</section>
@@ -12,6 +12,7 @@
import { messagesSearcher } from '$lib/commandCenter/searchers/messages';
import { providersSearcher } from '$lib/commandCenter/searchers/providers';
import { topicsSearcher } from '$lib/commandCenter/searchers/topics';
import { canWriteMessages } from '$lib/stores/roles';
import { project } from '../store';
// TODO: finalize the commands
@@ -23,7 +24,8 @@
addSubPanel(CreateMessagePanel);
},
icon: 'plus',
group: 'messaging'
group: 'messaging',
disabled: !$canWriteMessages
},
{
label: 'Go to Topics',

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