diff --git a/.env.example b/.env.example
index 30973b2c1e..14f47df658 100644
--- a/.env.example
+++ b/.env.example
@@ -4,5 +4,4 @@ PUBLIC_APPWRITE_MULTI_REGION=false
PUBLIC_APPWRITE_ENDPOINT=http://localhost/v1
PUBLIC_STRIPE_KEY=
PUBLIC_GROWTH_ENDPOINT=
-PUBLIC_CONSOLE_EMAIL_VERIFICATION=false
PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=true
\ No newline at end of file
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 4036ac982a..4bb77fbcbc 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -41,7 +41,6 @@ jobs:
"PUBLIC_CONSOLE_MODE=cloud"
"PUBLIC_CONSOLE_FEATURE_FLAGS="
"PUBLIC_APPWRITE_MULTI_REGION=true"
- "PUBLIC_CONSOLE_EMAIL_VERIFICATION=true"
"PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=false"
"PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}"
"PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY }}"
@@ -84,7 +83,6 @@ jobs:
"PUBLIC_CONSOLE_MODE=cloud"
"PUBLIC_CONSOLE_FEATURE_FLAGS="
"PUBLIC_APPWRITE_MULTI_REGION=true"
- "PUBLIC_CONSOLE_EMAIL_VERIFICATION=false"
"PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=false"
"PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}"
"PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY_STAGE }}"
@@ -124,7 +122,6 @@ jobs:
build-args: |
"PUBLIC_CONSOLE_MODE=self-hosted"
"PUBLIC_APPWRITE_MULTI_REGION=false"
- "PUBLIC_CONSOLE_EMAIL_VERIFICATION=false"
"PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=true"
"PUBLIC_CONSOLE_FEATURE_FLAGS="
"PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}"
@@ -164,7 +161,6 @@ jobs:
build-args: |
"PUBLIC_CONSOLE_MODE=cloud"
"PUBLIC_APPWRITE_MULTI_REGION=false"
- "PUBLIC_CONSOLE_EMAIL_VERIFICATION=false"
"PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=false"
"PUBLIC_CONSOLE_FEATURE_FLAGS="
"PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY_STAGE }}"
diff --git a/AGENTS.md b/AGENTS.md
index 86083b656c..993b448089 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -222,7 +222,6 @@ Set via `.env` (copy `.env.example`). All prefixed with `PUBLIC_` for SvelteKit:
| `PUBLIC_STRIPE_KEY` | -- | Stripe public key (cloud only) |
| `PUBLIC_GROWTH_ENDPOINT` | -- | Analytics endpoint |
| `PUBLIC_CONSOLE_FEATURE_FLAGS` | -- | Feature flags |
-| `PUBLIC_CONSOLE_EMAIL_VERIFICATION` | `false` | Require email verification |
| `PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS` | `true` | Mock AI in dev |
## Common pitfalls
diff --git a/Dockerfile b/Dockerfile
index 691652e7cb..11440e89bd 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -17,7 +17,6 @@ ADD ./static /app/static
ARG PUBLIC_CONSOLE_MODE
ARG PUBLIC_CONSOLE_FEATURE_FLAGS
ARG PUBLIC_APPWRITE_MULTI_REGION
-ARG PUBLIC_CONSOLE_EMAIL_VERIFICATION
ARG PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS
ARG PUBLIC_APPWRITE_ENDPOINT
ARG PUBLIC_GROWTH_ENDPOINT
@@ -31,7 +30,6 @@ ENV PUBLIC_GROWTH_ENDPOINT=$PUBLIC_GROWTH_ENDPOINT
ENV PUBLIC_CONSOLE_MODE=$PUBLIC_CONSOLE_MODE
ENV PUBLIC_CONSOLE_FEATURE_FLAGS=$PUBLIC_CONSOLE_FEATURE_FLAGS
ENV PUBLIC_APPWRITE_MULTI_REGION=$PUBLIC_APPWRITE_MULTI_REGION
-ENV PUBLIC_CONSOLE_EMAIL_VERIFICATION=$PUBLIC_CONSOLE_EMAIL_VERIFICATION
ENV PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=$PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS
ENV PUBLIC_STRIPE_KEY=$PUBLIC_STRIPE_KEY
ENV PUBLIC_CONSOLE_FINGERPRINT_KEY=$PUBLIC_CONSOLE_FINGERPRINT_KEY
diff --git a/build.js b/build.js
index 1b99db2213..f852e5e9a9 100644
--- a/build.js
+++ b/build.js
@@ -28,7 +28,6 @@ async function main() {
logEnv('MULTI REGION', env?.PUBLIC_APPWRITE_MULTI_REGION);
logEnv('APPWRITE ENDPOINT', env?.PUBLIC_APPWRITE_ENDPOINT, 'relative');
logEnv('GROWTH ENDPOINT', env?.PUBLIC_GROWTH_ENDPOINT);
- logEnv('CONSOLE EMAIL VERIFICATION', env?.PUBLIC_CONSOLE_EMAIL_VERIFICATION);
logEnv('CONSOLE MOCK AI SUGGESTIONS', env?.PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS);
log();
logDelimiter();
diff --git a/bun.lock b/bun.lock
index dd34a80e7d..a4f83a4c1f 100644
--- a/bun.lock
+++ b/bun.lock
@@ -6,7 +6,7 @@
"name": "@appwrite/console",
"dependencies": {
"@ai-sdk/svelte": "^1.1.24",
- "@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@d223f36",
+ "@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@467cd21",
"@appwrite.io/pink-icons": "0.25.0",
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@bfe7ce3",
"@appwrite.io/pink-legacy": "^1.0.3",
@@ -113,7 +113,7 @@
"@analytics/type-utils": ["@analytics/type-utils@0.6.4", "", {}, "sha512-Ou1gQxFakOWLcPnbFVsrPb8g1wLLUZYYJXDPjHkG07+5mustGs5yqACx42UAu4A6NszNN6Z5gGxhyH45zPWRxw=="],
- "@appwrite.io/console": ["@appwrite.io/console@https://pkg.vc/-/@appwrite/@appwrite.io/console@d223f36", { "dependencies": { "json-bigint": "1.0.0" } }],
+ "@appwrite.io/console": ["@appwrite.io/console@https://pkg.vc/-/@appwrite/@appwrite.io/console@467cd21", { "dependencies": { "json-bigint": "1.0.0" } }],
"@appwrite.io/pink-icons": ["@appwrite.io/pink-icons@0.25.0", "", {}, "sha512-0O3i2oEuh5mWvjO80i+X6rbzrWLJ1m5wmv2/M3a1p2PyBJsFxN8xQMTEmTn3Wl/D26SsM7SpzbdW6gmfgoVU9Q=="],
diff --git a/package.json b/package.json
index 588c697938..91e5325526 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,7 @@
},
"dependencies": {
"@ai-sdk/svelte": "^1.1.24",
- "@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@d223f36",
+ "@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@467cd21",
"@appwrite.io/pink-icons": "0.25.0",
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@bfe7ce3",
"@appwrite.io/pink-legacy": "^1.0.3",
diff --git a/src/hooks.client.ts b/src/hooks.client.ts
index 4a6565025a..44da3b9075 100644
--- a/src/hooks.client.ts
+++ b/src/hooks.client.ts
@@ -1,6 +1,7 @@
import * as Sentry from '@sentry/sveltekit';
import { isCloud, isProd } from '$lib/system';
import { AppwriteException } from '@appwrite.io/console';
+import { isVerifyEmailRedirectError } from '$lib/helpers/emailVerification';
import type { HandleClientError } from '@sveltejs/kit';
Sentry.init({
@@ -12,7 +13,9 @@ Sentry.init({
});
export const handleError: HandleClientError = ({ error, message, status }) => {
- console.error(error);
+ if (!isVerifyEmailRedirectError(error)) {
+ console.error(error);
+ }
let type;
if (error instanceof AppwriteException) {
diff --git a/src/lib/helpers/emailVerification.ts b/src/lib/helpers/emailVerification.ts
new file mode 100644
index 0000000000..7fe96eb700
--- /dev/null
+++ b/src/lib/helpers/emailVerification.ts
@@ -0,0 +1,28 @@
+import { AppwriteException } from '@appwrite.io/console';
+
+/** True when access is blocked until the console account email is verified. */
+export function isVerifyEmailRedirectError(error: unknown): boolean {
+ if (error instanceof AppwriteException) {
+ return (
+ error.type === 'user_email_not_verified' ||
+ error.type === 'console_account_verification_required' ||
+ (error.message?.includes('Console account verification is required') ?? false)
+ );
+ }
+
+ if (error && typeof error === 'object' && 'message' in error) {
+ const msg = (error as { message: unknown }).message;
+ if (typeof msg !== 'string') return false;
+ const typ =
+ 'type' in error && typeof (error as { type: unknown }).type === 'string'
+ ? (error as { type: string }).type
+ : undefined;
+ return (
+ typ === 'user_email_not_verified' ||
+ typ === 'console_account_verification_required' ||
+ msg.includes('Console account verification is required')
+ );
+ }
+
+ return false;
+}
diff --git a/src/lib/stores/billing.ts b/src/lib/stores/billing.ts
index 1589dec057..cdd3fc53bc 100644
--- a/src/lib/stores/billing.ts
+++ b/src/lib/stores/billing.ts
@@ -78,10 +78,16 @@ export const showBudgetAlert = derived(
);
function getPlansInfoStore(): BillingPlansMap | null {
- return get(plansInfo) ?? get(page).data?.plansInfo ?? null;
+ return get(plansInfo) ?? get(page).data?.plansInfo ?? new Map();
}
-function makeBillingPlan(billingPlanOrId: string | Models.BillingPlan): Models.BillingPlan {
+function makeBillingPlan(
+ billingPlanOrId: string | Models.BillingPlan | null | undefined
+): Models.BillingPlan | null {
+ if (!billingPlanOrId) {
+ return null;
+ }
+
return typeof billingPlanOrId === 'string' ? billingIdToPlan(billingPlanOrId) : billingPlanOrId;
}
@@ -89,21 +95,35 @@ export function getRoleLabel(role: string) {
return roles.find((r) => r.value === role)?.label ?? role;
}
-export function isStarterPlan(billingPlanOrId: string | Models.BillingPlan): boolean {
+export function isStarterPlan(
+ billingPlanOrId: string | Models.BillingPlan | null | undefined
+): boolean {
const billingPlan = makeBillingPlan(billingPlanOrId);
return planHasGroup(billingPlan, BillingPlanGroup.Starter);
}
-export function canUpgrade(billingPlanOrId: string | Models.BillingPlan): boolean {
+export function canUpgrade(
+ billingPlanOrId: string | Models.BillingPlan | null | undefined
+): boolean {
const billingPlan = makeBillingPlan(billingPlanOrId);
+ if (!billingPlan?.$id) {
+ return false;
+ }
+
const nextTier = getNextTierBillingPlan(billingPlan.$id);
// defaults back to PRO, so adjust the check!
return billingPlan.$id !== nextTier.$id;
}
-export function canDowngrade(billingPlanOrId: string | Models.BillingPlan): boolean {
+export function canDowngrade(
+ billingPlanOrId: string | Models.BillingPlan | null | undefined
+): boolean {
const billingPlan = makeBillingPlan(billingPlanOrId);
+ if (!billingPlan?.$id) {
+ return false;
+ }
+
const nextTier = getPreviousTierBillingPlan(billingPlan.$id);
// defaults back to Starter, so adjust the check!
@@ -111,7 +131,7 @@ export function canDowngrade(billingPlanOrId: string | Models.BillingPlan): bool
}
export function planHasGroup(
- billingPlanOrId: string | Models.BillingPlan,
+ billingPlanOrId: string | Models.BillingPlan | null | undefined,
group: BillingPlanGroup
): boolean {
const billingPlan = makeBillingPlan(billingPlanOrId);
@@ -567,6 +587,9 @@ export function checkForMarkedForDeletion(org: Models.Organization) {
export async function checkForMissingPaymentMethod() {
const starterPlan = getBasePlanFromGroup(BillingPlanGroup.Starter);
+ if (!starterPlan?.$id) {
+ return;
+ }
const orgs = await sdk.forConsole.organizations.list({
queries: [
diff --git a/src/lib/system.ts b/src/lib/system.ts
index 4532609925..0c9a44b76e 100644
--- a/src/lib/system.ts
+++ b/src/lib/system.ts
@@ -11,7 +11,6 @@ export const VARS = {
APPWRITE_ENDPOINT: env.PUBLIC_APPWRITE_ENDPOINT ?? undefined,
GROWTH_ENDPOINT: env.PUBLIC_GROWTH_ENDPOINT ?? undefined,
PUBLIC_STRIPE_KEY: env.PUBLIC_STRIPE_KEY ?? undefined,
- EMAIL_VERIFICATION: env.PUBLIC_CONSOLE_EMAIL_VERIFICATION === 'true',
MOCK_AI_SUGGESTIONS: (env.PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS ?? 'true') === 'true'
};
diff --git a/src/routes/(console)/+error.svelte b/src/routes/(console)/+error.svelte
index 7c01dcbb27..41863e61a4 100644
--- a/src/routes/(console)/+error.svelte
+++ b/src/routes/(console)/+error.svelte
@@ -1,9 +1,18 @@
-
diff --git a/src/routes/(console)/+layout.ts b/src/routes/(console)/+layout.ts
index b221432942..f40123b438 100644
--- a/src/routes/(console)/+layout.ts
+++ b/src/routes/(console)/+layout.ts
@@ -1,21 +1,71 @@
import { sdk } from '$lib/stores/sdk';
import { isCloud } from '$lib/system';
import type { LayoutLoad } from './$types';
+import type { Account } from '$lib/stores/user';
import { Dependencies } from '$lib/constants';
-import { Platform, Query } from '@appwrite.io/console';
+import { Platform, Query, type Models } from '@appwrite.io/console';
import { makePlansMap } from '$lib/helpers/billing';
import { plansInfo as plansInfoStore } from '$lib/stores/billing';
import { normalizeConsoleVariables } from '$lib/helpers/domains';
import { syncServerTime } from '$lib/helpers/fingerprint';
+import { redirect } from '@sveltejs/kit';
+import { resolve } from '$app/paths';
+import { isVerifyEmailRedirectError } from '$lib/helpers/emailVerification';
-export const load: LayoutLoad = async ({ depends, parent }) => {
- const { organizations, plansInfo } = await parent();
+export const load: LayoutLoad = async ({ depends, parent, url }) => {
+ const parentData = await parent();
+ const { organizations, plansInfo } = parentData;
+ const account = parentData.account as Account | undefined;
+
+ const { endpoint, project } = sdk.forConsole.client.config;
+ const verifyEmailUrl = resolve('/verify-email');
+
+ // While unverified, several console APIs (not only teams) may return 401; avoid failing the layout.
+ if (url.pathname === verifyEmailUrl && account && !account.emailVerification) {
+ depends(Dependencies.RUNTIMES);
+ depends(Dependencies.CONSOLE_VARIABLES);
+ depends(Dependencies.ORGANIZATION);
+
+ const [preferences, rawConsoleVariables, versionData] = await Promise.all([
+ sdk.forConsole.account.getPrefs().catch(() => ({}) as Models.DefaultPreferences),
+ sdk.forConsole.console.variables().catch(() => ({}) as Models.ConsoleVariables),
+ fetch(`${endpoint}/health/version`, {
+ headers: { 'X-Appwrite-Project': project as string }
+ })
+ .then(async (response) => {
+ const dateHeader = response.headers.get('Date');
+ const parsed = dateHeader ? new Date(dateHeader).getTime() : NaN;
+ if (Number.isFinite(parsed)) {
+ syncServerTime(Math.floor(parsed / 1000));
+ }
+ return response.json() as { version?: string };
+ })
+ .catch(() => null)
+ ]);
+
+ const consoleVariables = normalizeConsoleVariables(rawConsoleVariables);
+
+ plansInfoStore.set(plansInfo ?? null);
+
+ return {
+ roles: [],
+ scopes: [],
+ preferences,
+ currentOrgId: undefined,
+ organizations,
+ consoleVariables,
+ allProjectsCount: 0,
+ plansInfo: plansInfo ?? null,
+ version: versionData?.version ?? null
+ };
+ }
depends(Dependencies.RUNTIMES);
depends(Dependencies.CONSOLE_VARIABLES);
depends(Dependencies.ORGANIZATION);
- const { endpoint, project } = sdk.forConsole.client.config;
+ const shouldRedirectToVerifyEmail = (error: unknown) =>
+ isVerifyEmailRedirectError(error) && url.pathname !== verifyEmailUrl;
const plansArrayPromise =
plansInfo || !isCloud
@@ -38,7 +88,13 @@ export const load: LayoutLoad = async ({ depends, parent }) => {
return response.json() as { version?: string };
}),
sdk.forConsole.console.variables()
- ]);
+ ]).catch((error) => {
+ if (shouldRedirectToVerifyEmail(error)) {
+ redirect(303, verifyEmailUrl);
+ }
+
+ throw error;
+ });
const consoleVariables = normalizeConsoleVariables(rawConsoleVariables);
@@ -65,6 +121,10 @@ export const load: LayoutLoad = async ({ depends, parent }) => {
})
).total;
} catch (e) {
+ if (shouldRedirectToVerifyEmail(e)) {
+ redirect(303, verifyEmailUrl);
+ }
+
projectsCount = 0;
}
}
diff --git a/src/routes/(console)/verify-email/+page.ts b/src/routes/(console)/verify-email/+page.ts
index d97e736667..c97cca8dc0 100644
--- a/src/routes/(console)/verify-email/+page.ts
+++ b/src/routes/(console)/verify-email/+page.ts
@@ -4,14 +4,10 @@ import type { PageLoad } from './$types';
import { Dependencies } from '$lib/constants';
import { sdk } from '$lib/stores/sdk';
import { addNotification } from '$lib/stores/notifications';
-import { VARS } from '$lib/system';
export const load: PageLoad = async ({ parent, depends, url }) => {
- if (!VARS.EMAIL_VERIFICATION) {
- redirect(303, resolve('/'));
- }
-
const { account } = await parent();
+
depends(Dependencies.ACCOUNT);
const user = url.searchParams.get('userId') ?? null;
diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts
index 5bdef81d7c..4ebbe0eeef 100644
--- a/src/routes/+layout.ts
+++ b/src/routes/+layout.ts
@@ -8,19 +8,24 @@ import type { LayoutLoad } from './$types';
import { redirectTo } from './store';
import { resolve } from '$app/paths';
import type { Account } from '$lib/stores/user';
-import { type AppwriteException, Platform } from '@appwrite.io/console';
-import { isCloud, VARS } from '$lib/system';
+import { AppwriteException, Platform, type Models } from '@appwrite.io/console';
+import { isCloud } from '$lib/system';
import { checkPricingRefAndRedirect } from '$lib/helpers/pricingRedirect';
import { getTeamOrOrganizationList } from '$lib/stores/organization';
import { makePlansMap } from '$lib/helpers/billing';
import { plansInfo as plansInfoStore } from '$lib/stores/billing';
+import { isVerifyEmailRedirectError } from '$lib/helpers/emailVerification';
export const ssr = false;
+const EMPTY_ORGANIZATIONS: Models.TeamList = { total: 0, teams: [] };
+
export const load: LayoutLoad = async ({ depends, url, route }) => {
depends(Dependencies.ACCOUNT);
depends(Dependencies.ORGANIZATIONS);
+ const verifyEmailPath = resolve('/verify-email');
+
const [account, error] = (await sdk.forConsole.account
.get()
.then((response) => [response, null])
@@ -32,23 +37,49 @@ export const load: LayoutLoad = async ({ depends, url, route }) => {
}
if (account) {
- if (isCloud && !account.emailVerification && VARS.EMAIL_VERIFICATION) {
- const isConsoleRoute = route.id?.startsWith('/(console)');
- const isVerifyEmailPage = url.pathname === resolve('/verify-email');
-
- if (isConsoleRoute && !isVerifyEmailPage) {
- redirect(303, resolve('/verify-email'));
- }
+ // `/v1/teams` (and org list on cloud) returns 401 until the console account is verified;
+ // do not call that API on this route while still unverified.
+ if (url.pathname === verifyEmailPath) {
+ const plansInfo = await getPlatformPlans().catch(() => null);
+ plansInfoStore.set(plansInfo);
+ return {
+ plansInfo,
+ account,
+ organizations: EMPTY_ORGANIZATIONS
+ };
}
- const plansInfo = await getPlatformPlans();
- plansInfoStore.set(plansInfo);
+ try {
+ const [plansInfo, organizations] = await Promise.all([
+ getPlatformPlans(),
+ getTeamOrOrganizationList()
+ ]);
- return {
- plansInfo,
- account: account,
- organizations: await getTeamOrOrganizationList()
- };
+ plansInfoStore.set(plansInfo);
+
+ return {
+ plansInfo,
+ account: account,
+ organizations
+ };
+ } catch (error) {
+ if (isVerifyEmailRedirectError(error)) {
+ if (url.pathname !== verifyEmailPath) {
+ redirect(303, withParams(verifyEmailPath, url.searchParams));
+ }
+
+ // Already on verify-email: do not rethrow; the teams API is blocked until verified.
+ const plansInfo = await getPlatformPlans().catch(() => null);
+ plansInfoStore.set(plansInfo);
+ return {
+ plansInfo,
+ account,
+ organizations: EMPTY_ORGANIZATIONS
+ };
+ }
+
+ throw error;
+ }
}
const isPublicRoute = route.id?.startsWith('/(public)');
@@ -66,6 +97,12 @@ export const load: LayoutLoad = async ({ depends, url, route }) => {
redirect(303, withParams(mfaUrl, url.searchParams));
}
+ if (isVerifyEmailRedirectError(error)) {
+ if (url.pathname !== verifyEmailPath) {
+ redirect(303, withParams(verifyEmailPath, url.searchParams));
+ }
+ }
+
if (!isPublicRoute) {
if (isCloud) {
checkPricingRefAndRedirect(url.searchParams, true);