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);