Merge pull request #1097 from appwrite/feat-e2e-new-flows

feat: upgrade e2e for new flows
This commit is contained in:
Torsten Dittmann
2024-05-22 22:01:18 +02:00
committed by GitHub
62 changed files with 1357 additions and 884 deletions
+33
View File
@@ -0,0 +1,33 @@
name: E2E
on:
pull_request_target:
branches: ['**']
paths-ignore:
- '**/*.md'
- 'static/**/*'
env:
VITE_STRIPE_PUBLIC_KEY: ${{ vars.VITE_STRIPE_PUBLIC_KEY }}
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps chromium
- name: E2E Tests
run: npm run e2e
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 7
+1 -1
View File
@@ -28,6 +28,6 @@ jobs:
- name: Linter
run: npm run lint
- name: Unit Tests
run: npm test
run: npm run test
- name: Build Console
run: npm run build
+3 -1
View File
@@ -8,6 +8,8 @@ node_modules
.env
.env.*
!.env.example
test-results/
playwright-report/
.DS_STORE
.cache
@@ -145,4 +147,4 @@ dist
.stylelintcache
# SvelteKit build / generate output
.svelte-kit
.svelte-kit
+22 -88
View File
@@ -6,7 +6,7 @@
"": {
"name": "@appwrite/console",
"dependencies": {
"@appwrite.io/console": "^0.6.1",
"@appwrite.io/console": "^0.6.2",
"@appwrite.io/pink": "0.20.0",
"@appwrite.io/pink-icons": "0.20.0",
"@popperjs/core": "^2.11.8",
@@ -29,7 +29,7 @@
"devDependencies": {
"@melt-ui/pp": "^0.1.4",
"@melt-ui/svelte": "^0.61.2",
"@playwright/test": "^1.37.1",
"@playwright/test": "^1.44.0",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/kit": "^2.3.4",
"@sveltejs/vite-plugin-svelte": "^3.0.1",
@@ -157,13 +157,9 @@
"integrity": "sha512-TD+xbmsBLyYy/IxFimW/YL/9L2IEnM7/EoV9Aeh56U64Ify8o27HJcKjo38XY9Tcn0uOq1AX3thkKgvtWvwFQg=="
},
"node_modules/@appwrite.io/console": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/@appwrite.io/console/-/console-0.6.1.tgz",
"integrity": "sha512-XlyitzDGmuBzJLBtFuZzL9vS4ZgAWzxuvWrILc7LwdNh2oklzhbJ8BTO8/CAxjxOwR6UOLKjXdoQnqOMqR66Yw==",
"dependencies": {
"cross-fetch": "3.1.5",
"isomorphic-form-data": "2.0.0"
}
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/@appwrite.io/console/-/console-0.6.2.tgz",
"integrity": "sha512-3qYknuFwhvTN2GnPB8G1w5DgxfVorGtfj4uuEaXbTmMUmjA8fHaW5VkJriuUxCi6/TpxDxqV/hhEkdoXL+5H4w=="
},
"node_modules/@appwrite.io/pink": {
"version": "0.20.0",
@@ -1895,12 +1891,12 @@
}
},
"node_modules/@playwright/test": {
"version": "1.43.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.43.1.tgz",
"integrity": "sha512-HgtQzFgNEEo4TE22K/X7sYTYNqEMMTZmFS8kTq6m8hXj+m1D8TgwgIbumHddJa9h4yl4GkKb8/bgAl2+g7eDgA==",
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.44.0.tgz",
"integrity": "sha512-rNX5lbNidamSUorBhB4XZ9SQTjAqfe5M+p37Z8ic0jPFBMo5iCtQz1kRWkEMg+rYOKSlVycpQmpqjSFq7LXOfg==",
"dev": true,
"dependencies": {
"playwright": "1.43.1"
"playwright": "1.44.0"
},
"bin": {
"playwright": "cli.js"
@@ -3455,7 +3451,8 @@
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true
},
"node_modules/available-typed-arrays": {
"version": "1.0.7",
@@ -3948,6 +3945,7 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"dependencies": {
"delayed-stream": "~1.0.0"
},
@@ -4005,14 +4003,6 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/cross-fetch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz",
"integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==",
"dependencies": {
"node-fetch": "2.6.7"
}
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@@ -4226,6 +4216,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"engines": {
"node": ">=0.4.0"
}
@@ -4957,19 +4948,6 @@
"is-callable": "^1.1.3"
}
},
"node_modules/form-data": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz",
"integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.6",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 0.12"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -5785,14 +5763,6 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true
},
"node_modules/isomorphic-form-data": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isomorphic-form-data/-/isomorphic-form-data-2.0.0.tgz",
"integrity": "sha512-TYgVnXWeESVmQSg4GLVbalmQ+B4NPi/H4eWxqALKj63KsUrcu301YDjBqaOw3h+cbak7Na4Xyps3BiptHtxTfg==",
"dependencies": {
"form-data": "^2.3.2"
}
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
@@ -7110,6 +7080,7 @@
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"engines": {
"node": ">= 0.6"
}
@@ -7118,6 +7089,7 @@
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"dependencies": {
"mime-db": "1.52.0"
},
@@ -7239,44 +7211,6 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true
},
"node_modules/node-fetch": {
"version": "2.6.7",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
"integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-fetch/node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"node_modules/node-fetch/node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"node_modules/node-fetch/node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@@ -7681,12 +7615,12 @@
}
},
"node_modules/playwright": {
"version": "1.43.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.1.tgz",
"integrity": "sha512-V7SoH0ai2kNt1Md9E3Gwas5B9m8KR2GVvwZnAI6Pg0m3sh7UvgiYhRrhsziCmqMJNouPckiOhk8T+9bSAK0VIA==",
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.44.0.tgz",
"integrity": "sha512-F9b3GUCLQ3Nffrfb6dunPOkE5Mh68tR7zN32L4jCk4FjQamgesGay7/dAAe1WaMEGV04DkdJfcJzjoCKygUaRQ==",
"dev": true,
"dependencies": {
"playwright-core": "1.43.1"
"playwright-core": "1.44.0"
},
"bin": {
"playwright": "cli.js"
@@ -7699,9 +7633,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.43.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.1.tgz",
"integrity": "sha512-EI36Mto2Vrx6VF7rm708qSnesVQKbxEWvPrfA1IPY6HgczBplDx7ENtx+K2n4kJ41sLLkuGfmb0ZLSSXlDhqPg==",
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.44.0.tgz",
"integrity": "sha512-ZTbkNpFfYcGWohvTTl+xewITm7EOuqIqex0c7dNZ+aXsbrLj0qI8XlGKfPpipjm0Wny/4Lt4CJsWJk1stVS5qQ==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
+4 -3
View File
@@ -15,12 +15,13 @@
"test": "TZ=EST vitest run",
"test:ui": "TZ=EST vitest --ui",
"test:watch": "TZ=EST vitest watch",
"e2e": "playwright test tests/e2e"
"e2e": "playwright test tests/e2e",
"e2e:ui": "playwright test tests/e2e --ui"
},
"dependencies": {
"@appwrite.io/console": "^0.6.2",
"@appwrite.io/pink": "0.20.0",
"@appwrite.io/pink-icons": "0.20.0",
"@appwrite.io/console": "^0.6.1",
"@popperjs/core": "^2.11.8",
"@sentry/svelte": "^7.66.0",
"@sentry/tracing": "^7.66.0",
@@ -41,7 +42,7 @@
"devDependencies": {
"@melt-ui/pp": "^0.1.4",
"@melt-ui/svelte": "^0.61.2",
"@playwright/test": "^1.37.1",
"@playwright/test": "^1.44.0",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/kit": "^2.3.4",
"@sveltejs/vite-plugin-svelte": "^3.0.1",
+8
View File
@@ -1,7 +1,15 @@
import { type PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = {
timeout: 120000,
reportSlowTests: null,
reporter: [['html', { open: 'never' }]],
webServer: {
timeout: 120000,
env: {
VITE_APPWRITE_ENDPOINT: 'http://console-tests.appwrite.org/v1',
VITE_CONSOLE_MODE: 'cloud'
},
command: 'npm run build && npm run preview',
port: 4173
}
+1
View File
@@ -157,6 +157,7 @@ export enum Submit {
AccountAuthenticatorDelete = 'submit_account_authenticator_delete',
AccountRecoveryCodesCreate = 'submit_account_recovery_codes_create',
AccountRecoveryCodesUpdate = 'submit_account_recovery_codes_update',
AccountDeleteIdentity = 'submit_account_delete_identity',
UserCreate = 'submit_user_create',
UserDelete = 'submit_user_delete',
UserUpdateEmail = 'submit_user_update_email',
@@ -0,0 +1,35 @@
<script lang="ts">
import { trackEvent } from '$lib/actions/analytics';
import { BillingPlan } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import { tierToPlan, upgradeURL } from '$lib/stores/billing';
import { Card } from '..';
export let service: string;
export let eventSource: string;
</script>
<Card>
<slot>
<div class="u-flex u-flex-vertical u-main-center u-cross-center u-gap-8">
<h6 class="body-text-1 u-bold u-trim-1">Upgrade to add {service}</h6>
<p class="text u-text-center">
Upgrade to a {tierToPlan(BillingPlan.PRO).name} plan to add {service} to your organization
</p>
<Button
class="u-margin-block-start-16"
secondary
fullWidthMobile
href={$upgradeURL}
on:click={() => {
trackEvent('click_organization_upgrade', {
from: 'button',
source: eventSource
});
}}>
Upgrade
</Button>
</div>
</slot>
</Card>
+1
View File
@@ -4,3 +4,4 @@ export { default as SelectPaymentMethod } from './selectPaymentMethod.svelte';
export { default as UsageRates } from './usageRates.svelte';
export { default as EstimatedTotalBox } from './estimatedTotalBox.svelte';
export { default as PlanComparisonBox } from './planComparisonBox.svelte';
export { default as EmptyCardCloud } from './emptyCardCloud.svelte';
+5 -1
View File
@@ -1,3 +1,7 @@
<ul class="collapsible">
<script lang="ts">
export let fullWidth = false;
</script>
<ul class="collapsible" class:u-width-full-line={fullWidth}>
<slot />
</ul>
+44 -32
View File
@@ -4,42 +4,54 @@
export let withIndentation = false;
export let open = false;
export let disabled = false;
export let noContent = false;
export let isInfo = false;
export let gap = 16;
</script>
<li class="collapsible-item">
<details class="collapsible-wrapper" class:is-disabled={disabled} bind:open>
<!-- svelte-ignore a11y-no-redundant-roles -->
<summary
class="collapsible-button u-position-relative"
on:keyup={clickOnEnter}
on:click
role="button"
tabindex="0">
<slot name="beforetitle" />
<div>
<span class="text" class:u-color-text-disabled={disabled}
><slot name="title" /></span>
{#if $$slots.subtitle}
<span class="collapsible-button-optional" class:u-color-text-disabled={disabled}
><slot name="subtitle" /></span>
{/if}
<li class="collapsible-item" class:is-info={isInfo}>
{#if noContent}
<div class="collapsible-wrapper">
<div class={`collapsible-button u-gap-${gap}`}>
<slot />
</div>
<slot name="end">
<div class="icon">
<span
class="icon-cheveron-down u-font-size-20"
class:u-color-text-disabled={disabled}
aria-hidden="true" />
</div>
</slot>
</summary>
<div
class="collapsible-content"
class:u-margin-block-start-8={withIndentation}
class:u-padding-inline-32={withIndentation}>
<slot />
</div>
</details>
{:else}
<details class="collapsible-wrapper" class:is-disabled={disabled} bind:open>
<!-- svelte-ignore a11y-no-redundant-roles -->
<summary
class={`collapsible-button u-position-relative u-gap-${gap}`}
on:keyup={clickOnEnter}
on:click
role="button"
tabindex="0">
<slot name="beforetitle" />
<div>
<span class="text" class:u-color-text-disabled={disabled}>
<slot name="title" /></span>
{#if $$slots.subtitle}
<span
class="collapsible-button-optional"
class:u-color-text-disabled={disabled}><slot name="subtitle" /></span>
{/if}
</div>
<slot name="end">
<div class="icon">
<span
class="icon-cheveron-down u-font-size-20"
class:u-color-text-disabled={disabled}
aria-hidden="true" />
</div>
</slot>
</summary>
<div
class="collapsible-content"
class:u-margin-block-start-8={withIndentation}
class:u-padding-inline-32={withIndentation}>
<slot />
</div>
</details>
{/if}
</li>
<style lang="scss">
+1
View File
@@ -73,3 +73,4 @@ export { default as RadioBoxes } from './radioBoxes.svelte';
export { default as ModalWrapper } from './modalWrapper.svelte';
export { default as ModalSideCol } from './modalSideCol.svelte';
export { default as ImagePreview } from './imagePreview.svelte';
export { default as MfaChallengeFormList } from './mfaChallengeFormList.svelte';
@@ -0,0 +1,134 @@
<script context="module" lang="ts">
export async function verify(challenge: Models.MfaChallenge, code: string) {
try {
if (challenge == null) {
challenge = await sdk.forConsole.account.createMfaChallenge(
AuthenticationFactor.Totp
);
}
await sdk.forConsole.account.updateMfaChallenge(challenge.$id, code);
await invalidate(Dependencies.ACCOUNT);
trackEvent(Submit.AccountCreate);
} catch (error) {
trackError(error, Submit.AccountCreate);
throw error;
}
}
</script>
<script lang="ts">
import { onMount } from 'svelte';
import { invalidate } from '$app/navigation';
import { Button, FormItem, FormList, InputDigits, InputText } from '$lib/elements/forms';
import { sdk } from '$lib/stores/sdk';
import { Dependencies } from '$lib/constants';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { AuthenticationFactor, type Models } from '@appwrite.io/console';
export let factors: Models.MfaFactors & { recoveryCode: boolean };
/** If true, the form will be submitted automatically when the code is entered. */
export let autoSubmit: boolean = true;
export let showVerifyButton: boolean = true;
export let disabled: boolean = false;
export let challenge: Models.MfaChallenge;
export let code: string;
let challengeType: AuthenticationFactor;
const enabledFactors = Object.entries(factors).filter(([_, enabled]) => enabled);
async function createChallenge(factor: AuthenticationFactor) {
disabled = true;
challengeType = factor;
challenge = await sdk.forConsole.account.createMfaChallenge(factor);
disabled = false;
}
onMount(async () => {
const enabledNonRecoveryFactors = enabledFactors.filter(
([factor, _]) => factor != 'recoveryCode'
);
if (enabledNonRecoveryFactors.length == 1) {
if (factors.phone) {
createChallenge(AuthenticationFactor.Phone);
} else if (factors.email) {
createChallenge(AuthenticationFactor.Email);
}
}
});
</script>
<FormList>
{#if challengeType == AuthenticationFactor.Recoverycode}
<p>
Enter below one of the recovery codes you received when enabling MFA for this account.
</p>
<InputText id="recovery-code" bind:value={code} required autofocus />
{:else}
{#if factors.totp && (challengeType == AuthenticationFactor.Totp || challengeType == null)}
<p>Enter below a 6-digit one-time code generated by your authentication app.</p>
{:else if challengeType == AuthenticationFactor.Email}
<p>A 6-digit verification code was sent to your email, enter it below.</p>
{: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} />
{/if}
{#if showVerifyButton}
<FormItem>
<Button fullWidth submit {disabled}>Verify</Button>
</FormItem>
{/if}
{#if enabledFactors.length > 1}
<span class="with-separators eyebrow-heading-3">or</span>
<div class="u-flex-vertical u-gap-8">
{#if factors.totp && challengeType != null && challengeType != AuthenticationFactor.Totp}
<FormItem>
<Button
secondary
fullWidth
{disabled}
on:click={() => createChallenge(AuthenticationFactor.Totp)}>
<span class="icon-device-mobile u-font-size-20" aria-hidden="true" />
<span class="text">Authenticator app</span>
</Button>
</FormItem>
{/if}
{#if factors.email && challengeType != AuthenticationFactor.Email}
<FormItem>
<Button
secondary
fullWidth
{disabled}
on:click={() => createChallenge(AuthenticationFactor.Email)}>
<span class="icon-mail u-font-size-20" aria-hidden="true" />
<span class="text">Email verification</span>
</Button>
</FormItem>
{/if}
{#if factors.phone && challengeType != AuthenticationFactor.Phone}
<FormItem>
<Button
secondary
fullWidth
{disabled}
on:click={() => createChallenge(AuthenticationFactor.Phone)}>
<span class="icon-chat-alt u-font-size-20" aria-hidden="true" />
<span class="text">Phone verification</span>
</Button>
</FormItem>
{/if}
{#if factors.recoveryCode && challengeType != AuthenticationFactor.Recoverycode}
<FormItem>
<Button
text
fullWidth
{disabled}
on:click={() => createChallenge(AuthenticationFactor.Recoverycode)}>
<span class="text">Use recovery code</span>
</Button>
</FormItem>
{/if}
</div>
{/if}
</FormList>
+8 -5
View File
@@ -3,6 +3,7 @@
export let items = [];
export let limit = 5;
export let hideFooter = false;
let offset = 0;
@@ -10,9 +11,11 @@
$: paginatedItems = items.slice(offset, offset + limit);
</script>
<slot {paginatedItems} />
<slot {paginatedItems} {limit} />
<div class="u-flex u-margin-block-start-32 u-main-space-between">
<p class="text">Total results: {total}</p>
<PaginationInline {limit} bind:offset sum={total} hidePages />
</div>
{#if !hideFooter}
<div class="u-flex u-margin-block-start-32 u-main-space-between">
<p class="text">Total results: {total}</p>
<PaginationInline {limit} bind:offset sum={total} hidePages />
</div>
{/if}
+2 -2
View File
@@ -118,8 +118,8 @@
<!-- svelte-ignore css-unused-selector -->
<style lang="scss" global>
.tippy-user .tippy-box {
--p-drop-bg-color: var(--color-neutral-500);
--p-drop-border-color: var(--color-neutral-200);
--p-drop-bg-color: var(--color-neutral-105);
--p-drop-border-color: var(--color-neutral-85);
body.theme-light & {
--p-drop-bg-color: var(--color-neutral-0);
+1
View File
@@ -4,6 +4,7 @@ export const INTERVAL = 5 * 60000; // default interval to check for feedback
export enum Dependencies {
FACTORS = 'dependency:factors',
IDENTITIES = 'dependency:identities',
CREDIT = 'dependency:credit',
INVOICES = 'dependency:invoices',
ADDRESS = 'dependency:address',
+5 -1
View File
@@ -24,7 +24,11 @@
onValueChange: ({ next }) => {
value = next.join('');
if (autoSubmit && value.length === length && !autoSubmitted) {
if (value.length < length) {
autoSubmitted = false;
}
if (element && autoSubmit && value.length === length && !autoSubmitted) {
autoSubmitted = true;
const firstInputElement = element.querySelector('input');
firstInputElement?.form.requestSubmit();
+3 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { goto, invalidate } from '$app/navigation';
import { base } from '$app/paths';
import { AvatarInitials, DropList, DropListItem, DropListLink, Support } from '$lib/components';
import { app } from '$lib/stores/app';
@@ -21,7 +21,7 @@
import { sdk } from '$lib/stores/sdk';
import { isCloud } from '$lib/system';
import { Feedback } from '$lib/components/feedback';
import { BillingPlan } from '$lib/constants';
import { BillingPlan, Dependencies } from '$lib/constants';
import { upgradeURL } from '$lib/stores/billing';
let showDropdown = false;
@@ -38,6 +38,7 @@
async function logout() {
await sdk.forConsole.account.deleteSession('current');
await invalidate(Dependencies.ACCOUNT);
trackEvent(Submit.AccountLogout);
await goto(`${base}/login`);
}
+2 -2
View File
@@ -158,7 +158,7 @@
<span class="text">Storage</span>
</a>
</li>
<li class="drop-list-item is-only-mobile">
<li class="drop-list-item is-not-desktop">
<a
class="drop-button"
href={`${projectPath}/settings`}
@@ -197,7 +197,7 @@
<span class="text">Settings</span>
</a>
<ul class="drop-list is-only-mobile">
<ul class="drop-list is-not-desktop">
{#if isCloud && $organization?.billingPlan !== BillingPlan.SCALE}
<li class="drop-list-item">
<a class="drop-button" href={$upgradeURL}>
+40
View File
@@ -1762,6 +1762,46 @@ export const marketplace: MarketplaceTemplate[] = [
}
]
},
{
icon: 'icon-chip',
id: 'chat-with-anyscale',
name: 'Chat with AnyScale',
tagline: 'Create a chatbot using the AnyScale API.',
permissions: ['any'],
cron: '',
events: [],
timeout: 15,
usecases: ['AI'],
runtimes: [
...getRuntimes(
TemplateRuntimes.NODE,
'npm install',
'src/main.js',
'node/chat-with-anyscale'
)
],
instructions: `For documentation and instructions check out <a target="_blank" rel="noopener noreferrer" class="link" href="https://github.com/appwrite/templates/tree/main/node/chat-with-anyscale">file</a>.`,
vcsProvider: 'github',
providerRepositoryId: 'templates',
providerOwner: 'appwrite',
providerBranch: 'main',
variables: [
{
name: 'ANYSCALE_API_KEY',
description: `A unique key used to authenticate with the AnyScale API. <a class="u-bold" target="_blank" href="https://app.endpoints.anyscale.com/credentials">Learn more</a>.`,
placeholder: 'd03xxxxxxxx26',
required: true,
type: 'password'
},
{
name: 'ANYSCALE_MAX_TOKENS',
description: `The maximum number of tokens that Anyscale responses should contain. <a class="u-bold" target="_blank" href="https://help.openai.com/en/articles/4936856-what-are-tokens-and-how-to-count-them">Learn more</a>.`,
placeholder: '',
required: false,
type: 'number'
}
]
},
{
icon: 'icon-music-note',
id: 'music-generation-with-huggingface',
+232 -170
View File
@@ -1,6 +1,4 @@
import type { Models } from '@appwrite.io/console';
import type { SvelteComponent } from 'svelte';
import { writable } from 'svelte/store';
import Apple from '../../routes/console/project-[project]/auth/(providers)/appleOAuth.svelte';
import Auth0 from '../../routes/console/project-[project]/auth/(providers)/auth0OAuth.svelte';
import Authentik from '../../routes/console/project-[project]/auth/(providers)/authentikOAuth.svelte';
@@ -11,176 +9,240 @@ import Microsoft from '../../routes/console/project-[project]/auth/(providers)/m
import Oidc from '../../routes/console/project-[project]/auth/(providers)/oidcOAuth.svelte';
import Okta from '../../routes/console/project-[project]/auth/(providers)/oktaOAuth.svelte';
export type Provider = Models.AuthProvider & {
export type Provider = {
name: string;
icon: string;
docs?: string;
component?: typeof SvelteComponent<unknown>;
component: typeof SvelteComponent<unknown>;
};
export type Providers = {
providers: Provider[];
export const oAuthProviders: Record<string, Provider> = {
amazon: {
name: 'Amazon',
icon: 'amazon',
docs: 'https://developer.amazon.com/apps-and-games/services-and-apis',
component: Main
},
apple: {
name: 'Apple',
icon: 'apple',
docs: 'https://developer.apple.com/',
component: Apple
},
auth0: {
name: 'Auth0',
icon: 'auth0',
docs: 'https://auth0.com/developers',
component: Auth0
},
authentik: {
name: 'Authentik',
icon: 'authentik',
docs: 'https://goauthentik.io/integrations/sources/oauth/',
component: Authentik
},
autodesk: {
name: 'Autodesk',
icon: 'autodesk',
docs: 'https://forge.autodesk.com/en/docs/oauth/v2/developers_guide/overview/',
component: Main
},
bitbucket: {
name: 'Bitbucket',
icon: 'bitbucket',
docs: 'https://developer.atlassian.com/bitbucket',
component: Main
},
bitly: {
name: 'Bitly',
icon: 'bitly',
docs: 'https://dev.bitly.com/v4_documentation.html',
component: Main
},
box: {
name: 'Box',
icon: 'box',
docs: 'https://developer.box.com/reference/',
component: Main
},
dailymotion: {
name: 'Dailymotion',
icon: 'dailymotion',
docs: 'https://developers.dailymotion.com/api/',
component: Main
},
discord: {
name: 'Discord',
icon: 'discord',
docs: 'https://discordapp.com/developers/docs/topics/oauth2',
component: Main
},
disqus: {
name: 'Disqus',
icon: 'disqus',
docs: 'https://disqus.com/api/docs/auth/',
component: Main
},
dropbox: {
name: 'Dropbox',
icon: 'dropbox',
docs: 'https://www.dropbox.com/developers/documentation',
component: Main
},
etsy: {
name: 'Etsy',
icon: 'etsy',
docs: 'https://developers.etsy.com/',
component: Main
},
facebook: {
name: 'Facebook',
icon: 'facebook',
docs: 'https://developers.facebook.com/',
component: Main
},
github: {
name: 'GitHub',
icon: 'github',
docs: 'https://developer.github.com',
component: Main
},
gitlab: {
name: 'GitLab',
icon: 'gitlab',
docs: 'https://docs.gitlab.com/ee/api/',
component: GitLab
},
google: {
name: 'Google',
icon: 'google',
docs: 'https://support.google.com/googleapi/answer/6158849',
component: Google
},
linkedin: {
name: 'LinkedIn',
icon: 'linkedin',
docs: 'https://developer.linkedin.com/',
component: Main
},
microsoft: {
name: 'Microsoft',
icon: 'microsoft',
docs: 'https://developer.microsoft.com/en-us/',
component: Microsoft
},
notion: {
name: 'Notion',
icon: 'notion',
docs: 'https://developers.notion.com/docs',
component: Main
},
oidc: {
name: 'OIDC',
icon: 'oidc',
docs: 'https://openid.net/connect/faq/',
component: Oidc
},
okta: {
name: 'Okta',
icon: 'okta',
docs: 'https://developer.okta.com',
component: Okta
},
paypal: {
name: 'Paypal',
icon: 'paypal',
docs: 'https://developer.paypal.com/docs/api/overview/',
component: Main
},
paypalsandbox: {
name: 'Paypal Sandbox',
icon: 'paypal',
docs: 'https://developer.paypal.com/docs/api/overview/',
component: Main
},
podio: {
name: 'Podio',
icon: 'podio',
docs: 'https://developers.podio.com/doc/oauth-authorization',
component: Main
},
salesforce: {
name: 'Salesforce',
icon: 'salesforce',
docs: 'https://developer.salesforce.com/docs/',
component: Main
},
slack: {
name: 'Slack',
icon: 'slack',
docs: 'https://api.slack.com/',
component: Main
},
spotify: {
name: 'Spotify',
icon: 'spotify',
docs: 'https://developer.spotify.com/documentation/general/guides/authorization-guide/',
component: Main
},
stripe: {
name: 'Stripe',
icon: 'stripe',
docs: 'https://stripe.com/docs/api',
component: Main
},
tradeshift: {
name: 'Tradeshift',
icon: 'tradeshift',
docs: 'https://developers.tradeshift.com/docs/api',
component: Main
},
tradeshiftBox: {
name: 'Tradeshift Sandbox',
icon: 'tradeshift',
docs: 'https://developers.tradeshift.com/docs/api',
component: Main
},
twitch: {
name: 'Twitch',
icon: 'twitch',
docs: 'https://dev.twitch.tv/docs/auth',
component: Main
},
wordpress: {
name: 'Wordpress',
icon: 'wordpress',
docs: 'https://developer.wordpress.com/docs/oauth2/',
component: Main
},
yahoo: {
name: 'Yahoo',
icon: 'yahoo',
docs: 'https://developer.yahoo.com/oauth2/guide/flows_authcode/',
component: Main
},
yammer: {
name: 'Yammer',
icon: 'yammer',
docs: 'https://developer.yammer.com/docs/oauth-2',
component: Main
},
yandex: {
name: 'Yandex',
icon: 'yandex',
docs: 'https://tech.yandex.com/oauth/',
component: Main
},
zoho: {
name: 'Zoho',
icon: 'zoho',
docs: 'https://www.zoho.com/crm/developer/docs/api/oauth-overview.html',
component: Main
},
zoom: {
name: 'Zoom',
icon: 'zoom',
docs: 'https://marketplace.zoom.us/docs/guides/auth/oauth/',
component: Main
}
};
const setProviders = (project: Models.Project): Provider[] => {
return (
project?.oAuthProviders.map((n) => {
let docs: Provider['docs'];
let icon: Provider['icon'] = n.key.toLowerCase();
let component: Provider['component'] = Main;
switch (n.key.toLowerCase()) {
case 'amazon':
docs = 'https://developer.amazon.com/apps-and-games/services-and-apis';
break;
case 'apple':
docs = 'https://developer.apple.com/';
component = Apple;
break;
case 'auth0':
docs = 'https://auth0.com/developers';
component = Auth0;
break;
case 'authentik':
docs = 'https://goauthentik.io/integrations/sources/oauth/';
component = Authentik;
break;
case 'autodesk':
docs = 'https://forge.autodesk.com/en/docs/oauth/v2/developers_guide/overview/';
break;
case 'bitbucket':
docs = 'https://developer.atlassian.com/bitbucket';
break;
case 'bitly':
docs = 'https://dev.bitly.com/v4_documentation.html';
break;
case 'box':
docs = 'https://developer.box.com/reference/';
break;
case 'dailymotion':
docs = 'https://developers.dailymotion.com/api/';
break;
case 'discord':
docs = 'https://discordapp.com/developers/docs/topics/oauth2';
break;
case 'disqus':
docs = 'https://disqus.com/api/docs/auth/';
break;
case 'dropbox':
docs = 'https://www.dropbox.com/developers/documentation';
break;
case 'etsy':
docs = 'https://developers.etsy.com/';
break;
case 'facebook':
docs = 'https://developers.facebook.com/';
break;
case 'github':
docs = 'https://developer.github.com';
break;
case 'gitlab':
docs = 'https://docs.gitlab.com/ee/api/';
component = GitLab;
break;
case 'google':
docs = 'https://support.google.com/googleapi/answer/6158849';
component = Google;
break;
case 'linkedin':
docs = 'https://developer.linkedin.com/';
break;
case 'microsoft':
docs = 'https://developer.microsoft.com/en-us/';
component = Microsoft;
break;
case 'notion':
docs = 'https://developers.notion.com/docs';
break;
case 'oidc':
docs = 'https://openid.net/connect/faq/';
component = Oidc;
break;
case 'okta':
docs = 'https://developer.okta.com';
component = Okta;
break;
case 'paypal':
docs = 'https://developer.paypal.com/docs/api/overview/';
break;
case 'paypalsandbox':
icon = 'paypal';
docs = 'https://developer.paypal.com/docs/api/overview/';
break;
case 'podio':
docs = 'https://developers.podio.com/doc/oauth-authorization';
break;
case 'salesforce':
docs = 'https://developer.salesforce.com/docs/';
break;
case 'slack':
docs = 'https://api.slack.com/';
break;
case 'spotify':
docs =
'https://developer.spotify.com/documentation/general/guides/authorization-guide/';
break;
case 'stripe':
docs = 'https://stripe.com/docs/api';
break;
case 'tradeshift':
docs = 'https://developers.tradeshift.com/docs/api';
break;
case 'tradeshiftbox':
icon = 'tradeshift';
docs = 'https://developers.tradeshift.com/docs/api';
break;
case 'twitch':
docs = 'https://dev.twitch.tv/docs/auth';
break;
case 'wordpress':
docs = 'https://developer.wordpress.com/docs/oauth2/';
break;
case 'yahoo':
docs = 'https://developer.yahoo.com/oauth2/guide/flows_authcode/';
break;
case 'yammer':
docs = 'https://developer.yammer.com/docs/oauth-2';
break;
case 'yandex':
docs = 'https://tech.yandex.com/oauth/';
break;
case 'zoho':
docs = 'https://www.zoho.com/accounts/protocol/oauth/sign-in-using-zoho.html';
break;
case 'zoom':
docs = 'https://marketplace.zoom.us/docs/guides/auth/oauth/';
break;
default:
break;
}
return {
...n,
icon,
docs,
component
};
}) ?? []
);
};
function createOAuthProviders() {
const { subscribe, set } = writable<Providers>({
providers: setProviders(null)
});
return {
subscribe,
set,
load: (project: Models.Project) => {
const providers = setProviders(project);
set({ providers });
}
};
}
export const OAuthProviders = createOAuthProviders();
-4
View File
@@ -7,10 +7,6 @@
variables[variable.name] = variable.value ?? '';
});
if (!runtime) {
runtime = template.runtimes[0].name;
}
templateStore.set(template);
templateConfig.set({
$id: null,
@@ -57,7 +57,7 @@
id="runtime"
placeholder="Select runtime"
required
disabled={options.length <= 1}
disabled={options.length < 1}
{options}
bind:value={$templateConfig.runtime} />
{/await}
+2 -1
View File
@@ -42,12 +42,13 @@ export const load: LayoutLoad = async ({ depends, url }) => {
const path = url.search ? `${url.search}&${redirectUrl}` : `?${redirectUrl}`;
if (error.type === 'user_more_factors_required') {
if (url.pathname === '/mfa') return;
if (url.pathname === '/mfa') return {}; // Ensure any previous account/organizations are cleared
redirect(303, `/mfa${path}`);
}
if (!acceptedRoutes.some((n) => url.pathname.startsWith(n))) {
redirect(303, `/login${path}`);
}
return {}; // Ensure any previous account/organizations are cleared
}
};
+3 -1
View File
@@ -5,7 +5,9 @@ import { sdk } from '$lib/stores/sdk';
import { isCloud } from '$lib/system';
import type { LayoutLoad } from './$types';
export const load: LayoutLoad = async ({ fetch, depends }) => {
export const load: LayoutLoad = async ({ fetch, depends, parent }) => {
await parent(); // ensure user is authenticated before proceeding
depends(Dependencies.RUNTIMES);
depends(Dependencies.CONSOLE_VARIABLES);
+10 -1
View File
@@ -6,9 +6,18 @@ import { Dependencies } from '$lib/constants';
export const load: LayoutLoad = async ({ depends }) => {
depends(Dependencies.FACTORS);
depends(Dependencies.IDENTITIES);
const promises = [
sdk.forConsole.account.listMfaFactors(),
sdk.forConsole.account.listIdentities()
];
const [factors, identities] = await Promise.all(promises);
return {
factors: await sdk.forConsole.account.listMfaFactors(),
factors,
identities,
header: Header,
breadcrumbs: Breadcrumbs
};
+2
View File
@@ -5,12 +5,14 @@
import UpdateEmail from './updateEmail.svelte';
import DeleteAccount from './deleteAccount.svelte';
import UpdateMfa from './updateMfa.svelte';
import Identities from './identities.svelte';
</script>
<Container>
<UpdateName />
<UpdateEmail />
<UpdatePassword />
<Identities />
<UpdateMfa />
<DeleteAccount />
</Container>
@@ -0,0 +1,101 @@
<script lang="ts">
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { CardGrid, Heading } from '$lib/components';
import { Button } from '$lib/elements/forms';
import {
Table,
TableBody,
TableCell,
TableCellHead,
TableCellText,
TableHeader,
TableRow
} from '$lib/elements/table';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { identities } from './store';
import { toLocaleDateTime } from '$lib/helpers/date';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { oAuthProviders } from '$lib/stores/oauth-providers';
import EmptySearch from '$lib/components/emptySearch.svelte';
async function deleteIdentity(id: string) {
try {
await sdk.forConsole.account.deleteIdentity(id);
addNotification({
message: 'Identity has been deleted',
type: 'success'
});
await invalidate(Dependencies.IDENTITIES);
trackEvent(Submit.AccountDeleteIdentity);
} catch (error) {
addNotification({
message: error.message,
type: 'error'
});
trackError(error, Submit.AccountDeleteIdentity);
}
}
</script>
<CardGrid>
<Heading tag="h6" size="7">Identities</Heading>
<p class="text">
Identities are your connected GitHub accounts. You can sign in using these accounts.
</p>
<svelte:fragment slot="aside">
{#if $identities.length === 0}
<EmptySearch hidePagination>
<div class="u-text-center">
No identities are currently available. Once you sign in via GitHub, you'll see
it here.
</div>
</EmptySearch>
{:else}
<Table noMargin noStyles>
<TableHeader>
<TableCellHead width={100}>Provider</TableCellHead>
<TableCellHead>Email</TableCellHead>
<TableCellHead>Created At</TableCellHead>
<TableCellHead>Expiry Date</TableCellHead>
<TableCellHead width={40} />
</TableHeader>
<TableBody>
{#each $identities as identity (identity.$id)}
{@const provider = oAuthProviders[identity.provider]}
<TableRow>
<TableCell title="Provider">
<div class="u-flex u-gap-8 u-cross-center">
<div class="avatar is-size-small">
<span
class={`icon-${provider.icon}`}
style="font-size: var(--icon-size-medium)!important" />
</div>
{provider.name}
</div>
</TableCell>
<TableCellText title="Email">
{identity.providerEmail}
</TableCellText>
<TableCellText title="Created at">
{toLocaleDateTime(identity.$createdAt)}
</TableCellText>
<TableCellText title="Expiry date">
{toLocaleDateTime(identity.providerAccessTokenExpiry)}
</TableCellText>
<TableCell title="Remove">
<Button
text
class="is-only-icon"
on:click={() => deleteIdentity(identity.$id)}
><span class="icon-trash" aria-hidden="true" /></Button>
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
{/if}
</svelte:fragment>
</CardGrid>
@@ -27,7 +27,7 @@
<ul class="buttons-list">
<li class="buttons-list-item">
<Button
download="backups.txt"
download="appwrite-backups.txt"
href={`data:application/octet-stream;charset=utf-8,${formattedBackupCodes}`}
text>
<span class="icon-download u-font-size-20" aria-hidden="true" />
@@ -1,27 +1,32 @@
<script lang="ts">
import { Modal } from '$lib/components';
import { Button, FormList, InputDigits } from '$lib/elements/forms';
import { sdk } from '$lib/stores/sdk';
import { AuthenticationFactor } from '@appwrite.io/console';
import MfaChallengeFormList, { verify } from '$lib/components/mfaChallengeFormList.svelte';
import { Button } from '$lib/elements/forms';
import { type Models } from '@appwrite.io/console';
export let show = false;
export let regenerateRecoveryCodes: () => Promise<void>;
export let factors: Models.MfaFactors & { recoveryCode: boolean };
let code = '';
let error = '';
let challenge: Models.MfaChallenge = null;
let code = '';
async function verify() {
async function submit() {
try {
const challenge = await sdk.forConsole.account.createMfaChallenge(
AuthenticationFactor.Totp
);
await sdk.forConsole.account.updateMfaChallenge(challenge.$id, code);
await verify(challenge, code);
await regenerateRecoveryCodes();
show = false;
regenerateRecoveryCodes();
} catch (e) {
error = e.message;
}
}
$: if (show) {
error = '';
challenge = null;
code = '';
}
</script>
<Modal
@@ -30,16 +35,18 @@
state="warning"
headerDivider={false}
{error}
onSubmit={verify}
onSubmit={submit}
bind:show>
<p>
Are you sure you want to regenerate all recovery codes? All <b
>previously generated recovery codes will become invalid.</b>
</p>
<p>Enter the 6-digit verification code generated by your authenticator app to continue.</p>
<FormList>
<InputDigits bind:value={code} required autofocus autoSubmit={false} />
</FormList>
<MfaChallengeFormList
factors={{ ...factors, recoveryCode: false }}
bind:challenge
bind:code
showVerifyButton={false}
autoSubmit={false} />
<svelte:fragment slot="footer">
<Button text on:click={() => (show = false)}>Cancel</Button>
+4
View File
@@ -6,3 +6,7 @@ export const factors = derived(
page,
($page) => $page.data.factors as Models.MfaFactors & { recoveryCode: boolean }
);
export const identities = derived(
page,
($page) => $page.data.identities.identities as Models.Identity[]
);
+119 -83
View File
@@ -20,10 +20,19 @@
let showRegenerateRecoveryCodes = false;
let codes: Models.MfaRecoveryCodes = null;
$: enabledFactors = Object.entries($factors)
.filter(([_, enabled]) => enabled)
.map(([factor, _]) => factor);
$: enabledMethods = enabledFactors.filter((factor) => factor !== 'recoveryCode');
async function updateMfa() {
try {
await sdk.forConsole.account.updateMFA(!$user.mfa);
await invalidate(Dependencies.ACCOUNT);
addNotification({
message: `Multi-factor authentication has been ${$user.mfa ? 'enabled' : 'disabled'}`,
type: 'success'
});
trackEvent(Submit.AccountUpdateMfa, { mfa: !$user.mfa });
} catch (error) {
addNotification({
@@ -63,6 +72,10 @@
trackError(error, Submit.AccountRecoveryCodesUpdate);
}
}
$: if (!showRecoveryCodes) {
codes = null;
}
</script>
<CardGrid>
@@ -87,99 +100,119 @@
href="https://appwrite.io/docs/products/auth/mfa">Learn more.</Button>
</p>
</div>
<div class="u-flex-vertical u-gap-16">
<div class="u-sep-block-end" style="padding-block-end: 8px;">
<EyebrowHeading tag="h6" size={3} class="u-normal">Methods</EyebrowHeading>
</div>
<div
class="method u-flex u-flex-vertical-mobile u-gap-16 u-main-space-between u-sep-block-end"
style="padding-block-end: 16px">
<div class="u-flex u-gap-8">
<div class="avatar is-size-x-small">
<span class="icon-device-mobile" aria-hidden="true" />
</div>
<div class="u-flex-vertical u-gap-4 body-text-2">
<span class="u-bold">Authenticator app</span>
<span
>Use an authentication app to generate two-factor authentication
codes.</span>
</div>
{#if $user.mfa}
<div class="u-flex-vertical u-gap-16">
<div class="u-sep-block-end" style="padding-block-end: 8px;">
<EyebrowHeading tag="h6" size={3} class="u-normal">Methods</EyebrowHeading>
</div>
<div class="method-button">
{#if $factors.totp}
<Button text secondary on:click={() => (showDelete = true)}
>Delete</Button>
{:else}
<Button secondary on:click={() => (showSetup = true)}>Add</Button>
{/if}
</div>
</div>
{#if $factors.email}
<div
class="u-flex u-main-space-between u-sep-block-end"
class="method u-flex u-flex-vertical-mobile u-gap-16 u-main-space-between u-sep-block-end"
style="padding-block-end: 16px">
<div class="u-flex u-gap-8">
<div class="avatar is-size-x-small">
<span class="icon-mail" aria-hidden="true" />
<span class="icon-device-mobile" aria-hidden="true" />
</div>
<div class="u-flex-vertical u-gap-4 body-text-2">
<span class="u-bold">Email verification</span>
<span>One-time codes will be sent to: {$user.email}</span>
<span class="u-bold">Authenticator app</span>
<span
>Use an authentication app to generate two-factor authentication
codes.</span>
</div>
</div>
<div class="method-button">
{#if $factors.totp}
<Button
text
class="is-not-mobile"
on:click={() => (showDelete = true)}>Delete</Button>
<Button
text
class="is-only-mobile"
noMargin
on:click={() => (showDelete = true)}>Delete</Button>
{:else}
<Button secondary on:click={() => (showSetup = true)}>Add</Button>
{/if}
</div>
</div>
{#if $factors.email}
<div
class="u-flex u-main-space-between u-sep-block-end"
style="padding-block-end: 16px">
<div class="u-flex u-gap-8">
<div class="avatar is-size-x-small">
<span class="icon-mail" aria-hidden="true" />
</div>
<div class="u-flex-vertical u-gap-4 body-text-2">
<span class="u-bold">Email verification</span>
<span>One-time codes will be sent to: {$user.email}</span>
</div>
</div>
</div>
{/if}
{#if $factors.phone}
<div
class="u-flex u-main-space-between u-sep-block-end"
style="padding-block-end: 16px">
<div class="u-flex u-gap-8">
<div class="avatar is-size-x-small">
<span class="icon-send" aria-hidden="true" />
</div>
<div class="u-flex-vertical u-gap-4 body-text-2">
<span class="u-bold">SMS verification</span>
<span>One-time codes will be sent to: {$user.phone}</span>
</div>
</div>
</div>
{/if}
</div>
{#if enabledMethods.length > 0}
<div class="u-flex-vertical u-gap-16">
<div class="u-sep-block-end" style="padding-block-end: 8px;">
<EyebrowHeading tag="h6" size={3} class="u-normal"
>Recovery</EyebrowHeading>
</div>
<div
class="method u-flex u-flex-vertical-mobile u-gap-16 u-main-space-between u-sep-block-end"
style="padding-block-end: 16px">
<div class="u-flex u-gap-8">
<div class="avatar is-size-x-small">
<span class="icon-lock-open" aria-hidden="true" />
</div>
<div class="u-flex-vertical u-gap-4 body-text-2">
<span class="u-bold">Recovery codes</span>
<span
>Use in case you can't receive two-factor authentication
codes.</span>
</div>
</div>
<div class="method-button">
{#if $factors.recoveryCode}
<Button
class="method-button is-not-mobile"
text
on:click={() => (showRegenerateRecoveryCodes = true)}
>Regenerate</Button>
<Button
class="method-button is-only-mobile"
text
noMargin
on:click={() => (showRegenerateRecoveryCodes = true)}
>Regenerate</Button>
{:else}
<Button
class="method-button"
secondary
on:click={createRecoveryCodes}>View</Button>
{/if}
</div>
</div>
</div>
{/if}
{#if $factors.phone}
<div
class="u-flex u-main-space-between u-sep-block-end"
style="padding-block-end: 16px">
<div class="u-flex u-gap-8">
<div class="avatar is-size-x-small">
<span class="icon-send" aria-hidden="true" />
</div>
<div class="u-flex-vertical u-gap-4 body-text-2">
<span class="u-bold">SMS verification</span>
<span>One-time codes will be sent to: {$user.phone}</span>
</div>
</div>
</div>
{/if}
</div>
<div class="u-flex-vertical u-gap-16">
<div class="u-sep-block-end" style="padding-block-end: 8px;">
<EyebrowHeading tag="h6" size={3} class="u-normal">Recovery</EyebrowHeading>
</div>
<div
class="method u-flex u-flex-vertical-mobile u-gap-16 u-main-space-between u-sep-block-end"
style="padding-block-end: 16px">
<div class="u-flex u-gap-8">
<div class="avatar is-size-x-small">
<span class="icon-lock-open" aria-hidden="true" />
</div>
<div class="u-flex-vertical u-gap-4 body-text-2">
<span class="u-bold">Recovery codes</span>
<span
>Use in case you can't receive two-factor authentication codes.</span>
</div>
</div>
<div class="method-button">
{#if $factors.recoveryCode}
<Button
class="method-button"
text
secondary
on:click={() => (showRegenerateRecoveryCodes = true)}
>Regenerate</Button>
{:else}
<Button class="method-button" secondary on:click={createRecoveryCodes}
>View</Button>
{/if}
</div>
</div>
</div>
{/if}
</div>
</svelte:fragment>
</CardGrid>
@@ -190,7 +223,10 @@
<DeleteMfa bind:showDelete />
<MfaRecoveryCodes bind:showRecoveryCodes {codes} />
<MfaRegenerateCodes bind:show={showRegenerateRecoveryCodes} {regenerateRecoveryCodes} />
<MfaRegenerateCodes
bind:show={showRegenerateRecoveryCodes}
{regenerateRecoveryCodes}
factors={$factors} />
<style lang="scss">
@import '@appwrite.io/pink/src/abstract/variables/_devices.scss';
@@ -75,7 +75,7 @@
}
if ($page.url.searchParams.has('plan')) {
const plan = $page.url.searchParams.get('plan');
if (plan && plan in BillingPlan) {
if (plan && Object.values(BillingPlan).includes(plan as BillingPlan)) {
billingPlan = plan as BillingPlan;
}
}
@@ -1,7 +1,6 @@
<script lang="ts">
import { base } from '$app/paths';
import { Box, CardGrid, EyebrowHeading, Heading } from '$lib/components';
import { Pill } from '$lib/elements';
import { CardGrid, Collapsible, CollapsibleItem, Heading } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { toLocaleDate } from '$lib/helpers/date';
import { plansInfo, tierToPlan, upgradeURL } from '$lib/stores/billing';
@@ -14,11 +13,9 @@
import { humanFileSize } from '$lib/helpers/sizeConvertion';
import { BillingPlan } from '$lib/constants';
import { trackEvent } from '$lib/actions/analytics';
import { TableCell, TableCellText, TableList } from '$lib/elements/table';
import { clickOnEnter } from '$lib/helpers/a11y';
import { tooltip } from '$lib/actions/tooltip';
let currentInvoice: Invoice;
let showAddons = false;
let extraMembers = 0;
const today = new Date();
onMount(async () => {
@@ -32,7 +29,8 @@
});
$: currentPlan = $plansInfo?.get($organization?.billingPlan);
$: extraUsage = currentInvoice?.amount - currentPlan?.price || 0;
$: extraUsage = (currentInvoice?.amount ?? 0) - (currentPlan?.price ?? 0);
$: extraAddons = currentInvoice?.usage?.length ?? 0;
$: isTrial =
new Date($organization?.billingStartDate).getTime() - today.getTime() > 0 &&
$plansInfo.get($organization.billingPlan)?.trialDays;
@@ -52,188 +50,107 @@
$organization?.billingNextInvoiceDate
)}
</p>
<TableList>
<li class="table-row">
<TableCellText>
{tierToPlan($organization?.billingPlan)?.name} plan
</TableCellText>
<TableCellText style="text-align: right;">
<Collapsible>
<CollapsibleItem noContent>
<span class="body-text-2">
{tierToPlan($organization?.billingPlan)?.name} plan</span>
<div class="body-text-2 u-margin-inline-start-auto">
{isTrial ? formatCurrency(0) : formatCurrency(currentPlan?.price)}
</TableCellText>
</li>
<!-- svelte-ignore a11y-no-noninteractive-element-to-interactive-role -->
<li
class="table-row"
role="button"
on:click={() => (showAddons = !showAddons)}
on:keyup={clickOnEnter}>
<TableCell>
Add-ons <span class="inline-tag"
>{extraMembers
? currentInvoice?.usage?.length + 1
: currentInvoice?.usage?.length}</span>
<span class="icon-cheveron-down"></span>
</TableCell>
<TableCellText style="text-align: right;">
{formatCurrency(extraUsage)}
</TableCellText>
</li>
<!-- {#if currentInvoice?.usage?.length && $organization?.billingPlan !== BillingPlan.STARTER && !isTrial && showAddons} -->
{#if extraUsage && showAddons && $organization?.billingPlan !== BillingPlan.STARTER && !isTrial}
<li class="table-row">
<td class="table-col" width="100%" colspan="2">
<Box>
<TableList>
{#if extraMembers}
<li class="table-row">
<TableCell>
<div class="u-flex u-flex-vertical">
<span>Additional members</span>
<span>
{extraMembers}
</span>
</div>
</TableCell>
<TableCellText style="text-align: right;">
<!-- TODO: fetch the price from the backend -->
{formatCurrency(extraMembers * 15)}
</TableCellText>
</li>
{/if}
{#if currentInvoice?.usage}
{#each currentInvoice.usage as excess}
<li class="table-row">
{#if ['storage', 'bandwidth'].includes(excess.name)}
{@const excessValue = humanFileSize(
excess.value
)}
<TableCell>
<div class="u-flex u-flex-vertical">
<p>{excess.name}</p>
<span
title={formatNumberWithCommas(
excess.value ?? 0
) + 'bytes'}>
{excessValue.value ??
0}{excessValue.unit}
</span>
</div>
</TableCell>
<TableCellText style="text-align: right;">
{formatCurrency(excess.amount)}
</TableCellText>
{/if}
{#if ['users', 'executions'].includes(excess.name)}
<TableCell>
<div class="u-flex u-flex-vertical">
<p>
{excess.name}
</p>
<span
title={formatNumberWithCommas(
excess.value
)}
>{abbreviateNumber(
excess.value
)}</span>
</div>
</TableCell>
<TableCellText style="text-align: right;">
{formatCurrency(excess.amount)}
</TableCellText>
{/if}
</li>
{/each}
{/if}
</TableList>
</Box>
</td>
</li>
{/if}
<li class="table-row">
<TableCellText>Current total (USD)</TableCellText>
<TableCellText style="text-align: right;">
{formatCurrency(currentInvoice?.amount ?? 0)}
</TableCellText>
</li>
</TableList>
<Box class="u-flex-vertical u-gap-8">
<div class="u-flex u-main-space-between u-cross-center">
<div class="u-flex u-gap-8 u-cross-center">
<h6 class="body-text-1 u-bold u-trim-1">
{tierToPlan($organization?.billingPlan)?.name} plan
</h6>
{#if $organization?.billingPlan !== BillingPlan.STARTER && isTrial}
<Pill>TRIAL</Pill>
{/if}
</div>
</CollapsibleItem>
{#if $organization?.billingPlan !== BillingPlan.STARTER && extraUsage}
<CollapsibleItem isInfo gap={8}>
<svelte:fragment slot="beforetitle">
<span class="body-text-2"><b>Add-ons</b></span><span class="inline-tag"
>{extraMembers ? extraAddons + 1 : extraAddons}</span>
<div class="icon">
<span class="icon-cheveron-down" aria-hidden="true"></span>
</div>
</svelte:fragment>
<svelte:fragment slot="end">
<div class="body-text-2 u-margin-inline-start-auto">
<b>
{formatCurrency(extraUsage >= 0 ? extraUsage : 0)}
</b>
</div>
</svelte:fragment>
<p class="text">
{#if !extraUsage}
<span class="text u-color-text-gray">Total to-date:</span>
{/if}
<span class="body-text-1">
{isTrial ? formatCurrency(0) : formatCurrency(currentPlan?.price)}
</span>
</p>
</div>
{#if currentInvoice?.usage?.length && $organization?.billingPlan !== BillingPlan.STARTER && !isTrial}
{@const extraMembers = currentInvoice.usage.find((u) => u.name === 'members')}
{#if extraMembers}
<div class="u-margin-block-start-24">
<EyebrowHeading tag="h6" size={3}>Additional members</EyebrowHeading>
<ul>
<li class="u-flex u-main-space-between u-margin-block-start-8">
<p class="text u-color-text-gray">
<span> {extraMembers.value}</span>
{extraMembers.name}
</p>
<p class="text">{formatCurrency(extraMembers.amount)}</p>
</li>
</ul>
</div>
{/if}
<div class="u-margin-block-start-24">
<EyebrowHeading tag="h6" size={3}>Addons</EyebrowHeading>
<ul>
{#each currentInvoice.usage as excess}
{#if ['storage', 'bandwidth'].includes(excess.name)}
{@const excessValue = humanFileSize(excess.value)}
<li class="u-flex u-main-space-between u-margin-block-start-8">
<p class="text u-color-text-gray">
<span
title={formatNumberWithCommas(excess.value) +
{#if extraMembers}
<li class="u-flex-vertical u-gap-4 u-padding-block-8">
<div class="u-flex u-gap-4">
<h5 class="body-text-2 u-stretch">Additional members</h5>
<div>
{formatCurrency(
extraMembers * currentPlan.addons.member.price
)}
</div>
</div>
<div class="u-flex u-gap-4">
<h5 class="body-text-2 u-stretch u-color-text-offline">
{extraMembers}
</h5>
</div>
</li>
{/if}
{#if currentInvoice?.usage}
{#each currentInvoice.usage as excess, i}
<li
class="u-flex-vertical u-gap-4 {extraMembers
? 'u-padding-block-8'
: 'u-padding-block-start-8'}"
class:u-sep-block-start={i > 0 || extraMembers}>
{#if ['storage', 'bandwidth'].includes(excess.name)}
{@const excessValue = humanFileSize(excess.value)}
<div class="u-flex u-main-space-between">
<h5 class="body-text-2 u-stretch u-capitalize">
{excess.name}
</h5>
{formatCurrency(excess.amount)}
</div>
<h5
class="body-text-2 u-stretch u-color-text-offline"
title={formatNumberWithCommas(excess.value ?? 0) +
'bytes'}>
{excessValue.value}{excessValue.unit}
</span>
{excess.name}
</p>
<p class="text">{formatCurrency(excess.amount)}</p>
{excessValue.value ?? 0}{excessValue.unit}
</h5>
{/if}
{#if ['users', 'executions'].includes(excess.name)}
<div class="u-flex u-main-space-between">
<h5 class="body-text-2 u-stretch u-capitalize">
{excess.name}
</h5>
{formatCurrency(excess.amount)}
</div>
<h5
class="body-text-2 u-stretch u-color-text-offline"
title={formatNumberWithCommas(excess.value)}>
{abbreviateNumber(excess.value)}
</h5>
{/if}
</li>
{/if}
{#if ['users', 'executions'].includes(excess.name)}
<li class="u-flex u-main-space-between u-margin-block-start-8">
<p class="text u-color-text-gray">
<span title={formatNumberWithCommas(excess.value)}
>{abbreviateNumber(excess.value)}</span>
{excess.name}
</p>
<p class="text">{formatCurrency(excess.amount)}</p>
</li>
{/if}
{/each}
<li class="u-flex u-main-space-between u-margin-block-start-16">
<p class="body-text-1 u-bold">Total to-date:</p>
<p class="body-text-1 u-bold">
{formatCurrency(currentInvoice?.amount ?? 0)}
</p>
</li>
{/each}
{/if}
</ul>
</div>
</CollapsibleItem>
{/if}
</Box>
<CollapsibleItem noContent gap={4}>
<span class="body-text-2">Current total (USD)</span>
<span class="tooltip u-cross-center" aria-label="total info">
<span
class="icon-info"
aria-hidden="true"
use:tooltip={{
content:
'Totals displayed are estimates updated every 24 hours and may not accurately reflect your invoice.'
}}></span>
</span>
<div class="body-text-2 u-margin-inline-start-auto">
{formatCurrency(currentInvoice?.amount ?? 0)}
</div>
</CollapsibleItem>
</Collapsible>
</svelte:fragment>
<svelte:fragment slot="actions">
{#if $organization?.billingPlan === BillingPlan.STARTER}
@@ -17,6 +17,7 @@
import type { OrganizationUsage } from '$lib/sdk/billing';
import { BillingPlan } from '$lib/constants';
import { trackEvent } from '$lib/actions/analytics';
import TotalMembers from './totalMembers.svelte';
export let data;
@@ -297,6 +298,7 @@
{/if}
</svelte:fragment>
</CardGrid>
<TotalMembers members={data?.organizationMembers} />
<p class="text common-section u-color-text-gray">
Metrics are estimates updated every 24 hours and may not accurately reflect your invoice.
@@ -36,9 +36,10 @@ export const load: PageLoad = async ({ params, parent }) => {
endDate = currentInvoice.to;
}
const [invoices, usage] = await Promise.all([
const [invoices, usage, organizationMembers] = await Promise.all([
sdk.forConsole.billing.listInvoices(org.$id, [Query.orderDesc('from')]),
sdk.forConsole.billing.listUsage(params.organization, startDate, endDate)
sdk.forConsole.billing.listUsage(params.organization, startDate, endDate),
sdk.forConsole.teams.listMemberships(params.organization)
]);
const queries: string[] = [];
@@ -58,6 +59,7 @@ export const load: PageLoad = async ({ params, parent }) => {
organizationUsage: usage,
projectNames: projectNames.projects,
invoices,
currentInvoice
currentInvoice,
organizationMembers
};
};
@@ -0,0 +1,87 @@
<script lang="ts">
import { tooltip } from '$lib/actions/tooltip';
import { AvatarInitials, CardGrid, Heading, Paginator } from '$lib/components';
import { EmptyCardCloud } from '$lib/components/billing';
import { BillingPlan } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import {
TableBody,
TableCell,
TableCellHead,
TableCellText,
TableHeader,
TableRow,
TableScroll
} from '$lib/elements/table';
import { toLocaleDate } from '$lib/helpers/date';
import { formatCurrency } from '$lib/helpers/numbers';
import { plansInfo, tierToPlan } from '$lib/stores/billing';
import { newMemberModal, organization } from '$lib/stores/organization';
import type { Models } from '@appwrite.io/console';
export let members: Models.MembershipList;
$: total = members?.total ?? 0;
$: plan = $plansInfo?.get($organization?.billingPlan);
</script>
<CardGrid>
<Heading tag="h6" size="7">Members</Heading>
<p class="text">The number of members in your organization.</p>
<svelte:fragment slot="aside">
{#if $organization.billingPlan !== BillingPlan.STARTER}
<div class="u-flex u-flex-vertical">
<div class="u-flex u-main-space-between">
<p>
<span class="heading-level-4">{total}</span>
<span class="body-text-1 u-bold">Members</span>
</p>
<Button secondary on:click={() => newMemberModal.set(true)}>
<span class="icon-plus"></span> <span class="text">Invite</span>
</Button>
</div>
<p class="body-text-2">
Unlimited
<span
class="icon-info"
use:tooltip={{
content: `You can add unlimited organization members on the ${tierToPlan($organization.billingPlan).name} plan for ${formatCurrency(plan.addons.member.price)} each per billing period.`
}}></span>
</p>
</div>
<Paginator
items={members.memberships}
let:paginatedItems
hideFooter={members?.total <= 5}>
<TableScroll noMargin noStyles>
<TableHeader>
<TableCellHead>Members</TableCellHead>
<TableCellHead>Joined</TableCellHead>
</TableHeader>
<TableBody>
{#each paginatedItems as member}
<TableRow>
<TableCell title="name">
<div class="u-flex u-gap-12 u-cross-center">
<AvatarInitials size={32} name={member.userName} />
<span class="text u-trim">
{member.userName ? member.userName : member.userEmail}
</span>
</div>
</TableCell>
<TableCellText title="joined">
{member.joined
? toLocaleDate(member.joined)
: toLocaleDate(member.$createdAt)}
</TableCellText>
</TableRow>
{/each}
</TableBody>
</TableScroll>
</Paginator>
{:else}
<EmptyCardCloud service="members" eventSource="organization_usage" />
{/if}
</svelte:fragment>
</CardGrid>
@@ -2,14 +2,15 @@
import { page } from '$app/stores';
import { Alert, CopyInput, Modal } from '$lib/components';
import { Button, FormList, InputSwitch, InputText, InputTextarea } from '$lib/elements/forms';
import type { Provider } from '$lib/stores/oauth-providers';
import { oAuthProviders, type Provider } from '$lib/stores/oauth-providers';
import { sdk } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import { updateOAuth } from '../updateOAuth';
import type { Models } from '@appwrite.io/console';
const projectId = $page.params.project;
export let provider: Provider;
export let provider: Models.AuthProvider;
export let show = false;
let appId: string = null;
@@ -18,11 +19,13 @@
let teamID: string = null;
let p8: string = null;
let error: string;
let oAuthProvider: Provider;
onMount(() => {
appId ??= provider.appId;
enabled ??= provider.enabled;
if (provider.secret) ({ keyID, teamID, p8 } = JSON.parse(provider.secret));
oAuthProvider = oAuthProviders[provider.key];
});
const update = async () => {
@@ -44,7 +47,7 @@
<p>
To use {provider.name} authentication in your application, first fill in this form. For more
info you can
<a class="link" href={provider.docs} target="_blank" rel="noopener noreferrer">
<a class="link" href={oAuthProvider?.docs} target="_blank" rel="noopener noreferrer">
visit the docs.
</a>
</p>
@@ -2,12 +2,15 @@
import { page } from '$app/stores';
import { Alert, CopyInput, Modal } from '$lib/components';
import { Button, FormList, InputPassword, InputSwitch, InputText } from '$lib/elements/forms';
import type { Provider } from '$lib/stores/oauth-providers';
import { oAuthProviders, type Provider } from '$lib/stores/oauth-providers';
import { sdk } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import { updateOAuth } from '../updateOAuth';
import type { Models } from '@appwrite.io/console';
export let provider: Provider;
const projectId = $page.params.project;
export let provider: Models.AuthProvider;
export let show = false;
let appId: string = null;
@@ -15,14 +18,15 @@
let clientSecret: string = null;
let auth0Domain: string = null;
let error: string;
let oAuthProvider: Provider;
onMount(() => {
appId ??= provider.appId;
enabled ??= provider.enabled;
if (provider.secret) ({ clientSecret, auth0Domain } = JSON.parse(provider.secret));
oAuthProvider = oAuthProviders[provider.key];
});
const projectId = $page.params.project;
const update = async () => {
const result = await updateOAuth({ projectId, provider, secret, appId, enabled });
@@ -45,7 +49,7 @@
<p>
To use {provider.name} authentication in your application, first fill in this form. For more
info you can
<a class="link" href={provider.docs} target="_blank" rel="noopener noreferrer"
<a class="link" href={oAuthProvider?.docs} target="_blank" rel="noopener noreferrer"
>visit the docs.</a>
</p>
<InputSwitch id="state" bind:value={enabled} label={enabled ? 'Enabled' : 'Disabled'} />
@@ -2,12 +2,15 @@
import { page } from '$app/stores';
import { Alert, CopyInput, Modal } from '$lib/components';
import { Button, FormList, InputPassword, InputSwitch, InputText } from '$lib/elements/forms';
import type { Provider } from '$lib/stores/oauth-providers';
import { oAuthProviders, type Provider } from '$lib/stores/oauth-providers';
import { sdk } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import { updateOAuth } from '../updateOAuth';
import type { Models } from '@appwrite.io/console';
export let provider: Provider;
const projectId = $page.params.project;
export let provider: Models.AuthProvider;
export let show = false;
let appId: string = null;
@@ -15,14 +18,15 @@
let clientSecret: string = null;
let authentikDomain: string = null;
let error: string;
let oAuthProvider: Provider;
onMount(() => {
appId ??= provider.appId;
enabled ??= provider.enabled;
if (provider.secret) ({ clientSecret, authentikDomain } = JSON.parse(provider.secret));
oAuthProvider = oAuthProviders[provider.key];
});
const projectId = $page.params.project;
const update = async () => {
const result = await updateOAuth({ projectId, provider, secret, appId, enabled });
@@ -45,7 +49,7 @@
<p>
To use {provider.name} authentication in your application, first fill in this form. For more
info you can
<a class="link" href={provider.docs} target="_blank" rel="noopener noreferrer"
<a class="link" href={oAuthProvider?.docs} target="_blank" rel="noopener noreferrer"
>visit the docs.</a>
</p>
<InputSwitch id="state" bind:value={enabled} label={enabled ? 'Enabled' : 'Disabled'} />
@@ -2,12 +2,15 @@
import { page } from '$app/stores';
import { Alert, CopyInput, Modal } from '$lib/components';
import { Button, FormList, InputPassword, InputSwitch, InputText } from '$lib/elements/forms';
import type { Provider } from '$lib/stores/oauth-providers';
import { oAuthProviders, type Provider } from '$lib/stores/oauth-providers';
import { sdk } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import { updateOAuth } from '../updateOAuth';
import type { Models } from '@appwrite.io/console';
export let provider: Provider;
const projectId = $page.params.project;
export let provider: Models.AuthProvider;
export let show = false;
let appId: string = null;
@@ -15,14 +18,15 @@
let clientSecret: string = null;
let endpoint: string = null;
let error: string;
let oAuthProvider: Provider;
onMount(() => {
appId ??= provider.appId;
enabled ??= provider.enabled;
if (provider.secret) ({ clientSecret, endpoint } = JSON.parse(provider.secret));
oAuthProvider = oAuthProviders[provider.key];
});
const projectId = $page.params.project;
const update = async () => {
const result = await updateOAuth({ projectId, provider, secret, appId, enabled });
@@ -43,7 +47,7 @@
<p>
To use {provider.name} authentication in your application, first fill in this form. For more
info you can
<a class="link" href={provider.docs} target="_blank" rel="noopener noreferrer"
<a class="link" href={oAuthProvider?.docs} target="_blank" rel="noopener noreferrer"
>visit the docs.</a>
</p>
<InputSwitch id="state" bind:value={enabled} label={enabled ? 'Enabled' : 'Disabled'} />
@@ -2,24 +2,27 @@
import { page } from '$app/stores';
import { Alert, CopyInput, Modal } from '$lib/components';
import { Button, FormList, InputPassword, InputSwitch, InputText } from '$lib/elements/forms';
import type { Provider } from '$lib/stores/oauth-providers';
import { oAuthProviders, type Provider } from '$lib/stores/oauth-providers';
import { sdk } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import { updateOAuth } from '../updateOAuth';
export let provider: Provider;
export let show = false;
import type { Models } from '@appwrite.io/console';
const projectId = $page.params.project;
export let provider: Models.AuthProvider;
export let show = false;
let enabled: boolean = null;
let appId: string = null;
let secret: string = null;
let oAuthProvider: Provider;
onMount(() => {
enabled ??= provider.enabled;
appId ??= provider.appId;
secret ??= provider.secret;
oAuthProvider = oAuthProviders[provider.key];
});
let error: string;
@@ -41,7 +44,7 @@
<p>
To use {provider.name} authentication in your application, first fill in this form. For more
info you can
<a class="link" href={provider.docs} target="_blank" rel="noopener noreferrer"
<a class="link" href={oAuthProvider?.docs} target="_blank" rel="noopener noreferrer"
>visit the docs.</a>
</p>
<InputSwitch id="state" bind:value={enabled} label={enabled ? 'Enabled' : 'Disabled'} />
@@ -2,24 +2,27 @@
import { page } from '$app/stores';
import { Alert, CopyInput, Modal } from '$lib/components';
import { Button, FormList, InputPassword, InputSwitch, InputText } from '$lib/elements/forms';
import type { Provider } from '$lib/stores/oauth-providers';
import { sdk } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import { updateOAuth } from '../updateOAuth';
export let provider: Provider;
export let show = false;
import type { Models } from '@appwrite.io/console';
import { oAuthProviders, type Provider } from '$lib/stores/oauth-providers';
const projectId = $page.params.project;
export let provider: Models.AuthProvider;
export let show = false;
let enabled: boolean = null;
let appId: string = null;
let secret: string = null;
let oAuthProvider: Provider;
onMount(() => {
enabled ??= provider.enabled;
appId ??= provider.appId;
secret ??= provider.secret;
oAuthProvider = oAuthProviders[provider.key];
});
let error: string;
@@ -41,7 +44,7 @@
<p>
To use {provider.name} authentication in your application, first fill in this form. For more
info you can
<a class="link" href={provider.docs} target="_blank" rel="noopener noreferrer"
<a class="link" href={oAuthProvider?.docs} target="_blank" rel="noopener noreferrer"
>visit the docs.</a>
</p>
<InputSwitch id="state" bind:value={enabled} label={enabled ? 'Enabled' : 'Disabled'} />
@@ -2,12 +2,15 @@
import { page } from '$app/stores';
import { Alert, CopyInput, Modal } from '$lib/components';
import { Button, FormList, InputPassword, InputSwitch, InputText } from '$lib/elements/forms';
import type { Provider } from '$lib/stores/oauth-providers';
import { oAuthProviders, type Provider } from '$lib/stores/oauth-providers';
import { sdk } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import { updateOAuth } from '../updateOAuth';
import type { Models } from '@appwrite.io/console';
export let provider: Provider;
const projectId = $page.params.project;
export let provider: Models.AuthProvider;
export let show = false;
let appId: string = null;
@@ -15,15 +18,15 @@
let clientSecret: string = null;
let tenantID: string = null;
let error: string;
let oAuthProvider: Provider;
onMount(() => {
appId ??= provider.appId;
enabled ??= provider.enabled;
if (provider.secret) ({ clientSecret, tenantID } = JSON.parse(provider.secret));
oAuthProvider = oAuthProviders[provider.key];
});
const projectId = $page.params.project;
const update = async () => {
const result = await updateOAuth({ projectId, provider, secret, appId, enabled });
@@ -44,7 +47,7 @@
<p>
To use {provider.name} authentication in your application, first fill in this form. For more
info you can
<a class="link" href={provider.docs} target="_blank" rel="noopener noreferrer">
<a class="link" href={oAuthProvider?.docs} target="_blank" rel="noopener noreferrer">
visit the docs.
</a>
</p>
@@ -2,14 +2,15 @@
import { page } from '$app/stores';
import { Alert, CopyInput, Modal } from '$lib/components';
import { Button, FormList, InputPassword, InputSwitch, InputText } from '$lib/elements/forms';
import type { Provider } from '$lib/stores/oauth-providers';
import { oAuthProviders, type Provider } from '$lib/stores/oauth-providers';
import { sdk } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import { updateOAuth } from '../updateOAuth';
import type { Models } from '@appwrite.io/console';
const projectId = $page.params.project;
export let provider: Provider;
export let provider: Models.AuthProvider;
export let show = false;
let appId: string = null;
@@ -20,6 +21,7 @@
let tokenEndpoint: string = null;
let userinfoEndpoint: string = null;
let error: string;
let oAuthProvider: Provider;
// secret is valid if clientSecret is set and either wellKnownEndpoint or all of the other endpoints are set
$: isValidSecret =
@@ -38,6 +40,7 @@
userinfoEndpoint
} = JSON.parse(provider.secret));
}
oAuthProvider = oAuthProviders[provider.key];
});
const update = async () => {
@@ -67,7 +70,7 @@
<p>
To use {provider.name} authentication in your application, first fill in this form. For more
info you can
<a class="link" href={provider.docs} target="_blank" rel="noopener noreferrer"
<a class="link" href={oAuthProvider?.docs} target="_blank" rel="noopener noreferrer"
>visit the docs.</a>
</p>
<InputSwitch id="state" bind:value={enabled} label={enabled ? 'Enabled' : 'Disabled'} />
@@ -2,14 +2,15 @@
import { page } from '$app/stores';
import { Alert, CopyInput, Modal } from '$lib/components';
import { Button, FormList, InputPassword, InputSwitch, InputText } from '$lib/elements/forms';
import type { Provider } from '$lib/stores/oauth-providers';
import { oAuthProviders, type Provider } from '$lib/stores/oauth-providers';
import { sdk } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import { updateOAuth } from '../updateOAuth';
import type { Models } from '@appwrite.io/console';
const projectId = $page.params.project;
export let provider: Provider;
export let provider: Models.AuthProvider;
export let show = false;
let appId: string = null;
@@ -18,6 +19,7 @@
let oktaDomain: string = null;
let authorizationServerId: string = null;
let error: string;
let oAuthProvider: Provider;
onMount(() => {
appId ??= provider.appId;
@@ -25,6 +27,7 @@
if (provider.secret) {
({ clientSecret, oktaDomain, authorizationServerId } = JSON.parse(provider.secret));
}
oAuthProvider = oAuthProviders[provider.key];
});
const update = async () => {
@@ -49,7 +52,7 @@
<p>
To use {provider.name} authentication in your application, first fill in this form. For more
info you can
<a class="link" href={provider.docs} target="_blank" rel="noopener noreferrer"
<a class="link" href={oAuthProvider?.docs} target="_blank" rel="noopener noreferrer"
>visit the docs.</a>
</p>
<InputSwitch id="state" bind:value={enabled} label={enabled ? 'Enabled' : 'Disabled'} />
@@ -8,18 +8,15 @@
import { app } from '$lib/stores/app';
import { authMethods, type AuthMethod } from '$lib/stores/auth-methods';
import { addNotification } from '$lib/stores/notifications';
import type { Provider } from '$lib/stores/oauth-providers';
import { OAuthProviders } from '$lib/stores/oauth-providers';
import { oAuthProviders } from '$lib/stores/oauth-providers';
import { sdk } from '$lib/stores/sdk';
import type { Models } from '@appwrite.io/console';
import { project } from '../../store';
const projectId = $page.params.project;
let showProvider = false;
$: {
authMethods.load($project);
OAuthProviders.load($project);
}
let selectedProvider: Models.AuthProvider | null = null;
async function authUpdate(box: AuthMethod) {
try {
@@ -42,10 +39,10 @@
}
}
let selectedProvider: Provider | null = null;
$: authMethods.load($project);
</script>
{#if $authMethods && $OAuthProviders}
{#if $authMethods && $project}
<Container>
<CardGrid>
<Heading tag="h2" size="7">Auth methods</Heading>
@@ -67,34 +64,37 @@
<section class="common-section">
<h2 class="heading-level-6 common-section">OAuth2 Providers</h2>
<ul class="grid-box common-section">
{#each $OAuthProviders.providers
{#each $project.oAuthProviders
.filter((p) => p.name !== 'Mock')
.sort((a, b) => (a.enabled === b.enabled ? 0 : a.enabled ? -1 : 1)) as provider}
<li class="grid-box-item">
<button
class="card u-flex u-flex-vertical u-cross-center u-width-full-line"
on:click={() => {
selectedProvider = provider;
showProvider = true;
trackEvent(`click_select_provider`, {
provider: provider.key.toLowerCase()
});
}}>
<div class="avatar">
<img
height="20"
width="20"
src={`/icons/${$app.themeInUse}/color/${provider.icon}.svg`}
alt={provider.name} />
</div>
<p class="u-margin-block-start-8">{provider.name}</p>
<div class="u-margin-block-start-24">
<Pill success={provider.enabled}>
{provider.enabled ? 'enabled' : 'disabled'}
</Pill>
</div>
</button>
</li>
{@const oAuthProvider = oAuthProviders[provider.key]}
{#if oAuthProvider}
<li class="grid-box-item">
<button
class="card u-flex u-flex-vertical u-cross-center u-width-full-line"
on:click={() => {
selectedProvider = provider;
showProvider = true;
trackEvent(`click_select_provider`, {
provider: provider.key.toLowerCase()
});
}}>
<div class="avatar">
<img
height="20"
width="20"
src={`/icons/${$app.themeInUse}/color/${oAuthProvider.icon}.svg`}
alt={provider.name} />
</div>
<p class="u-margin-block-start-8">{provider.name}</p>
<div class="u-margin-block-start-24">
<Pill success={provider.enabled}>
{provider.enabled ? 'enabled' : 'disabled'}
</Pill>
</div>
</button>
</li>
{/if}
{/each}
</ul>
</section>
@@ -102,8 +102,9 @@
{/if}
{#if selectedProvider && showProvider}
{@const oAuthProvider = oAuthProviders[selectedProvider.key]}
<svelte:component
this={selectedProvider.component}
this={oAuthProvider.component}
bind:provider={selectedProvider}
bind:show={showProvider}
on:close={() => {
@@ -3,13 +3,12 @@ import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Dependencies } from '$lib/constants';
import { isValueOfStringEnum } from '$lib/helpers/types';
import { addNotification } from '$lib/stores/notifications';
import type { Provider } from '$lib/stores/oauth-providers';
import { sdk } from '$lib/stores/sdk';
import { OAuthProvider } from '@appwrite.io/console';
import { OAuthProvider, type Models } from '@appwrite.io/console';
type Args = {
projectId: string;
provider: Provider;
provider: Models.AuthProvider;
appId: string;
secret: string;
enabled: boolean;
@@ -20,8 +20,7 @@
import { addNotification } from '$lib/stores/notifications';
import { invalidate } from '$app/navigation';
import type { Column } from '$lib/helpers/types';
import { OAuthProviders, type Provider } from '$lib/stores/oauth-providers';
import { project } from '$routes/console/project-[project]/store';
import { oAuthProviders } from '$lib/stores/oauth-providers';
import { app } from '$lib/stores/app';
export let columns: Column[];
@@ -30,7 +29,6 @@
let selectedIds: string[] = [];
let showDelete = false;
let deleting = false;
let providerLookup: Record<string, Provider> = {};
async function handleDelete() {
showDelete = false;
@@ -58,12 +56,6 @@
showDelete = false;
}
}
$: OAuthProviders.load($project);
$: providerLookup = $OAuthProviders.providers.reduce((acc, provider) => {
acc[provider.key] = provider;
return acc;
}, {});
</script>
<TableScroll>
@@ -93,7 +85,7 @@
</TableCell>
{/key}
{:else if column.id === 'provider'}
{@const provider = providerLookup[identity[column.id]]}
{@const provider = oAuthProviders[identity[column.id]]}
<TableCellText title={column.title} width={column.width}>
<div class="u-inline-flex u-cross-center u-gap-8">
<div class="avatar is-size-small">
@@ -2,11 +2,10 @@ import { page } from '$app/stores';
import { derived } from 'svelte/store';
import type { Models } from '@appwrite.io/console';
export const runtimesList = derived(page, async ($page) => {
const { runtimes } = (await $page.data.runtimesList) as Models.RuntimeList;
const filtered = runtimes.filter((runtime) => !runtime.$id.startsWith('python-ml'));
return { runtimes: filtered, total: filtered.length };
});
export const runtimesList = derived(
page,
async ($page) => (await $page.data.runtimesList) as Models.RuntimeList
);
export const baseRuntimesList = derived(runtimesList, async ($runtimesList) => {
const baseRuntimes = new Map<string, Models.Runtime>();
@@ -36,6 +36,8 @@
return 'failed';
} else if (status === 'completed') {
return 'completed';
} else if (status === 'processing') {
return 'processing';
}
return 'pending';
+9 -8
View File
@@ -18,6 +18,7 @@
import { page } from '$app/stores';
import { OAuthProvider } from '@appwrite.io/console';
import { redirectTo } from '$routes/store';
import { user } from '$lib/stores/user';
let mail: string, pass: string, disabled: boolean;
@@ -26,18 +27,18 @@
disabled = true;
await sdk.forConsole.account.createEmailPasswordSession(mail, pass);
await invalidate(Dependencies.ACCOUNT);
addNotification({
type: 'success',
message: 'Successfully logged in.'
});
trackEvent(Submit.AccountLogin);
if ($user) {
addNotification({
type: 'success',
message: 'Successfully logged in.'
});
}
if ($redirectTo) {
window.location.href = $redirectTo;
return;
}
await invalidate(Dependencies.ACCOUNT);
trackEvent(Submit.AccountCreate);
if ($page.url.searchParams) {
const redirect = $page.url.searchParams.get('redirect');
$page.url.searchParams.delete('redirect');
@@ -55,7 +56,7 @@
type: 'error',
message: error.message
});
trackError(error, Submit.AccountCreate);
trackError(error, Submit.AccountLogin);
}
}
+14 -123
View File
@@ -1,58 +1,31 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto, invalidate } from '$app/navigation';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { Button, Form, FormItem, FormList, InputDigits, InputText } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { Button, Form } from '$lib/elements/forms';
import { sdk } from '$lib/stores/sdk';
import { Unauthenticated } from '$lib/layout';
import { Dependencies } from '$lib/constants';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import type { Models } from '@appwrite.io/console';
import MfaChallengeFormList, { verify } from '$lib/components/mfaChallengeFormList.svelte';
import { page } from '$app/stores';
import { AuthenticationFactor, type Models } from '@appwrite.io/console';
import { addNotification } from '$lib/stores/notifications.js';
export let data;
let code: string;
let disabled: boolean;
let challenge: Models.MfaChallenge;
let challengeType: AuthenticationFactor;
const factors = data.factors as Models.MfaFactors & { recoveryCode: boolean };
const enabledFactors = Object.entries(factors).filter(([_, enabled]) => enabled);
async function createChallenge(factor: AuthenticationFactor) {
challengeType = factor;
challenge = await sdk.forConsole.account.createMfaChallenge(factor);
}
onMount(async () => {
const enabledNonRecoveryFactors = enabledFactors.filter(
([factor, _]) => factor != 'recoveryCode'
);
if (enabledNonRecoveryFactors.length == 1) {
if (factors.phone) {
createChallenge(AuthenticationFactor.Phone);
} else if (factors.email) {
createChallenge(AuthenticationFactor.Email);
}
}
});
let disabled = false;
let challenge: Models.MfaChallenge = null;
let code = '';
async function back() {
await sdk.forConsole.account.deleteSession('current');
await goto(`${base}/`);
}
async function verify() {
async function submit() {
disabled = true;
try {
disabled = true;
if (challenge == null) {
await createChallenge(AuthenticationFactor.Totp);
}
await sdk.forConsole.account.updateMfaChallenge(challenge.$id, code);
await invalidate(Dependencies.ACCOUNT);
trackEvent(Submit.AccountCreate);
await verify(challenge, code);
if ($page.url.searchParams) {
const redirect = $page.url.searchParams.get('redirect');
$page.url.searchParams.delete('redirect');
@@ -65,12 +38,11 @@
await goto(`${base}/console`);
}
} catch (error) {
disabled = false;
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.AccountCreate);
disabled = false;
}
}
</script>
@@ -91,89 +63,8 @@
</svelte:fragment>
<svelte:fragment slot="title">Verify your identity</svelte:fragment>
<svelte:fragment>
<Form onSubmit={verify}>
<FormList>
{#if factors.totp && (challengeType == AuthenticationFactor.Totp || challengeType == null)}
<p class="body-text-1">
Enter below a 6-digit one-time code generated by your authentication app.
</p>
<InputDigits bind:value={code} required autofocus />
{:else if challengeType == AuthenticationFactor.Email}
<p class="body-text-1">
A 6-digit verification code was sent to your email, enter it below.
</p>
<InputDigits bind:value={code} required autofocus />
{:else if challengeType == AuthenticationFactor.Phone}
<p class="body-text-1">
A 6-digit verification code was sent to your phone, enter it below.
</p>
<InputDigits bind:value={code} required autofocus />
{:else if challengeType == AuthenticationFactor.Recoverycode}
<p class="body-text-1">
Enter below one of the recovery codes you received when enabling MFA for
this account.
</p>
<InputText id="recovery-code" bind:value={code} required autofocus />
{/if}
<FormItem>
<Button fullWidth submit {disabled}>Verify</Button>
</FormItem>
{#if enabledFactors.length > 1}
<span class="with-separators eyebrow-heading-3">or</span>
<div class="u-flex-vertical u-gap-8">
{#if factors.totp && challengeType != null && challengeType != AuthenticationFactor.Totp}
<FormItem>
<Button
secondary
fullWidth
{disabled}
on:click={() => createChallenge(AuthenticationFactor.Totp)}>
<span
class="icon-device-mobile u-font-size-20"
aria-hidden="true" />
<span class="text">Authenticator app</span>
</Button>
</FormItem>
{/if}
{#if factors.email && challengeType != AuthenticationFactor.Email}
<FormItem>
<Button
secondary
fullWidth
{disabled}
on:click={() => createChallenge(AuthenticationFactor.Email)}>
<span class="icon-mail u-font-size-20" aria-hidden="true" />
<span class="text">Email verification</span>
</Button>
</FormItem>
{/if}
{#if factors.phone && challengeType != AuthenticationFactor.Phone}
<FormItem>
<Button
secondary
fullWidth
{disabled}
on:click={() => createChallenge(AuthenticationFactor.Phone)}>
<span class="icon-chat-alt u-font-size-20" aria-hidden="true" />
<span class="text">Phone verification</span>
</Button>
</FormItem>
{/if}
{#if factors.recoveryCode && challengeType != AuthenticationFactor.Recoverycode}
<FormItem>
<Button
text
fullWidth
{disabled}
on:click={() =>
createChallenge(AuthenticationFactor.Recoverycode)}>
<span class="text">Use recovery code</span>
</Button>
</FormItem>
{/if}
</div>
{/if}
</FormList>
<Form onSubmit={submit} class="body-text-1">
<MfaChallengeFormList {factors} bind:challenge bind:code bind:disabled />
</Form>
</svelte:fragment>
</Unauthenticated>
+21
View File
@@ -0,0 +1,21 @@
export function getOrganizationIdFromUrl(pathname: string) {
const regex = /\/console\/organization-([^/]+)(\/.*)?/;
const match = pathname.match(regex);
if (match) {
return match[1];
}
throw new Error('Organization ID not found in pathname');
}
export function getProjectIdFromUrl(pathname: string) {
const regex = /\/console\/project-([^/]+)(\/.*)?/;
const match = pathname.match(regex);
if (match) {
return match[1];
}
throw new Error('Project ID not found in pathname');
}
@@ -0,0 +1,8 @@
import { test } from '@playwright/test';
import { registerUserStep } from '../steps/account';
import { createFreeProject } from '../steps/free-project';
test('onboarding - free tier', async ({ page }) => {
await registerUserStep(page);
await createFreeProject(page);
});
@@ -0,0 +1,8 @@
import { test } from '@playwright/test';
import { registerUserStep } from '../steps/account';
import { createProProject } from '../steps/pro-project';
test('onboarding - pro', async ({ page }) => {
await registerUserStep(page);
await createProProject(page);
});
@@ -0,0 +1,19 @@
import { test } from '@playwright/test';
import { registerUserStep } from '../steps/account';
import { createFreeProject } from '../steps/free-project';
import { enterCreditCard } from '../steps/pro-project';
test('upgrade - free tier', async ({ page }) => {
await registerUserStep(page);
await createFreeProject(page);
await test.step('upgrade project', async () => {
await page.getByRole('link', { name: 'upgrade' }).click();
await page.waitForURL('/console/organization-**/change-plan');
await page.locator('input[value="tier-1"]').click();
await page.getByRole('button', { name: ' Add', exact: true }).click();
await enterCreditCard(page);
// skip members
await page.getByRole('button', { name: 'change plan' }).click();
await page.waitForURL('/console/organization-**');
});
});
-54
View File
@@ -1,54 +0,0 @@
import { expect, test } from '@playwright/test';
/*TODO: Things to test in login:
- presence of forgot password link
- validation message on wrong input
- correct response and redirect after login
- logout works
- back button does not log out user
- forward button does not log in user after logout
- limit to total number of login attempts
-
*/
test('login page has inputs and button', async ({ page }) => {
await page.goto('/login');
const mail = page.locator('id=email');
const pass = page.locator('id=password');
const button = page.locator('button:has-text("Sign in")');
expect(await mail.isVisible());
expect(await pass.isVisible());
expect(await button.isVisible());
});
test('login page has a working sign up link', async ({ page }) => {
await page.goto('/login');
await page.waitForTimeout(100);
const signup = page.locator('a[href="/register"]');
expect(await signup.isVisible());
});
test('login page inputs are navigable by keyboard', async ({ page }) => {
await page.goto('/login');
const mail = page.locator('id=email');
await mail.focus();
await page.keyboard.type('wrongemail@apppwrite.io');
await page.keyboard.press('Tab');
await page.keyboard.type('password');
await page.keyboard.press('Tab');
await page.keyboard.press('Enter');
expect(await page.locator('.toaster-item').isVisible());
expect(await page.locator('text=Invalid credentials').isVisible());
});
test('login page shows error & response is 401 with wrong inputs', async ({ page }) => {
await page.goto('/login');
await page.fill('id=email', 'wrongemail@apppwrite.io');
await page.fill('id=password', 'wrongpassword');
await page.click('button:has-text("Sign in")');
page.on('response', (response) => {
expect(response.status()).toBe(401);
});
expect(await page.locator('.toaster-item').isVisible());
expect(await page.locator('text=Invalid credentials').isVisible());
});
-11
View File
@@ -1,11 +0,0 @@
import { expect, test } from '@playwright/test';
test('register page has inputs', async ({ page }) => {
await page.goto('/register');
const name = page.locator('id=name');
const mail = page.locator('id=email');
const pass = page.locator('id=password');
expect(await name.isVisible());
expect(await mail.isVisible());
expect(await pass.isVisible());
});
+34
View File
@@ -0,0 +1,34 @@
import { test, type Page } from '@playwright/test';
type Metadata = {
name: string;
email: string;
password: string;
};
export function registerUserStep(page: Page): Promise<Metadata> {
return test.step('register user', async () => {
const seed = crypto.randomUUID();
await page.goto('/register');
await page.getByRole('button', { name: 'only required' }).click();
const inputs = {
name: page.locator('id=name'),
email: page.locator('id=email'),
password: page.locator('id=password'),
terms: page.locator('id=terms')
};
const values = {
name: 'testuser ' + seed,
email: 'testuser+' + seed + '@apppwrite.io',
password: 'testuser+' + seed + '@apppwrite.io'
};
await inputs.name.fill(values.name);
await inputs.email.fill(values.email);
await inputs.password.fill(values.password);
await inputs.terms.check();
await page.getByRole('button', { name: 'Sign up', exact: true }).click();
await page.waitForURL('/console/onboarding');
return values;
});
}
+37
View File
@@ -0,0 +1,37 @@
import { test, expect, type Page } from '@playwright/test';
import { getOrganizationIdFromUrl, getProjectIdFromUrl } from '../helpers/url';
type Metadata = {
id: string;
organizationId: string;
};
export async function createFreeProject(page: Page): Promise<Metadata> {
const organizationId = await test.step('create organization', async () => {
await page.goto('/console');
await page.waitForURL('/console/onboarding');
await page.locator('id=name').fill('test org');
await page.locator('id=plan').selectOption('tier-0');
await page.getByRole('button', { name: 'get started' }).click();
await page.waitForURL('/console/organization-**');
return getOrganizationIdFromUrl(page.url());
});
const projectId = await test.step('create project', async () => {
await page.waitForURL('/console/organization-**');
await page.getByRole('button', { name: 'create project' }).first().click();
await page.locator('id=name').fill('test project');
await page.getByRole('button', { name: 'next' }).click();
await page.locator('label').filter({ hasText: 'Frankfurt' }).click();
await page.getByRole('button', { name: 'create' }).click();
await page.waitForURL('/console/project-**/overview/platforms');
expect(page.url()).toContain('/console/project-');
return getProjectIdFromUrl(page.url());
});
return {
id: projectId,
organizationId
};
}
+53
View File
@@ -0,0 +1,53 @@
import { test, expect, type Page } from '@playwright/test';
import { getOrganizationIdFromUrl, getProjectIdFromUrl } from '../helpers/url';
type Metadata = {
id: string;
organizationId: string;
};
export async function enterCreditCard(page: Page) {
await page.getByPlaceholder('cardholder').fill('Test User');
const stripe = page.frameLocator('[title="Secure payment input frame"]');
await stripe.locator('id=Field-numberInput').fill('4242424242424242');
await stripe.locator('id=Field-expiryInput').fill('1250');
await stripe.locator('id=Field-cvcInput').fill('123');
await stripe.locator('id=Field-countryInput').selectOption('DE');
await page.getByRole('button', { name: 'Add', exact: true }).click();
}
export async function createProProject(page: Page): Promise<Metadata> {
const organizationId = await test.step('create organization', async () => {
await page.goto('/console');
await page.waitForURL('/console/onboarding');
await page.locator('id=name').fill('test org');
await page.locator('id=plan').selectOption('tier-1');
await page.getByRole('button', { name: 'get started' }).click();
await page.waitForURL('/console/create-organization**');
await page.getByRole('button', { name: ' Add', exact: true }).click();
await enterCreditCard(page);
// skip members
await page.getByRole('button', { name: 'create organization' }).click();
await page.waitForURL('/console/organization-**');
return getOrganizationIdFromUrl(page.url());
});
const projectId = await test.step('create project', async () => {
await page.waitForURL('/console/organization-**');
await page.getByRole('button', { name: 'create project' }).first().click();
await page.getByPlaceholder('project name').fill('test project');
await page.getByRole('button', { name: 'next' }).click();
await page.locator('label').filter({ hasText: 'frankfurt' }).click();
await page.getByRole('button', { name: 'create' }).click();
await page.waitForURL('/console/project-**/overview/platforms');
expect(page.url()).toContain('/console/project-');
return getProjectIdFromUrl(page.url());
});
return {
id: projectId,
organizationId
};
}