From de294bc715498d70cc343b38643e61e659f4c577 Mon Sep 17 00:00:00 2001
From: GeiserX <9169332+GeiserX@users.noreply.github.com>
Date: Wed, 25 Feb 2026 10:49:50 +0100
Subject: [PATCH 01/10] docs: add v2.0 self-hosting transformation roadmap
Document the complete vision for LynxPrompt v2.0: pivot from SaaS to
self-hostable platform with feature toggles, database consolidation,
Stripe platform commission model, and federated interconnect plan.
---
docs/ROADMAP.md | 253 +++++++++++++++++++++++++++++++++++-------------
1 file changed, 184 insertions(+), 69 deletions(-)
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 3ca1c5f..5b0d977 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -2,6 +2,167 @@
This document tracks planned features, improvements, and business decisions for LynxPrompt.
+---
+
+## π₯ v2.0 β Self-Hostable Platform Transformation (February 2026)
+
+### Vision
+
+LynxPrompt v2.0 pivots from a SaaS product to a **self-hostable platform** that companies can deploy on their own premises. The core product becomes open and deployable, with monetization shifting to marketplace commission and optional services.
+
+Companies deploy their own instance to manage AI IDE configurations (AGENTS.md, .cursor/rules/, etc.) internally. All features are available to all users β no tier gating.
+
+### Key Decisions (Confirmed)
+
+#### 1. Remove Pricing & Teams Subscription Tier
+- Delete the `/pricing` page entirely
+- Remove `SubscriptionPlan` enum and all tier-gating logic
+- All features (AI, SSO, wizard, API) available to everyone
+- Keep `Team` model for organizational grouping (useful for companies)
+- Remove `TeamBillingRecord`, Stripe subscription fields from Team
+- Remove all "Upgrade to Teams" CTAs from web UI, CLI, and docs
+
+#### 2. Stripe: Optional with Platform Commission
+- `ENABLE_STRIPE=false` by default β when disabled, all blueprints are free
+- When enabled, **the default Stripe account is LynxPrompt's (hardcoded)**
+- Any deployment that enables paid marketplace blueprints routes payments through LynxPrompt's Stripe account β LynxPrompt earns the 30% platform commission
+- Companies that want their own Stripe account must provide their own keys explicitly
+- This is the monetization model for the open-source platform: free to deploy, LynxPrompt earns from marketplace transactions across all federated instances
+
+#### 3. Remove GlitchTip (Error Tracking)
+- Remove all GlitchTip-specific code and hardcoded DSNs
+- Make Sentry integration optional β works if `SENTRY_DSN` is set, skip entirely if not
+- Delete GlitchTip infrastructure (containers, Caddy entry, DNS record, gitea repo)
+- Keep `@sentry/nextjs` as optional for companies that want their own Sentry/GlitchTip
+
+#### 4. Remove ClickHouse (Analytics)
+- Remove all ClickHouse code (analytics lib, API routes, env vars, docker-compose service)
+- ClickHouse was used for trending templates, search stats, wizard funnel
+- Keep ClickHouse as a wizard database option (it's a valid DB users might configure)
+
+#### 5. Feature Toggles via Environment Variables
+All features configurable via env vars for maximum deployment flexibility:
+
+**Auth:**
+- `ENABLE_GITHUB_OAUTH=false` β show/hide GitHub login
+- `ENABLE_GOOGLE_OAUTH=false` β show/hide Google login
+- `ENABLE_EMAIL_AUTH=true` β magic link / email login
+- `ENABLE_PASSKEYS=true` β WebAuthn passkeys
+- `ENABLE_TURNSTILE=false` β Cloudflare Turnstile CAPTCHA
+- `ENABLE_SSO=false` β SAML/OIDC/LDAP (promoted from Teams-only to first-class)
+- `ENABLE_USER_REGISTRATION=true` β set false for invite-only instances
+
+**AI:**
+- `ENABLE_AI=false` β master toggle for all AI features (editing, wizard assistant)
+- `ANTHROPIC_API_KEY` β required when AI enabled
+- `AI_MODEL=claude-3-5-haiku-latest` β configurable model
+
+**Content:**
+- `ENABLE_BLOG=false` β blog nav item and routes
+- `ENABLE_SUPPORT_FORUM=false` β support forum nav item and routes
+
+**Marketplace:**
+- `ENABLE_STRIPE=false` β paid blueprints and Stripe checkout
+
+**Analytics:**
+- `UMAMI_SCRIPT_URL` β configurable Umami script URL
+- `NEXT_PUBLIC_UMAMI_WEBSITE_ID` β Umami website ID (already exists)
+
+**Branding:**
+- `APP_NAME=LynxPrompt` β app title in header, emails, meta tags
+- `APP_URL=http://localhost:3000` β base URL
+- `APP_LOGO_URL` β custom logo URL
+
+#### 6. Database Consolidation
+- **Default**: single PostgreSQL database (all 4 Prisma schemas share one DB)
+- **Advanced**: users can split into separate databases via different `DATABASE_URL_*` vars
+- Remove Percona pg_tde from development docker-compose β use standard `postgres:18-alpine` everywhere
+- Keep Percona pg_tde only in production/dev-server docker-compose (gitea) where it's already deployed
+- New `docker-compose.selfhost.yml`: 1 Postgres + 1 LynxPrompt container
+
+#### 7. Dynamic CSP Headers
+- Build Content-Security-Policy in `proxy.ts` based on enabled services
+- Only include Umami, Turnstile, Sentry domains when those services are configured
+- Cleaner security headers for minimal deployments
+
+#### 8. Hardcoded URL Audit
+- Replace all `lynxprompt.com` hardcoded references with `APP_URL` env var
+- Affects: email templates, fallback URLs, image references, API fallbacks, structured data
+
+#### 9. Health Check Enhancement
+- `/api/health` checks actual DB connectivity, not just returns 200
+- Critical for container orchestration and monitoring
+
+#### 10. Auto-Migration on Startup
+- `entrypoint.sh` runs `prisma migrate deploy` for all schemas on container start
+- Idempotent β safe for every restart
+- Companies don't need to run migrations manually
+
+### Federated Interconnect (Planned v2.x)
+
+A decentralized blueprint sharing network across LynxPrompt instances.
+
+**Concept:**
+- Each instance opts in via `ENABLE_FEDERATION=true` (default `true`)
+- Instances register themselves in a central registry (GitHub Gist or lightweight discovery service)
+- Each instance publishes its public blueprints via a standardized API
+- When browsing the marketplace, users see blueprints from all federated instances
+- Each blueprint shows its origin domain (e.g., "from lynxprompt.com", "from acme-corp.internal")
+- Results are lazy-loaded to keep the marketplace responsive
+
+**Architecture:**
+- Central registry: a JSON file (Gist or static endpoint) listing participating instances
+ - Each instance writes its URL, name, and public API endpoint
+ - Registry is polled periodically by each instance
+- Federation API: `/api/v1/federation/blueprints` β returns public blueprints
+- Blueprint metadata includes `origin_domain`, `origin_instance_name`
+- Rate limiting and API key exchange for security
+- Instance verification (domain ownership check)
+
+**Stripe Integration with Federation:**
+- When a user purchases a paid blueprint from a remote federated instance, the payment routes through the origin instance's Stripe (which defaults to LynxPrompt's account)
+- This means LynxPrompt earns commission on all marketplace transactions across the entire federation
+
+**Testing Plan:**
+- Use prod (lynxprompt.com) and dev (dev.lynxprompt.com) as the first two federated instances
+- Dev instance should show prod blueprints with "from lynxprompt.com" label
+- Validate lazy loading, search across instances, and cross-instance purchases
+
+**Implementation Phases:**
+1. Define federation API schema and protocol
+2. Build the central registry mechanism
+3. Implement federation client (fetching remote blueprints)
+4. UI for federated blueprints (origin badges, lazy loading)
+5. Cross-instance purchasing via Stripe
+6. Admin controls (allowlist/blocklist federated instances)
+
+### Documentation Changes for v2.0
+
+- Delete: pricing page, pricing docs, billing FAQ
+- Rewrite: AI features docs (remove "Teams-only" language)
+- Rewrite: marketplace selling docs (Stripe optional)
+- Add: self-hosting guide with env var reference
+- Add: `docker-compose.selfhost.yml` quick start
+- Rewrite: README.md (self-hostable platform positioning)
+- Update: CLI docs (configurable server URL for self-hosted)
+
+### Infrastructure Changes for v2.0
+
+- Delete GlitchTip stack from watchtower (containers + gitea repo + Caddy entry + DNS)
+- Update prod docker-compose (gitea/watchtower/lynxprompt/) with all new feature flags enabled
+- Update dev docker-compose (gitea/geiserback/lynxprompt-dev/) with all new feature flags enabled
+- Bump image tag to `drumsergio/lynxprompt:2.0.0`
+- Remove Sentry DSN env vars from prod/dev docker-compose
+
+### Version
+
+- Web app: 2.0.0
+- CLI: 2.0.0
+- Single PR: `feat/v2.0-self-hosting` β `main`
+- Preservation branch: `sergio-before-internationalization` (captures pre-v2.0 state)
+
+---
+
## π’ Business & Legal Foundation
### Entity & Operator
@@ -61,7 +222,7 @@ Per EU Consumer Rights Directive, digital content can waive 14-day withdrawal IF
- [x] GDPR Article 6 legal basis (Contract + Legitimate Interest)
- [x] Physical address disclosure
- [x] "No DPO appointed" statement
-- [x] Third-party processors detailed (GitHub, Google, Stripe, Umami, Anthropic, GlitchTip)
+- [x] Third-party processors detailed (GitHub, Google, Stripe, Umami, Anthropic, ~~GlitchTip~~ removed in v2.0)
- [x] Umami: self-hosted in EU, cookieless, legitimate interest basis
- [x] International transfers + SCCs
- [x] No automated decision-making statement
@@ -126,6 +287,7 @@ Per EU Consumer Rights Directive, digital content can waive 14-day withdrawal IF
- [x] Project scaffolding with Next.js 15, React 19, TypeScript
- [x] PostgreSQL database with Prisma ORM (dual-database architecture)
+- [x] ClickHouse for analytics (self-hosted EU)
- [x] Umami analytics (self-hosted EU, cookieless)
- [x] Authentication with NextAuth.js (GitHub, Google, Magic Link, Passkeys)
- [x] Homepage with platform carousel
@@ -295,23 +457,11 @@ Based on GitHub's recommended agents, offer one-click presets:
| `@api-agent` | Builds API endpoints | `npm run dev`, `curl` tests | Modify routes, ask before schema changes |
| `@deploy-agent` | Handles dev deployments | `npm run build`, `docker build` | Only deploy to dev, require approval |
-#### Wizard Tiers (Feature Gating) β
IMPLEMENTED
-
-| Feature | Free | Pro | Max |
-| -------------------------------------- | ---- | --- | --- |
-| Basic wizard steps | β
| β
| β
|
-| Intermediate wizard steps | β | β
| β
|
-| Advanced wizard steps | β | β | β
|
-| All community blueprints (including paid) | β | β | β
|
+#### ~~Wizard Tiers (Feature Gating)~~ β REMOVED in v2.0
-**Wizard Step Tiers (Updated):**
-- **Basic** (Free): Project Info, Tech Stack, Platforms, Generate
-- **Intermediate** (Pro): + Repository, Release Strategy, Commands
-- **Advanced** (Max): + Persona, Code Style, Boundaries, Agent Presets
+> **v2.0 Change:** All wizard steps are available to all users. No tier gating.
-**Admin Privileges:**
-- ADMIN and SUPERADMIN roles automatically receive MAX tier (no payment required)
-- Displayed as "Admin" badge in billing section
+All wizard steps (Basic, Intermediate, Advanced) are accessible to everyone.
#### User Dashboard
@@ -436,7 +586,7 @@ When downloading, user sees:
#### Template Analytics
-- [ ] Track template downloads/usage
+- [ ] Track template downloads/usage ~~(ClickHouse)~~ (alternative TBD post-v2.0)
- [ ] Show trending templates
- [ ] Usage statistics for template authors
- [ ] Revenue reports for paid templates
@@ -445,58 +595,22 @@ When downloading, user sees:
## π° Monetization Strategy
-### Subscription Tiers
-
-| Tier | Monthly | Annual (10% off) | Features |
-| --------- | -------------- | ---------------- | -------------------------------------------------------------- |
-| **Free** | β¬0/month | β¬0/year | Basic templates, limited wizard features |
-| **Pro** | β¬5/month | β¬54/year | Intermediate repo wizards, priority support |
-| **Max** | β¬20/month | β¬216/year | Advanced wizards + ALL community prompts (including paid ones) |
-| **Teams** | β¬10/seat/month | β¬108/seat/year | Everything in Max + team features, SSO, centralized billing |
+### ~~Subscription Tiers~~ β REMOVED in v2.0
-#### Key Subscription Rules
+> **v2.0 Change:** All subscription tiers (Free/Pro/Max/Teams) have been removed. All features are available to all users. The monetization model is now marketplace commission (see v2.0 section above).
-- **Users (free)**: Full wizard access, all platforms, API, sell blueprints
-- **Teams**: Everything in Users + AI features, SSO, team-shared blueprints
+Previously:
-#### Billing Intervals
+| Tier | Status |
+|------|--------|
+| Free | Now the only tier β all features included |
+| Pro | Removed |
+| Max | Removed |
+| Teams | Removed (Team org model kept for grouping, billing removed) |
-- **Monthly**: Can be canceled anytime. Access continues until end of billing period.
-- **Annual**: 10% discount. Cannot be canceled mid-cycle (yearly commitment). Access continues until year ends.
+### ~~Teams Tier~~ β REMOVED in v2.0
-### Teams Tier Details β
IMPLEMENTED
-
-| Setting | Value |
-|---------|-------|
-| Price | β¬10/seat/month |
-| Minimum seats | 3 |
-| Maximum seats | Unlimited |
-| Color | Teal/Cyan gradient |
-| AI usage limit | β¬5/user/month |
-
-#### Teams Features
-
-- **Team-shared blueprints**: Share blueprints privately within your team
-- **Blueprint visibility**: Private, Team, or Public options
-- **SSO authentication**: SAML 2.0, OpenID Connect, LDAP/Active Directory
-- **Centralized billing**: One admin pays for all seats
-- **Active user billing**: Only pay for users who logged in during the billing period
-- **Roles**: ADMIN (full control) and MEMBER (team access)
-- **Multiple admins**: Teams can have multiple administrators
-- **Pro-rated billing**: Adding seats mid-cycle charges prorated amount
-- **Credits**: Unused seats generate credits for next cycle
-
-#### Teams Billing Logic
-
-```
-Monthly Bill = β¬10 Γ MAX(active_users, 3)
-
-Where:
-- active_users = users who logged in during the billing period
-- Minimum 3 seats always billed (even if only 2 active)
-- Mid-cycle additions: (β¬10 / 30 days) Γ days_remaining Γ new_seats
-- Credits: (billed_seats - active_seats) Γ β¬10 β next cycle
-```
+> Team management (members, invitations, SSO) is kept as an organizational feature but is no longer a paid tier. SSO is promoted to a first-class feature available to all instances via `ENABLE_SSO` env var.
### Template Marketplace Pricing
@@ -733,7 +847,7 @@ POST /api/generate - Generate config files from wizard data
- [ ] Redis for caching/sessions
- [ ] S3/R2 for file storage (template assets, user uploads)
-- [x] GlitchTip error tracking (self-hosted at glitchtip.lynxprompt.com)
+- [x] ~~GlitchTip error tracking~~ β **Removed in v2.0** (Sentry optional via env var)
- [x] Status page (Uptime Kuma) at status.lynxprompt.com
- [ ] CDN for static assets
- [ ] Database backups automation
@@ -741,8 +855,9 @@ POST /api/generate - Generate config files from wizard data
### Current Infrastructure
-- [x] PostgreSQL (4 databases: app, users, blog, support)
-- [x] Umami (self-hosted EU, cookieless analytics)
+- [x] PostgreSQL (4 databases: app, users, blog, support) β **v2.0: single DB default, multi-DB optional**
+- [x] ~~ClickHouse (self-hosted EU, analytics)~~ β **Removed in v2.0**
+- [x] Umami (self-hosted EU, cookieless analytics) β **v2.0: configurable via env var**
- [x] Docker deployment with GitOps (Portainer)
- [x] Cloudflare DDoS protection and WAF
- [x] TLS 1.3 encryption in transit
@@ -758,7 +873,7 @@ POST /api/generate - Generate config files from wizard data
- [ ] Annual third-party penetration test
- [ ] Bug bounty program (HackerOne or similar)
-> **Note:** GlitchTip is preferred over Sentry for self-hosted error tracking. It keeps all data in EU.
+> **Note (v2.0):** GlitchTip and ClickHouse have been removed. Error tracking is optional via generic Sentry DSN env var. Companies can point to their own Sentry/GlitchTip instance if desired.
---
@@ -1477,7 +1592,7 @@ This enables:
- Multi-language support (i18n) - only when user base justifies
- **Cryptocurrency payments (Bitcoin, Ethereum, USDC) via Coinbase Commerce**
- Custom integrations (Slack, Teams notifications)
-- White-label solutions for enterprise
+- ~~White-label solutions for enterprise~~ β **Partially addressed in v2.0** (custom branding via `APP_NAME`, `APP_LOGO_URL` env vars)
### Completed Ideas β
- ~~Annual subscription discount~~ β 10% discount (~1.2 months free)
From 748b7e42ea9b86e900692eee2a40ca2647229925 Mon Sep 17 00:00:00 2001
From: GeiserX <9169332+GeiserX@users.noreply.github.com>
Date: Wed, 25 Feb 2026 10:50:50 +0100
Subject: [PATCH 02/10] refactor: make Sentry optional, remove hardcoded
GlitchTip DSN
- Sentry only initializes when SENTRY_DSN / NEXT_PUBLIC_SENTRY_DSN is set
- Remove hardcoded GlitchTip fallback DSN from client provider
- Remove glitchtip.lynxprompt.com from CSP connect-src
- Keep @sentry/nextjs as optional dependency for self-hosted Sentry
---
sentry.edge.config.ts | 25 ++----
sentry.server.config.ts | 91 +++++++++-----------
src/components/providers/sentry-provider.tsx | 20 +----
src/proxy.ts | 2 +-
4 files changed, 50 insertions(+), 88 deletions(-)
diff --git a/sentry.edge.config.ts b/sentry.edge.config.ts
index 765b145..2359b33 100644
--- a/sentry.edge.config.ts
+++ b/sentry.edge.config.ts
@@ -1,19 +1,10 @@
-// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
-// https://docs.sentry.io/platforms/javascript/guides/nextjs/
-
import * as Sentry from "@sentry/nextjs";
-Sentry.init({
- // GlitchTip DSN - configured via environment variable
- dsn: process.env.SENTRY_DSN,
-
- // Only enable in production
- enabled: process.env.NODE_ENV === "production",
-
- // Performance Monitoring - adjust based on traffic
- tracesSampleRate: 0.1, // 10% of transactions
-
- // Setting this option to true will print useful information to the console while you're setting up Sentry.
- debug: false,
-});
-
+if (process.env.SENTRY_DSN && process.env.NODE_ENV === "production") {
+ Sentry.init({
+ dsn: process.env.SENTRY_DSN,
+ enabled: true,
+ tracesSampleRate: 0.1,
+ debug: false,
+ });
+}
diff --git a/sentry.server.config.ts b/sentry.server.config.ts
index 9ae6bb5..9f2e330 100644
--- a/sentry.server.config.ts
+++ b/sentry.server.config.ts
@@ -1,54 +1,41 @@
-// This file configures the initialization of Sentry on the server.
-// The config you add here will be used whenever the server handles a request.
-// https://docs.sentry.io/platforms/javascript/guides/nextjs/
-
import * as Sentry from "@sentry/nextjs";
-Sentry.init({
- // GlitchTip DSN - configured via environment variable
- dsn: process.env.SENTRY_DSN,
-
- // Only enable in production
- enabled: process.env.NODE_ENV === "production",
-
- // Performance Monitoring - adjust based on traffic
- tracesSampleRate: 0.1, // 10% of transactions
-
- // Setting this option to true will print useful information to the console while you're setting up Sentry.
- debug: false,
-
- // Filter out sensitive data from error reports
- beforeSend(event) {
- // Remove potentially sensitive headers
- if (event.request?.headers) {
- const sensitiveHeaders = [
- "authorization",
- "cookie",
- "x-api-key",
- "x-auth-token",
- ];
- sensitiveHeaders.forEach((header) => {
- if (event.request?.headers?.[header]) {
- event.request.headers[header] = "[Filtered]";
- }
- });
- }
-
- // Remove potentially sensitive data from breadcrumbs
- if (event.breadcrumbs) {
- event.breadcrumbs = event.breadcrumbs.map((breadcrumb) => {
- if (
- breadcrumb.data &&
- typeof breadcrumb.data === "object" &&
- "password" in breadcrumb.data
- ) {
- breadcrumb.data.password = "[Filtered]";
- }
- return breadcrumb;
- });
- }
-
- return event;
- },
-});
-
+if (process.env.SENTRY_DSN && process.env.NODE_ENV === "production") {
+ Sentry.init({
+ dsn: process.env.SENTRY_DSN,
+ enabled: true,
+ tracesSampleRate: 0.1,
+ debug: false,
+
+ beforeSend(event) {
+ if (event.request?.headers) {
+ const sensitiveHeaders = [
+ "authorization",
+ "cookie",
+ "x-api-key",
+ "x-auth-token",
+ ];
+ sensitiveHeaders.forEach((header) => {
+ if (event.request?.headers?.[header]) {
+ event.request.headers[header] = "[Filtered]";
+ }
+ });
+ }
+
+ if (event.breadcrumbs) {
+ event.breadcrumbs = event.breadcrumbs.map((breadcrumb) => {
+ if (
+ breadcrumb.data &&
+ typeof breadcrumb.data === "object" &&
+ "password" in breadcrumb.data
+ ) {
+ breadcrumb.data.password = "[Filtered]";
+ }
+ return breadcrumb;
+ });
+ }
+
+ return event;
+ },
+ });
+}
diff --git a/src/components/providers/sentry-provider.tsx b/src/components/providers/sentry-provider.tsx
index fac3fde..1b7e200 100644
--- a/src/components/providers/sentry-provider.tsx
+++ b/src/components/providers/sentry-provider.tsx
@@ -3,30 +3,21 @@
import { useEffect } from "react";
import * as Sentry from "@sentry/nextjs";
-// GlitchTip DSN - hardcoded for client-side (NEXT_PUBLIC_* vars are build-time only)
-const SENTRY_DSN =
- process.env.NEXT_PUBLIC_SENTRY_DSN ||
- "https://84ae8dcc6c4b4028ade54aea25172088@glitchtip.lynxprompt.com/1";
+const SENTRY_DSN = process.env.NEXT_PUBLIC_SENTRY_DSN;
let initialized = false;
export function SentryProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
- if (initialized) return;
+ if (initialized || !SENTRY_DSN) return;
initialized = true;
Sentry.init({
dsn: SENTRY_DSN,
-
- // Performance Monitoring - 10% of transactions
tracesSampleRate: 0.1,
-
- // Debug mode - set to true to troubleshoot
debug: false,
- // Filter out sensitive data
beforeSend(event) {
- // Remove potentially sensitive query parameters
if (event.request?.query_string) {
const sensitiveParams = [
"token",
@@ -40,7 +31,6 @@ export function SentryProvider({ children }: { children: React.ReactNode }) {
event.request.query_string = params.toString();
}
- // Remove cookies from error reports
if (event.request?.cookies) {
delete event.request.cookies;
}
@@ -48,13 +38,7 @@ export function SentryProvider({ children }: { children: React.ReactNode }) {
return event;
},
});
-
- // Expose Sentry globally for console testing
- if (typeof window !== "undefined") {
- (window as typeof window & { Sentry: typeof Sentry }).Sentry = Sentry;
- }
}, []);
return <>{children}>;
}
-
diff --git a/src/proxy.ts b/src/proxy.ts
index 84895e8..90bf043 100644
--- a/src/proxy.ts
+++ b/src/proxy.ts
@@ -89,7 +89,7 @@ function addSecurityHeaders(response: NextResponse): NextResponse {
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https://avatars.githubusercontent.com https://lh3.googleusercontent.com https://*.gravatar.com https://gravatar.com",
"font-src 'self' data:",
- "connect-src 'self' https://umami.lynxprompt.com https://challenges.cloudflare.com https://glitchtip.lynxprompt.com https://cloudflareinsights.com", // Umami + Turnstile + GlitchTip + CF Insights
+ "connect-src 'self' https://umami.lynxprompt.com https://challenges.cloudflare.com https://cloudflareinsights.com",
"frame-src 'self' https://challenges.cloudflare.com", // Turnstile iframe
"frame-ancestors 'none'",
"base-uri 'self'",
From 7677fa85ef2f4971ca2b1a656f188f015d8b99c1 Mon Sep 17 00:00:00 2001
From: GeiserX <9169332+GeiserX@users.noreply.github.com>
Date: Wed, 25 Feb 2026 11:03:30 +0100
Subject: [PATCH 03/10] feat: remove pricing page, Teams billing, and
subscription tier gating
- Delete /pricing page and remove from navigation
- Remove TeamBillingRecord model and Stripe subscription fields from schema
- Simplify subscription.ts: all features available to everyone
- Strip Stripe subscription code, keep marketplace blueprint purchases
- Remove paid blueprint creation restriction (was Teams-only)
- Remove AI access subscription check (will be env-var gated)
- Remove Teams billing API (keep team org management)
- Remove pricing/upgrade CTAs from blog, support, blueprints pages
- Remove JSON-LD offers and Team references from homepage
---
prisma/schema-users.prisma | 64 +-
src/app/api/ai/edit-blueprint/route.ts | 17 +-
src/app/api/billing/change-plan/route.ts | 87 ---
src/app/api/billing/checkout/route.ts | 158 +----
src/app/api/billing/portal/route.ts | 55 +-
src/app/api/billing/status/route.ts | 85 +--
src/app/api/billing/webhook/route.ts | 416 +------------
src/app/api/blueprints/route.ts | 10 -
src/app/api/teams/[teamId]/billing/route.ts | 438 +-------------
src/app/api/teams/route.ts | 92 +--
src/app/blog/[slug]/page.tsx | 3 -
src/app/blog/admin/[slug]/edit/page.tsx | 3 -
src/app/blog/admin/new/page.tsx | 3 -
src/app/blog/admin/page.tsx | 3 -
src/app/blueprints/[id]/edit/page.tsx | 2 +-
src/app/blueprints/create/page.tsx | 2 +-
src/app/docs/ai-features/page.tsx | 2 +-
src/app/docs/faq/billing/page.tsx | 6 +-
src/app/docs/marketplace/page.tsx | 4 +-
src/app/docs/marketplace/pricing/page.tsx | 4 +-
src/app/page.tsx | 9 -
src/app/pricing/layout.tsx | 112 ----
src/app/pricing/page.tsx | 640 --------------------
src/app/settings/page.tsx | 10 +-
src/app/sitemap.ts | 6 -
src/app/support/[id]/page.tsx | 3 -
src/app/teams/page.tsx | 3 -
src/components/page-header.tsx | 1 -
src/lib/stripe.ts | 76 +--
src/lib/subscription.ts | 133 +---
30 files changed, 100 insertions(+), 2347 deletions(-)
delete mode 100644 src/app/api/billing/change-plan/route.ts
delete mode 100644 src/app/pricing/layout.tsx
delete mode 100644 src/app/pricing/page.tsx
diff --git a/prisma/schema-users.prisma b/prisma/schema-users.prisma
index 814eed6..9c16b4f 100644
--- a/prisma/schema-users.prisma
+++ b/prisma/schema-users.prisma
@@ -92,16 +92,10 @@ model User {
socialMastodon String? // Mastodon handle (e.g., @user@instance.social)
socialDiscord String? // Discord username
- // Subscription & Billing (Stripe)
- stripeCustomerId String? @unique // Stripe Customer ID
- stripeSubscriptionId String? // Stripe Subscription ID
+ // Subscription (billing removed - all users get full access)
subscriptionPlan SubscriptionPlan @default(FREE)
- subscriptionStatus String? // active, canceled, past_due, unpaid, trialing
- subscriptionInterval String? // monthly or annual
- currentPeriodEnd DateTime? // When current billing period ends
- cancelAtPeriodEnd Boolean @default(false)
- // AI Usage Tracking (for MAX users)
+ // AI Usage Tracking
aiTokensUsedThisPeriod Int @default(0) // Total tokens used this billing period
aiUsageResetAt DateTime? // When to reset token count (aligned with billing period)
aiLastRequestAt DateTime? // For rate limiting
@@ -116,7 +110,7 @@ model User {
// Seller Payout Settings
paypalEmail String? // PayPal email for receiving payouts
- // Activity tracking (for Teams billing - track last login)
+ // Activity tracking
lastLoginAt DateTime? // Last time user logged in (updated on session creation)
// Relations
@@ -139,8 +133,6 @@ model User {
@@index([email])
@@index([role])
- @@index([stripeCustomerId])
- @@index([subscriptionPlan])
@@index([lastLoginAt])
}
@@ -151,10 +143,8 @@ enum UserRole {
}
enum SubscriptionPlan {
- FREE // Standard user - all features except AI
- TEAMS // Teams user - all features including AI, SSO, team sharing
- // DEPRECATED: PRO and MAX no longer used - all users get full wizard access
- // Keeping enum values would require database migration, so we just use FREE for all non-Teams users
+ FREE // All users - everyone gets full access
+ TEAMS // Kept for DB backwards compatibility (no longer used for gating)
}
// ============================================================================
@@ -191,15 +181,8 @@ model Team {
slug String @unique // URL-friendly identifier (e.g., "acme-corp")
logo String? // Team logo URL (uploaded image)
- // Billing - handled by team admin(s)
- stripeCustomerId String? @unique // Stripe Customer ID for the team
- stripeSubscriptionId String? // Stripe Subscription ID (per-seat)
- subscriptionInterval String? // monthly or annual
- billingCycleStart DateTime? // When current billing cycle started
- maxSeats Int @default(3) // Prepaid seats (minimum 3)
-
- // AI Usage Settings (Teams get higher limits)
- aiUsageLimitPerUser Int @default(500) // β¬5 = 500 cents max AI spend per user/month
+ // Organization limits
+ maxSeats Int @default(50) // Max members in the team
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -208,12 +191,10 @@ model Team {
members TeamMember[]
invitations TeamInvitation[]
ssoConfig TeamSSOConfig?
- billingRecords TeamBillingRecord[]
blueprints UserTemplate[] // Team-shared blueprints
purchases BlueprintPurchase[] // Templates purchased by team members (shared with all)
@@index([slug])
- @@index([stripeCustomerId])
}
model TeamMember {
@@ -289,37 +270,6 @@ model TeamSSOConfig {
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
}
-model TeamBillingRecord {
- id String @id @default(cuid())
- teamId String
-
- // Billing period
- periodStart DateTime
- periodEnd DateTime
-
- // Seat counts for this period
- totalSeats Int // Max seats configured
- activeSeats Int // Users who logged in during period
- billedSeats Int // MAX(activeSeats, 3) - what was actually charged
-
- // Financial
- amountBilled Int // Total amount in cents (billedSeats Γ β¬10)
- currency String @default("EUR")
- stripeInvoiceId String? // Reference to Stripe invoice
-
- // Credits (for unused seats from previous cycle)
- creditApplied Int @default(0) // Credit in cents applied to this invoice
- creditGenerated Int @default(0) // Credit generated for next cycle (if active < billed)
-
- createdAt DateTime @default(now())
-
- team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
-
- @@index([teamId])
- @@index([periodStart])
- @@index([periodEnd])
-}
-
// ============================================================================
// PASSKEYS (WebAuthn Authenticators)
// ============================================================================
diff --git a/src/app/api/ai/edit-blueprint/route.ts b/src/app/api/ai/edit-blueprint/route.ts
index 7a450a9..f5aa400 100644
--- a/src/app/api/ai/edit-blueprint/route.ts
+++ b/src/app/api/ai/edit-blueprint/route.ts
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
-import { authenticateRequest, isTeams } from "@/lib/api-auth";
+import { authenticateRequest } from "@/lib/api-auth";
import { prismaUsers } from "@/lib/db-users";
import Anthropic from "@anthropic-ai/sdk";
@@ -64,17 +64,13 @@ export async function POST(request: Request) {
);
}
- // Check if user is Teams subscriber (AI features are Teams-only)
const user = await prismaUsers.user.findUnique({
where: { id: auth.user.id },
select: {
- subscriptionPlan: true,
- role: true,
aiTokensUsedThisPeriod: true,
aiUsageResetAt: true,
aiLastRequestAt: true,
aiRequestsThisMinute: true,
- currentPeriodEnd: true,
},
});
@@ -82,13 +78,6 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
- if (!isTeams(auth.user)) {
- return NextResponse.json(
- { error: "AI editing is only available for Teams subscribers" },
- { status: 403 }
- );
- }
-
// Check rate limiting
const now = new Date();
const lastRequest = user.aiLastRequestAt;
@@ -116,7 +105,7 @@ export async function POST(request: Request) {
// Check usage reset (aligned with billing period)
let tokensUsed = user.aiTokensUsedThisPeriod;
- const resetAt = user.aiUsageResetAt || user.currentPeriodEnd;
+ const resetAt = user.aiUsageResetAt;
if (resetAt && now > resetAt) {
// Reset usage at billing period end
@@ -254,7 +243,7 @@ export async function POST(request: Request) {
aiTokensUsedThisPeriod: tokensUsed + costUnits,
aiLastRequestAt: now,
aiRequestsThisMinute: requestsThisMinute,
- aiUsageResetAt: user.currentPeriodEnd || new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), // Default: 30 days
+ aiUsageResetAt: user.aiUsageResetAt || new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000),
},
});
diff --git a/src/app/api/billing/change-plan/route.ts b/src/app/api/billing/change-plan/route.ts
deleted file mode 100644
index 3e3ab04..0000000
--- a/src/app/api/billing/change-plan/route.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import { NextResponse } from "next/server";
-import { getServerSession } from "next-auth";
-import { authOptions } from "@/lib/auth";
-import { ensureStripe, getPlanFromPriceId, getIntervalFromPriceId } from "@/lib/stripe";
-import { prismaUsers } from "@/lib/db-users";
-
-/**
- * Change subscription plan (DEPRECATED)
- *
- * This endpoint was used for upgrading/downgrading between Pro and Max plans.
- * Since January 2026, we only have Users (free) and Teams plans, so plan
- * changes are no longer supported via API. Users should contact support.
- */
-export async function POST() {
- // Plan changes are no longer supported as we only have Users (free) and Teams
- // This endpoint is kept for backwards compatibility but will always return an error
- return NextResponse.json(
- { error: "Plan changes are no longer supported. Please contact support to modify your subscription." },
- { status: 400 }
- );
-}
-
-/**
- * GET: Check current plan and any pending changes
- */
-export async function GET() {
- try {
- const session = await getServerSession(authOptions);
-
- if (!session?.user?.id) {
- return NextResponse.json(
- { error: "Authentication required" },
- { status: 401 }
- );
- }
-
- const user = await prismaUsers.user.findUnique({
- where: { id: session.user.id },
- select: {
- stripeSubscriptionId: true,
- subscriptionPlan: true,
- subscriptionStatus: true,
- currentPeriodEnd: true,
- cancelAtPeriodEnd: true,
- },
- });
-
- if (!user?.stripeSubscriptionId) {
- return NextResponse.json({
- currentPlan: user?.subscriptionPlan?.toLowerCase() || "free",
- status: user?.subscriptionStatus || null,
- pendingChange: null,
- });
- }
-
- const stripe = ensureStripe();
- const subscriptionResponse = await stripe.subscriptions.retrieve(user.stripeSubscriptionId);
- const subscription = subscriptionResponse as unknown as {
- status: string;
- current_period_end: number;
- items: { data: Array<{ price: { id: string } }> };
- metadata?: Record;
- cancel_at_period_end: boolean;
- };
-
- // Check for scheduled changes
- const scheduledDowngrade = subscription.metadata?.scheduledDowngrade;
- const currentPriceId = subscription.items.data[0]?.price?.id;
- const currentPlan = currentPriceId ? getPlanFromPriceId(currentPriceId) : "free";
- const interval = currentPriceId ? getIntervalFromPriceId(currentPriceId) : "monthly";
-
- return NextResponse.json({
- currentPlan,
- interval,
- status: subscription.status,
- currentPeriodEnd: new Date(subscription.current_period_end * 1000).toISOString(),
- cancelAtPeriodEnd: subscription.cancel_at_period_end,
- pendingChange: scheduledDowngrade || null,
- });
- } catch (error) {
- console.error("Error getting subscription info:", error);
- return NextResponse.json(
- { error: "Failed to get subscription info" },
- { status: 500 }
- );
- }
-}
diff --git a/src/app/api/billing/checkout/route.ts b/src/app/api/billing/checkout/route.ts
index 45dd206..5bc5f9c 100644
--- a/src/app/api/billing/checkout/route.ts
+++ b/src/app/api/billing/checkout/route.ts
@@ -1,148 +1,12 @@
-import { NextRequest, NextResponse } from "next/server";
-import { getServerSession } from "next-auth";
-import { authOptions } from "@/lib/auth";
-import { ensureStripe, getPriceIdForPlan, type SubscriptionPlan, type BillingInterval } from "@/lib/stripe";
-import { prismaUsers } from "@/lib/db-users";
-
-export async function POST(request: NextRequest) {
- try {
- const session = await getServerSession(authOptions);
-
- if (!session?.user?.id || !session.user.email) {
- return NextResponse.json(
- { error: "Authentication required" },
- { status: 401 }
- );
- }
-
- const { plan, interval, euDigitalContentConsent } = (await request.json()) as {
- plan: SubscriptionPlan;
- interval?: BillingInterval;
- euDigitalContentConsent?: boolean;
- };
-
- // Default to monthly if not specified
- const billingInterval: BillingInterval = interval === "annual" ? "annual" : "monthly";
-
- if (!plan || plan !== "teams") {
- return NextResponse.json(
- { error: "Invalid plan selected. Only Teams plan is available for subscription." },
- { status: 400 }
- );
- }
-
- // EU Digital Content Directive compliance
- // User must consent to immediate access and waive 14-day withdrawal right
- if (!euDigitalContentConsent) {
- return NextResponse.json(
- { error: "You must consent to immediate access and waive your withdrawal right to proceed." },
- { status: 400 }
- );
- }
-
- const priceId = getPriceIdForPlan(plan, billingInterval);
- if (!priceId) {
- return NextResponse.json(
- { error: `Stripe price not configured for ${plan} ${billingInterval} plan` },
- { status: 500 }
- );
- }
-
- const stripe = ensureStripe();
-
- // Get or create Stripe customer
- const user = await prismaUsers.user.findUnique({
- where: { id: session.user.id },
- select: {
- stripeCustomerId: true,
- stripeSubscriptionId: true,
- subscriptionStatus: true,
- email: true,
- name: true
- },
- });
-
- // If user already has an active subscription, they should use plan change API
- if (user?.stripeSubscriptionId &&
- (user.subscriptionStatus === "active" || user.subscriptionStatus === "trialing")) {
- return NextResponse.json(
- {
- error: "You already have an active subscription. Use the change plan option instead.",
- hasActiveSubscription: true,
- redirectTo: "/api/billing/change-plan"
- },
- { status: 400 }
- );
- }
-
- let customerId = user?.stripeCustomerId;
-
- if (!customerId) {
- // Create new Stripe customer
- const customer = await stripe.customers.create({
- email: session.user.email,
- name: user?.name || undefined,
- metadata: {
- userId: session.user.id,
- },
- });
- customerId = customer.id;
-
- // Save customer ID to database
- await prismaUsers.user.update({
- where: { id: session.user.id },
- data: { stripeCustomerId: customerId },
- });
- }
-
- // Create Stripe Checkout session
- const checkoutSession = await stripe.checkout.sessions.create({
- customer: customerId,
- mode: "subscription",
- payment_method_types: ["card"],
- line_items: [
- {
- price: priceId,
- quantity: 1,
- },
- ],
- success_url: `${process.env.NEXTAUTH_URL}/settings?tab=billing&success=true`,
- cancel_url: `${process.env.NEXTAUTH_URL}/settings?tab=billing&canceled=true`,
- metadata: {
- userId: session.user.id,
- plan: plan,
- interval: billingInterval,
- // EU Digital Content Directive consent tracking
- euDigitalContentConsent: "true",
- consentTimestamp: new Date().toISOString(),
- },
- subscription_data: {
- metadata: {
- userId: session.user.id,
- plan: plan,
- interval: billingInterval,
- euDigitalContentConsent: "true",
- consentTimestamp: new Date().toISOString(),
- },
- },
- // Allow promotion codes
- allow_promotion_codes: true,
- // Collect billing address for VAT
- billing_address_collection: "required",
- // Automatic tax calculation (if configured)
- automatic_tax: { enabled: false }, // Enable when Stripe Tax is configured
- });
-
- return NextResponse.json({ url: checkoutSession.url });
- } catch (error) {
- console.error("Error creating checkout session:", error);
- return NextResponse.json(
- { error: "Failed to create checkout session" },
- { status: 500 }
- );
- }
+import { NextResponse } from "next/server";
+
+/**
+ * Subscription checkout has been removed.
+ * This endpoint is kept for future marketplace checkout if needed.
+ */
+export async function POST() {
+ return NextResponse.json(
+ { error: "Subscription billing is no longer available. All features are free for all users." },
+ { status: 410 }
+ );
}
-
-
-
-
diff --git a/src/app/api/billing/portal/route.ts b/src/app/api/billing/portal/route.ts
index d9b023b..764752f 100644
--- a/src/app/api/billing/portal/route.ts
+++ b/src/app/api/billing/portal/route.ts
@@ -1,50 +1,13 @@
import { NextResponse } from "next/server";
-import { getServerSession } from "next-auth";
-import { authOptions } from "@/lib/auth";
-import { ensureStripe } from "@/lib/stripe";
-import { prismaUsers } from "@/lib/db-users";
+/**
+ * Stripe Customer Portal for marketplace customers.
+ * Subscription billing has been removed, but marketplace customers
+ * may still need to manage their payment methods.
+ */
export async function POST() {
- try {
- const session = await getServerSession(authOptions);
-
- if (!session?.user?.id) {
- return NextResponse.json(
- { error: "Authentication required" },
- { status: 401 }
- );
- }
-
- const user = await prismaUsers.user.findUnique({
- where: { id: session.user.id },
- select: { stripeCustomerId: true },
- });
-
- if (!user?.stripeCustomerId) {
- return NextResponse.json(
- { error: "No billing account found" },
- { status: 400 }
- );
- }
-
- const stripe = ensureStripe();
-
- // Create Stripe Customer Portal session
- const portalSession = await stripe.billingPortal.sessions.create({
- customer: user.stripeCustomerId,
- return_url: `${process.env.NEXTAUTH_URL}/settings?tab=billing`,
- });
-
- return NextResponse.json({ url: portalSession.url });
- } catch (error) {
- console.error("Error creating portal session:", error);
- return NextResponse.json(
- { error: "Failed to create portal session" },
- { status: 500 }
- );
- }
+ return NextResponse.json(
+ { error: "Billing portal is no longer available." },
+ { status: 410 }
+ );
}
-
-
-
-
diff --git a/src/app/api/billing/status/route.ts b/src/app/api/billing/status/route.ts
index 5d1e1d9..9c4ef7d 100644
--- a/src/app/api/billing/status/route.ts
+++ b/src/app/api/billing/status/route.ts
@@ -2,7 +2,6 @@ import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prismaUsers } from "@/lib/db-users";
-import { ensureStripe, getPlanFromPriceId, getIntervalFromPriceId } from "@/lib/stripe";
export async function GET() {
try {
@@ -19,13 +18,6 @@ export async function GET() {
where: { id: session.user.id },
select: {
role: true,
- subscriptionPlan: true,
- subscriptionStatus: true,
- subscriptionInterval: true,
- currentPeriodEnd: true,
- cancelAtPeriodEnd: true,
- stripeCustomerId: true,
- stripeSubscriptionId: true,
teamMemberships: {
include: {
team: {
@@ -34,8 +26,6 @@ export async function GET() {
name: true,
slug: true,
logo: true,
- stripeSubscriptionId: true,
- billingCycleStart: true,
},
},
},
@@ -50,69 +40,22 @@ export async function GET() {
);
}
- // Check if user is part of a team
const teamMembership = user.teamMemberships[0];
- const isTeamsUser = user.subscriptionPlan === "TEAMS" || !!teamMembership;
-
- // Admins and Superadmins get Teams tier features for free
const isAdmin = user.role === "ADMIN" || user.role === "SUPERADMIN";
-
- // Determine effective plan
- let effectivePlan: string;
- if (isAdmin) {
- effectivePlan = "teams";
- } else if (isTeamsUser) {
- effectivePlan = "teams";
- } else {
- effectivePlan = user.subscriptionPlan.toLowerCase();
- }
-
- // Check for pending changes if user has active subscription
- let pendingChange: string | null = null;
- let actualCurrentPlan = effectivePlan;
- let billingInterval: "monthly" | "annual" = (user.subscriptionInterval as "monthly" | "annual") || "monthly";
-
- // For non-Teams, non-Admin users with Stripe subscription
- if (!isAdmin && !isTeamsUser && user.stripeSubscriptionId) {
- try {
- const stripe = ensureStripe();
- const subscription = await stripe.subscriptions.retrieve(user.stripeSubscriptionId);
-
- // Get the currently active plan and interval from Stripe
- const currentPriceId = subscription.items.data[0]?.price?.id;
- if (currentPriceId) {
- actualCurrentPlan = getPlanFromPriceId(currentPriceId);
- billingInterval = getIntervalFromPriceId(currentPriceId);
- }
-
- // Check for scheduled downgrade in metadata
- if (subscription.metadata?.scheduledDowngrade) {
- pendingChange = subscription.metadata.scheduledDowngrade;
- }
- } catch (stripeError) {
- // If we can't reach Stripe, just use the DB values
- console.error("Error fetching subscription from Stripe:", stripeError);
- }
- }
-
- // Teams users are considered active if they're part of a team (billing is handled at team level)
- const hasActiveSubscription = isAdmin || isTeamsUser || (!!user.stripeSubscriptionId &&
- (user.subscriptionStatus === "active" || user.subscriptionStatus === "trialing"));
return NextResponse.json({
- plan: actualCurrentPlan,
- interval: billingInterval,
- status: isAdmin || isTeamsUser ? "active" : user.subscriptionStatus,
- currentPeriodEnd: isAdmin ? null : (isTeamsUser ? teamMembership?.team?.billingCycleStart : user.currentPeriodEnd),
- cancelAtPeriodEnd: isAdmin || isTeamsUser ? false : user.cancelAtPeriodEnd,
- hasStripeAccount: !!user.stripeCustomerId,
- hasActiveSubscription,
- isAdmin, // Flag for UI to show "Admin" badge instead of plan
- isTeamsUser, // Flag for UI to show "Teams" badge
- pendingChange, // For showing scheduled downgrades
- isAnnual: billingInterval === "annual", // Convenience flag for UI
- // Teams-specific data
- team: isTeamsUser && teamMembership ? {
+ plan: "free",
+ interval: "monthly",
+ status: "active",
+ currentPeriodEnd: null,
+ cancelAtPeriodEnd: false,
+ hasStripeAccount: false,
+ hasActiveSubscription: true,
+ isAdmin,
+ isTeamsUser: !!teamMembership,
+ pendingChange: null,
+ isAnnual: false,
+ team: teamMembership ? {
id: teamMembership.team.id,
name: teamMembership.team.name,
slug: teamMembership.team.slug,
@@ -128,7 +71,3 @@ export async function GET() {
);
}
}
-
-
-
-
diff --git a/src/app/api/billing/webhook/route.ts b/src/app/api/billing/webhook/route.ts
index 350524a..e237563 100644
--- a/src/app/api/billing/webhook/route.ts
+++ b/src/app/api/billing/webhook/route.ts
@@ -1,7 +1,7 @@
import { headers } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
-import { ensureStripe, getPlanFromPriceId, getIntervalFromPriceId } from "@/lib/stripe";
+import { ensureStripe } from "@/lib/stripe";
import { prismaUsers } from "@/lib/db-users";
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
@@ -43,51 +43,12 @@ export async function POST(request: NextRequest) {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
- // Handle blueprint purchases (one-time payments)
if (session.metadata?.type === "blueprint_purchase") {
await handleBlueprintPurchase(session);
- } else {
- // Handle subscription checkout
- await handleCheckoutCompleted(session);
}
break;
}
- case "customer.subscription.created":
- case "customer.subscription.updated": {
- const subscription = event.data.object as Stripe.Subscription;
- // Check if this is a Teams subscription (has teamId in metadata)
- if (subscription.metadata?.teamId) {
- await handleTeamsSubscriptionChange(subscription);
- } else {
- await handleSubscriptionChange(subscription);
- }
- break;
- }
-
- case "customer.subscription.deleted": {
- const subscription = event.data.object as Stripe.Subscription;
- // Check if this is a Teams subscription
- if (subscription.metadata?.teamId) {
- await handleTeamsSubscriptionDeleted(subscription);
- } else {
- await handleSubscriptionDeleted(subscription);
- }
- break;
- }
-
- case "invoice.payment_failed": {
- const invoice = event.data.object as Stripe.Invoice;
- await handlePaymentFailed(invoice);
- break;
- }
-
- case "invoice.payment_succeeded": {
- const invoice = event.data.object as Stripe.Invoice;
- await handlePaymentSucceeded(invoice);
- break;
- }
-
default:
console.log(`Unhandled event type: ${event.type}`);
}
@@ -102,272 +63,11 @@ export async function POST(request: NextRequest) {
}
}
-async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
- // Check if this is a Teams checkout
- if (session.metadata?.type === "teams") {
- await handleTeamsCheckoutCompleted(session);
- return;
- }
-
- const userId = session.metadata?.userId;
- if (!userId) {
- console.error("No userId in checkout session metadata");
- return;
- }
-
- // Subscription will be updated by subscription.created/updated webhook
- console.log(`Checkout completed for user ${userId}`);
-}
-
-async function handleTeamsCheckoutCompleted(session: Stripe.Checkout.Session) {
- const { teamName, teamSlug, creatorUserId } = session.metadata || {};
- const subscriptionId = session.subscription as string;
- const customerId = session.customer as string;
-
- if (!teamName || !teamSlug || !creatorUserId) {
- console.error("Missing team metadata in checkout session", session.metadata);
- return;
- }
-
- // Check if team already exists (in case of duplicate webhook)
- const existingTeam = await prismaUsers.team.findUnique({
- where: { slug: teamSlug },
- });
-
- if (existingTeam) {
- console.log(`Team ${teamSlug} already exists, skipping creation`);
- return;
- }
-
- // Get subscription details for billing info
- const stripe = ensureStripe();
- const subscription = await stripe.subscriptions.retrieve(subscriptionId);
-
- // Get the interval from the subscription
- const interval = subscription.items.data[0]?.plan?.interval === "year" ? "annual" : "monthly";
-
- // Create the team
- const team = await prismaUsers.team.create({
- data: {
- name: teamName,
- slug: teamSlug,
- stripeCustomerId: customerId,
- stripeSubscriptionId: subscriptionId,
- subscriptionInterval: interval,
- maxSeats: subscription.items.data[0]?.quantity || 3,
- billingCycleStart: new Date(),
- members: {
- create: {
- userId: creatorUserId,
- role: "ADMIN",
- isActiveThisCycle: true,
- lastActiveAt: new Date(),
- },
- },
- },
- });
-
- // Update the subscription metadata with the team ID
- await stripe.subscriptions.update(subscriptionId, {
- metadata: {
- teamId: team.id,
- teamSlug: team.slug,
- },
- });
-
- // Update the creator's subscription plan to TEAMS
- await prismaUsers.user.update({
- where: { id: creatorUserId },
- data: {
- subscriptionPlan: "TEAMS",
- },
- });
-
- console.log(`Team "${teamName}" created successfully with ID ${team.id}`);
-}
-
-async function handleSubscriptionChange(subscription: Stripe.Subscription) {
- const userId = subscription.metadata?.userId;
- const customerId = subscription.customer as string;
-
- // Find user by customer ID or metadata
- const user = await prismaUsers.user.findFirst({
- where: userId ? { id: userId } : { stripeCustomerId: customerId },
- });
-
- if (!user) {
- console.error(`User not found for subscription ${subscription.id}`);
- return;
- }
-
- // Get the plan and interval from the first subscription item
- const priceId = subscription.items.data[0]?.price?.id;
- const plan = priceId ? getPlanFromPriceId(priceId) : "free";
- const interval = priceId ? getIntervalFromPriceId(priceId) : "monthly";
-
- // Map subscription status
- // Note: PRO and MAX are legacy - new subscriptions only have FREE or TEAMS
- // Legacy PRO/MAX subscriptions are treated as FREE (full wizard access now)
- type SubscriptionPlan = "FREE" | "TEAMS";
- const planMap: Record = {
- free: "FREE",
- pro: "FREE", // Legacy - map to FREE (they get full wizard access anyway)
- max: "FREE", // Legacy - map to FREE (they get full wizard access anyway)
- teams: "TEAMS",
- };
-
- // Get current period end - cast to access the property
- const sub = subscription as unknown as { current_period_end?: number };
- const currentPeriodEnd = sub.current_period_end;
-
- // Determine effective plan based on subscription status
- // If subscription is not active/trialing, user should be on FREE
- const isActiveSubscription =
- subscription.status === "active" ||
- subscription.status === "trialing";
-
- const effectivePlan: SubscriptionPlan = isActiveSubscription
- ? (planMap[plan] || "FREE")
- : "FREE";
-
- await prismaUsers.user.update({
- where: { id: user.id },
- data: {
- stripeSubscriptionId: subscription.id,
- stripeCustomerId: customerId,
- subscriptionPlan: effectivePlan,
- subscriptionStatus: subscription.status,
- subscriptionInterval: interval,
- currentPeriodEnd: currentPeriodEnd
- ? new Date(currentPeriodEnd * 1000)
- : null,
- cancelAtPeriodEnd: subscription.cancel_at_period_end,
- },
- });
-
- console.log(`Updated subscription for user ${user.id}: ${effectivePlan} ${interval} (status: ${subscription.status})`);
-}
-
-async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
- const customerId = subscription.customer as string;
-
- const user = await prismaUsers.user.findFirst({
- where: { stripeCustomerId: customerId },
- });
-
- if (!user) {
- console.error(`User not found for customer ${customerId}`);
- return;
- }
-
- await prismaUsers.user.update({
- where: { id: user.id },
- data: {
- subscriptionPlan: "FREE",
- subscriptionStatus: "canceled",
- stripeSubscriptionId: null,
- cancelAtPeriodEnd: false,
- },
- });
-
- console.log(`Subscription deleted for user ${user.id}`);
-}
-
-async function handlePaymentFailed(invoice: Stripe.Invoice) {
- const customerId = invoice.customer as string;
-
- const user = await prismaUsers.user.findFirst({
- where: { stripeCustomerId: customerId },
- });
-
- if (user) {
- await prismaUsers.user.update({
- where: { id: user.id },
- data: { subscriptionStatus: "past_due" },
- });
- console.log(`Payment failed for user ${user.id}`);
- }
-}
-
-async function handlePaymentSucceeded(invoice: Stripe.Invoice) {
- const customerId = invoice.customer as string;
- const inv = invoice as unknown as { subscription?: string; billing_reason?: string };
- const subscriptionId = inv.subscription;
-
- const user = await prismaUsers.user.findFirst({
- where: { stripeCustomerId: customerId },
- });
-
- if (!user) return;
-
- // Update status if was past_due
- if (user.subscriptionStatus === "past_due") {
- await prismaUsers.user.update({
- where: { id: user.id },
- data: { subscriptionStatus: "active" },
- });
- console.log(`Payment succeeded for user ${user.id}`);
- }
-
- // Check for scheduled downgrade on renewal invoices
- if (subscriptionId && inv.billing_reason === "subscription_cycle") {
- try {
- const stripe = ensureStripe();
- const subscription = await stripe.subscriptions.retrieve(subscriptionId);
-
- const scheduledDowngrade = subscription.metadata?.scheduledDowngrade;
- const scheduledDowngradePrice = subscription.metadata?.scheduledDowngradePrice;
-
- if (scheduledDowngrade && scheduledDowngradePrice) {
- // Apply the downgrade now
- await stripe.subscriptions.update(subscriptionId, {
- items: [
- {
- id: subscription.items.data[0].id,
- price: scheduledDowngradePrice,
- },
- ],
- proration_behavior: "none", // Already at new billing cycle
- metadata: {
- ...subscription.metadata,
- scheduledDowngrade: null,
- scheduledDowngradePrice: null,
- plan: scheduledDowngrade,
- },
- });
-
- // Update database
- // Note: Legacy PRO/MAX mapped to FREE - only TEAMS is a paid tier now
- type SubscriptionPlan = "FREE" | "TEAMS";
- const planMap: Record = {
- pro: "FREE", // Legacy
- max: "FREE", // Legacy
- free: "FREE",
- teams: "TEAMS",
- };
-
- await prismaUsers.user.update({
- where: { id: user.id },
- data: {
- subscriptionPlan: planMap[scheduledDowngrade] || "FREE",
- },
- });
-
- console.log(`Applied scheduled downgrade to ${scheduledDowngrade} for user ${user.id}`);
- }
- } catch (error) {
- console.error("Error applying scheduled downgrade:", error);
- }
- }
-}
-
-// Platform owner email - payments go directly to the platform's Stripe account
const PLATFORM_OWNER_EMAIL = "dev@lynxprompt.com";
async function handleBlueprintPurchase(session: Stripe.Checkout.Session) {
const { templateId, userId, originalPrice, paidPrice, isMaxDiscount, currency, teamId } = session.metadata || {};
- // Backwards compatibility with old purchases that only have 'price'
const price = originalPrice || session.metadata?.price;
const actualPaid = paidPrice || price;
@@ -379,8 +79,6 @@ async function handleBlueprintPurchase(session: Stripe.Checkout.Session) {
const originalPriceInCents = parseInt(price, 10);
const paidPriceInCents = parseInt(actualPaid, 10);
- // Check if the template belongs to the platform owner
- // For platform owner blueprints, all revenue stays with platform (no payout needed)
const template = await prismaUsers.userTemplate.findUnique({
where: { id: templateId },
select: {
@@ -392,16 +90,11 @@ async function handleBlueprintPurchase(session: Stripe.Checkout.Session) {
const isPlatformOwnerTemplate = template?.user?.email === PLATFORM_OWNER_EMAIL;
- // Author gets 70% of ORIGINAL price, UNLESS it's the platform owner's template
- // Platform owner's revenue goes directly to Stripe (no payout needed)
const authorShare = isPlatformOwnerTemplate ? 0 : Math.floor(originalPriceInCents * 0.7);
- // Platform fee is what's left from what was paid
const platformFee = paidPriceInCents - authorShare;
- // Get current version info for the purchase record
const purchaseVersion = template?.publishedVersion || template?.currentVersion || 1;
- // Find the version record ID for linking
const versionRecord = await prismaUsers.userTemplateVersion.findUnique({
where: {
templateId_version: {
@@ -413,7 +106,6 @@ async function handleBlueprintPurchase(session: Stripe.Checkout.Session) {
});
try {
- // Create purchase record - if teamId is present, this is a team purchase
const purchaseData: {
userId: string;
templateId: string;
@@ -428,16 +120,15 @@ async function handleBlueprintPurchase(session: Stripe.Checkout.Session) {
} = {
userId,
templateId,
- amount: paidPriceInCents, // What was actually paid
+ amount: paidPriceInCents,
currency: currency || "EUR",
stripePaymentId: session.payment_intent as string,
- authorShare, // 70% of original price
- platformFee, // Remaining (20% if discounted, 30% if not)
+ authorShare,
+ platformFee,
versionId: versionRecord?.id || undefined,
versionNumber: purchaseVersion,
};
- // If purchased by a team member, add teamId (makes it available to entire team)
if (teamId) {
purchaseData.teamId = teamId;
}
@@ -446,7 +137,6 @@ async function handleBlueprintPurchase(session: Stripe.Checkout.Session) {
data: purchaseData,
});
- // Update template revenue with original price (for author earnings tracking)
await prismaUsers.userTemplate.update({
where: { id: templateId },
data: {
@@ -458,7 +148,6 @@ async function handleBlueprintPurchase(session: Stripe.Checkout.Session) {
const teamInfo = teamId ? ` (team: ${teamId})` : "";
console.log(`Blueprint purchase: ${templateId} by ${userId}${teamInfo} - paid: ${paidPriceInCents} cents, original: ${originalPriceInCents} cents (author: ${authorShare}, platform: ${platformFee}, max discount: ${isMaxDiscount === "true"})`);
} catch (error) {
- // Handle duplicate purchase (race condition)
if ((error as { code?: string }).code === "P2002") {
console.log(`Duplicate purchase attempt for ${templateId} by ${userId}`);
} else {
@@ -466,100 +155,3 @@ async function handleBlueprintPurchase(session: Stripe.Checkout.Session) {
}
}
}
-
-/**
- * Handle Teams subscription changes
- */
-async function handleTeamsSubscriptionChange(subscription: Stripe.Subscription) {
- const teamId = subscription.metadata?.teamId;
- const customerId = subscription.customer as string;
-
- // Find team by teamId or customer ID
- const team = await prismaUsers.team.findFirst({
- where: teamId ? { id: teamId } : { stripeCustomerId: customerId },
- });
-
- if (!team) {
- console.error(`Team not found for subscription ${subscription.id}`);
- return;
- }
-
- // Get seat count from subscription
- const seatCount = subscription.items.data[0]?.quantity || 3;
-
- // Get current period info
- const sub = subscription as unknown as { current_period_start?: number; current_period_end?: number };
- const billingCycleStart = sub.current_period_start
- ? new Date(sub.current_period_start * 1000)
- : null;
-
- await prismaUsers.team.update({
- where: { id: team.id },
- data: {
- stripeSubscriptionId: subscription.id,
- stripeCustomerId: customerId,
- maxSeats: seatCount,
- billingCycleStart,
- },
- });
-
- // If subscription became active, mark team members as TEAMS plan
- if (subscription.status === "active") {
- const members = await prismaUsers.teamMember.findMany({
- where: { teamId: team.id },
- select: { userId: true },
- });
-
- // Update all team members to TEAMS plan
- await prismaUsers.user.updateMany({
- where: { id: { in: members.map(m => m.userId) } },
- data: { subscriptionPlan: "TEAMS" },
- });
-
- console.log(`Team ${team.id} activated: ${members.length} members upgraded to TEAMS plan`);
- }
-
- console.log(`Updated Teams subscription for team ${team.id}: ${seatCount} seats (status: ${subscription.status})`);
-}
-
-/**
- * Handle Teams subscription deletion
- */
-async function handleTeamsSubscriptionDeleted(subscription: Stripe.Subscription) {
- const customerId = subscription.customer as string;
-
- const team = await prismaUsers.team.findFirst({
- where: { stripeCustomerId: customerId },
- });
-
- if (!team) {
- console.error(`Team not found for customer ${customerId}`);
- return;
- }
-
- // Get all team members
- const members = await prismaUsers.teamMember.findMany({
- where: { teamId: team.id },
- select: { userId: true },
- });
-
- // Downgrade all team members to FREE plan
- await prismaUsers.user.updateMany({
- where: { id: { in: members.map(m => m.userId) } },
- data: { subscriptionPlan: "FREE" },
- });
-
- await prismaUsers.team.update({
- where: { id: team.id },
- data: {
- stripeSubscriptionId: null,
- billingCycleStart: null,
- },
- });
-
- console.log(`Teams subscription canceled for team ${team.id}: ${members.length} members downgraded to FREE`);
-}
-
-
-
-
diff --git a/src/app/api/blueprints/route.ts b/src/app/api/blueprints/route.ts
index 4019388..dbe5b5f 100644
--- a/src/app/api/blueprints/route.ts
+++ b/src/app/api/blueprints/route.ts
@@ -390,16 +390,6 @@ export async function POST(request: NextRequest) {
// }
// }
- // Check if user can create paid blueprints (Teams only)
- if (price !== null && price !== undefined && price > 0) {
- if (!isTeamsUser) {
- return NextResponse.json(
- { error: "Only Teams subscribers can create paid blueprints. Upgrade to Teams to unlock this feature." },
- { status: 403 }
- );
- }
- }
-
// Validation
if (!name || typeof name !== "string" || name.trim().length < 3) {
return NextResponse.json(
diff --git a/src/app/api/teams/[teamId]/billing/route.ts b/src/app/api/teams/[teamId]/billing/route.ts
index 624d382..ee859c4 100644
--- a/src/app/api/teams/[teamId]/billing/route.ts
+++ b/src/app/api/teams/[teamId]/billing/route.ts
@@ -2,23 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prismaUsers } from "@/lib/db-users";
-import { ensureStripe, STRIPE_PRICE_IDS } from "@/lib/stripe";
-import { TEAMS_MIN_SEATS, TEAMS_PRICE_PER_SEAT, calculateProratedAmount } from "@/lib/subscription";
-import { z } from "zod";
-// Validation schemas
-const createSubscriptionSchema = z.object({
- seats: z.number().min(TEAMS_MIN_SEATS, `Minimum ${TEAMS_MIN_SEATS} seats required`),
- euDigitalContentConsent: z.boolean(),
-});
-
-const updateSeatsSchema = z.object({
- seats: z.number().min(TEAMS_MIN_SEATS, `Minimum ${TEAMS_MIN_SEATS} seats required`),
-});
-
-/**
- * Helper: Check if user is a team admin
- */
async function isTeamAdmin(userId: string, teamId: string): Promise {
const membership = await prismaUsers.teamMember.findUnique({
where: {
@@ -29,7 +13,8 @@ async function isTeamAdmin(userId: string, teamId: string): Promise {
}
/**
- * GET /api/teams/[teamId]/billing - Get billing info (admin only)
+ * GET /api/teams/[teamId]/billing - Get team info (admin only)
+ * Subscription billing has been removed. Returns basic team membership info.
*/
export async function GET(
request: NextRequest,
@@ -43,10 +28,9 @@ export async function GET(
const { teamId } = await params;
- // Check if user is a team admin
if (!(await isTeamAdmin(session.user.id, teamId))) {
return NextResponse.json(
- { error: "Only team admins can view billing" },
+ { error: "Only team admins can view team info" },
{ status: 403 }
);
}
@@ -56,14 +40,9 @@ export async function GET(
include: {
members: {
select: {
- isActiveThisCycle: true,
lastActiveAt: true,
},
},
- billingRecords: {
- orderBy: { periodStart: "desc" },
- take: 6, // Last 6 billing periods
- },
},
});
@@ -71,425 +50,20 @@ export async function GET(
return NextResponse.json({ error: "Team not found" }, { status: 404 });
}
- // Calculate active users
- const activeMembers = team.members.filter((m) => m.isActiveThisCycle).length;
const totalMembers = team.members.length;
- // Get Stripe subscription details if exists
- let stripeSubscription = null;
- if (team.stripeSubscriptionId) {
- try {
- const stripe = ensureStripe();
- const sub = await stripe.subscriptions.retrieve(team.stripeSubscriptionId);
- stripeSubscription = {
- status: sub.status,
- currentPeriodEnd: new Date((sub as unknown as { current_period_end: number }).current_period_end * 1000),
- cancelAtPeriodEnd: sub.cancel_at_period_end,
- quantity: sub.items.data[0]?.quantity || 0,
- };
- } catch (e) {
- console.error("Error fetching Stripe subscription:", e);
- }
- }
-
- // Calculate next bill estimate
- const billableSeats = Math.max(activeMembers, TEAMS_MIN_SEATS);
- const nextBillEstimate = billableSeats * TEAMS_PRICE_PER_SEAT;
-
return NextResponse.json({
billing: {
- stripeCustomerId: team.stripeCustomerId,
- stripeSubscriptionId: team.stripeSubscriptionId,
- subscription: stripeSubscription,
maxSeats: team.maxSeats,
totalMembers,
- activeMembers,
- billableSeats,
- nextBillEstimate,
- nextBillEstimateFormatted: `β¬${(nextBillEstimate / 100).toFixed(2)}`,
- billingCycleStart: team.billingCycleStart,
- aiUsageLimitPerUser: team.aiUsageLimitPerUser,
- },
- history: team.billingRecords.map((r) => ({
- id: r.id,
- periodStart: r.periodStart,
- periodEnd: r.periodEnd,
- totalSeats: r.totalSeats,
- activeSeats: r.activeSeats,
- billedSeats: r.billedSeats,
- amountBilled: r.amountBilled,
- amountFormatted: `β¬${(r.amountBilled / 100).toFixed(2)}`,
- creditApplied: r.creditApplied,
- creditGenerated: r.creditGenerated,
- })),
- });
- } catch (error) {
- console.error("Error fetching billing:", error);
- return NextResponse.json(
- { error: "Failed to fetch billing information" },
- { status: 500 }
- );
- }
-}
-
-/**
- * POST /api/teams/[teamId]/billing - Create Teams subscription (admin only)
- */
-export async function POST(
- request: NextRequest,
- { params }: { params: Promise<{ teamId: string }> }
-) {
- try {
- const session = await getServerSession(authOptions);
- if (!session?.user?.id || !session.user.email) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
-
- const { teamId } = await params;
-
- // Check if user is a team admin
- if (!(await isTeamAdmin(session.user.id, teamId))) {
- return NextResponse.json(
- { error: "Only team admins can manage billing" },
- { status: 403 }
- );
- }
-
- const body = await request.json();
- const validation = createSubscriptionSchema.safeParse(body);
-
- if (!validation.success) {
- return NextResponse.json(
- { error: "Invalid input", details: validation.error.flatten() },
- { status: 400 }
- );
- }
-
- const { seats, euDigitalContentConsent } = validation.data;
-
- if (!euDigitalContentConsent) {
- return NextResponse.json(
- { error: "You must consent to immediate access and waive your withdrawal right to proceed." },
- { status: 400 }
- );
- }
-
- const team = await prismaUsers.team.findUnique({
- where: { id: teamId },
- });
-
- if (!team) {
- return NextResponse.json({ error: "Team not found" }, { status: 404 });
- }
-
- if (team.stripeSubscriptionId) {
- return NextResponse.json(
- { error: "Team already has an active subscription. Use PATCH to update seats." },
- { status: 400 }
- );
- }
-
- const stripe = ensureStripe();
-
- // Get or create Stripe customer for the team
- let customerId = team.stripeCustomerId;
-
- if (!customerId) {
- const customer = await stripe.customers.create({
- email: session.user.email,
- name: team.name,
- metadata: {
- teamId: team.id,
- teamSlug: team.slug,
- adminUserId: session.user.id,
- },
- });
- customerId = customer.id;
- }
-
- // Create checkout session for Teams subscription
- const priceId = STRIPE_PRICE_IDS.teams_seat_monthly;
- if (!priceId) {
- return NextResponse.json(
- { error: "Teams pricing is not configured" },
- { status: 500 }
- );
- }
-
- const checkoutSession = await stripe.checkout.sessions.create({
- customer: customerId,
- mode: "subscription",
- payment_method_types: ["card"],
- line_items: [
- {
- price: priceId,
- quantity: seats, // Per-seat billing with quantity
- },
- ],
- success_url: `${process.env.NEXTAUTH_URL}/teams/${team.slug}/manage?billing=success`,
- cancel_url: `${process.env.NEXTAUTH_URL}/teams/${team.slug}/manage?billing=canceled`,
- metadata: {
- teamId: team.id,
- seats: seats.toString(),
- euDigitalContentConsent: "true",
- consentTimestamp: new Date().toISOString(),
- },
- subscription_data: {
- metadata: {
- teamId: team.id,
- seats: seats.toString(),
- },
- },
- allow_promotion_codes: true,
- billing_address_collection: "required",
- });
-
- // Update team with customer ID and max seats
- await prismaUsers.team.update({
- where: { id: teamId },
- data: {
- stripeCustomerId: customerId,
- maxSeats: seats,
},
+ history: [],
});
-
- return NextResponse.json({ url: checkoutSession.url });
} catch (error) {
- console.error("Error creating subscription:", error);
+ console.error("Error fetching team info:", error);
return NextResponse.json(
- { error: "Failed to create subscription" },
+ { error: "Failed to fetch team information" },
{ status: 500 }
);
}
}
-
-/**
- * PATCH /api/teams/[teamId]/billing - Update seat count (admin only)
- */
-export async function PATCH(
- request: NextRequest,
- { params }: { params: Promise<{ teamId: string }> }
-) {
- try {
- const session = await getServerSession(authOptions);
- if (!session?.user?.id) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
-
- const { teamId } = await params;
-
- // Check if user is a team admin
- if (!(await isTeamAdmin(session.user.id, teamId))) {
- return NextResponse.json(
- { error: "Only team admins can update billing" },
- { status: 403 }
- );
- }
-
- const body = await request.json();
- const validation = updateSeatsSchema.safeParse(body);
-
- if (!validation.success) {
- return NextResponse.json(
- { error: "Invalid input", details: validation.error.flatten() },
- { status: 400 }
- );
- }
-
- const { seats } = validation.data;
-
- const team = await prismaUsers.team.findUnique({
- where: { id: teamId },
- include: {
- _count: { select: { members: true } },
- },
- });
-
- if (!team) {
- return NextResponse.json({ error: "Team not found" }, { status: 404 });
- }
-
- if (!team.stripeSubscriptionId) {
- return NextResponse.json(
- { error: "No active subscription. Use POST to create one." },
- { status: 400 }
- );
- }
-
- // Can't reduce seats below current member count
- if (seats < team._count.members) {
- return NextResponse.json(
- { error: `Cannot reduce seats below current member count (${team._count.members}). Remove members first.` },
- { status: 400 }
- );
- }
-
- const stripe = ensureStripe();
-
- // Get current subscription
- const subscription = await stripe.subscriptions.retrieve(team.stripeSubscriptionId);
- const subscriptionItemId = subscription.items.data[0]?.id;
-
- if (!subscriptionItemId) {
- return NextResponse.json(
- { error: "Subscription item not found" },
- { status: 500 }
- );
- }
-
- const currentSeats = subscription.items.data[0]?.quantity || TEAMS_MIN_SEATS;
- const isIncreasing = seats > currentSeats;
- const seatDifference = seats - currentSeats;
-
- // Calculate if this is the same day as billing cycle start
- const now = new Date();
- const cycleStart = team.billingCycleStart ? new Date(team.billingCycleStart) : new Date();
-
- // Check if same day (comparing year, month, day)
- const isSameDayAsCycleStart =
- now.getFullYear() === cycleStart.getFullYear() &&
- now.getMonth() === cycleStart.getMonth() &&
- now.getDate() === cycleStart.getDate();
-
- // Determine proration behavior:
- // - Increasing seats on same day as cycle start: no proration (full price)
- // - Increasing seats mid-cycle: prorate
- // - Decreasing seats: no proration, takes effect next cycle
- let prorationBehavior: "create_prorations" | "none" | "always_invoice" = "none";
-
- if (isIncreasing) {
- if (isSameDayAsCycleStart) {
- // Same day = full price, use always_invoice to charge immediately
- prorationBehavior = "always_invoice";
- } else {
- // Mid-cycle = prorate
- prorationBehavior = "create_prorations";
- }
- }
- // Decreasing: prorationBehavior stays "none" (no refund, change takes effect at renewal)
-
- // Update subscription quantity
- await stripe.subscriptions.update(team.stripeSubscriptionId, {
- items: [
- {
- id: subscriptionItemId,
- quantity: seats,
- },
- ],
- proration_behavior: prorationBehavior,
- metadata: {
- ...subscription.metadata,
- seats: seats.toString(),
- },
- });
-
- // Update team max seats
- await prismaUsers.team.update({
- where: { id: teamId },
- data: { maxSeats: seats },
- });
-
- // Calculate amount for response
- let chargeAmount = 0;
- let chargeNote = "";
-
- if (isIncreasing) {
- if (isSameDayAsCycleStart) {
- // Full price for new seats
- chargeAmount = seatDifference * TEAMS_PRICE_PER_SEAT;
- chargeNote = "Full amount charged for new seats (same day as billing cycle start)";
- } else {
- // Calculate prorated amount
- const cycleEnd = new Date(cycleStart);
- cycleEnd.setMonth(cycleEnd.getMonth() + 1);
-
- const totalDays = Math.ceil((cycleEnd.getTime() - cycleStart.getTime()) / (1000 * 60 * 60 * 24));
- const daysRemaining = Math.ceil((cycleEnd.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
-
- chargeAmount = calculateProratedAmount(
- Math.max(0, daysRemaining),
- totalDays,
- seatDifference
- );
- chargeNote = `Prorated amount charged for ${daysRemaining} remaining days`;
- }
- } else {
- chargeNote = "Seat reduction will take effect at the next billing cycle. No refund for current period.";
- }
-
- return NextResponse.json({
- message: `Seat count updated from ${currentSeats} to ${seats}`,
- previousSeats: currentSeats,
- newSeats: seats,
- chargeAmount: isIncreasing ? chargeAmount : 0,
- chargeAmountFormatted: isIncreasing ? `β¬${(chargeAmount / 100).toFixed(2)}` : "β¬0.00",
- note: chargeNote,
- isSameDayAsCycleStart,
- });
- } catch (error) {
- console.error("Error updating seats:", error);
- return NextResponse.json(
- { error: "Failed to update seat count" },
- { status: 500 }
- );
- }
-}
-
-/**
- * DELETE /api/teams/[teamId]/billing - Cancel subscription (admin only)
- */
-export async function DELETE(
- request: NextRequest,
- { params }: { params: Promise<{ teamId: string }> }
-) {
- try {
- const session = await getServerSession(authOptions);
- if (!session?.user?.id) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
-
- const { teamId } = await params;
-
- // Check if user is a team admin
- if (!(await isTeamAdmin(session.user.id, teamId))) {
- return NextResponse.json(
- { error: "Only team admins can cancel subscription" },
- { status: 403 }
- );
- }
-
- const team = await prismaUsers.team.findUnique({
- where: { id: teamId },
- });
-
- if (!team) {
- return NextResponse.json({ error: "Team not found" }, { status: 404 });
- }
-
- if (!team.stripeSubscriptionId) {
- return NextResponse.json(
- { error: "No active subscription to cancel" },
- { status: 400 }
- );
- }
-
- const stripe = ensureStripe();
-
- // Cancel at period end (don't immediately revoke access)
- await stripe.subscriptions.update(team.stripeSubscriptionId, {
- cancel_at_period_end: true,
- });
-
- return NextResponse.json({
- message: "Subscription will be canceled at the end of the current billing period",
- note: "Team members will retain access until then",
- });
- } catch (error) {
- console.error("Error canceling subscription:", error);
- return NextResponse.json(
- { error: "Failed to cancel subscription" },
- { status: 500 }
- );
- }
-}
-
diff --git a/src/app/api/teams/route.ts b/src/app/api/teams/route.ts
index 8a5f4ae..cb268c7 100644
--- a/src/app/api/teams/route.ts
+++ b/src/app/api/teams/route.ts
@@ -2,19 +2,13 @@ import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prismaUsers } from "@/lib/db-users";
-import { ensureStripe, STRIPE_PRICE_IDS } from "@/lib/stripe";
import { z } from "zod";
-const MIN_SEATS = 3;
-
-// Validation schema for team creation
const createTeamSchema = z.object({
name: z.string().min(2).max(100),
slug: z.string().min(2).max(50).regex(/^[a-z0-9-]+$/, {
message: "Slug must be lowercase alphanumeric with hyphens only",
}),
- interval: z.enum(["monthly", "annual"]).optional().default("monthly"),
- seats: z.number().min(MIN_SEATS, `Minimum ${MIN_SEATS} seats required`).optional().default(MIN_SEATS),
});
/**
@@ -47,7 +41,6 @@ export async function GET() {
role: m.role,
memberCount: m.team._count.members,
joinedAt: m.joinedAt,
- billingCycleStart: m.team.billingCycleStart,
maxSeats: m.team.maxSeats,
}));
@@ -62,7 +55,7 @@ export async function GET() {
}
/**
- * POST /api/teams - Create a new team (redirects to Stripe checkout)
+ * POST /api/teams - Create a new team (no billing required)
*/
export async function POST(request: NextRequest) {
try {
@@ -81,7 +74,7 @@ export async function POST(request: NextRequest) {
);
}
- const { name, slug, interval, seats } = validation.data;
+ const { name, slug } = validation.data;
// Check if user is already in a team
const existingMembership = await prismaUsers.teamMember.findFirst({
@@ -111,76 +104,26 @@ export async function POST(request: NextRequest) {
);
}
- // Get or create Stripe customer
- const stripe = ensureStripe();
- let stripeCustomerId = await prismaUsers.user.findUnique({
- where: { id: session.user.id },
- select: { stripeCustomerId: true },
- }).then(u => u?.stripeCustomerId);
-
- if (!stripeCustomerId) {
- const customer = await stripe.customers.create({
- email: session.user.email,
- name: session.user.name || undefined,
- metadata: {
- userId: session.user.id,
- },
- });
- stripeCustomerId = customer.id;
-
- await prismaUsers.user.update({
- where: { id: session.user.id },
- data: { stripeCustomerId },
- });
- }
-
- // Determine price ID based on interval
- const priceId = interval === "annual"
- ? STRIPE_PRICE_IDS.teams_seat_annual
- : STRIPE_PRICE_IDS.teams_seat_monthly;
-
- if (!priceId) {
- return NextResponse.json(
- { error: "Teams pricing not configured. Please contact support." },
- { status: 500 }
- );
- }
-
- // Create Stripe checkout session for Teams subscription
- const checkoutSession = await stripe.checkout.sessions.create({
- customer: stripeCustomerId,
- mode: "subscription",
- payment_method_types: ["card"],
- line_items: [
- {
- price: priceId,
- quantity: seats,
- },
- ],
- subscription_data: {
- metadata: {
- teamName: name,
- teamSlug: slug,
- creatorUserId: session.user.id,
- type: "teams",
- seats: seats.toString(),
+ // Create team directly (no billing)
+ const team = await prismaUsers.team.create({
+ data: {
+ name,
+ slug,
+ members: {
+ create: {
+ userId: session.user.id,
+ role: "ADMIN",
+ },
},
},
- metadata: {
- teamName: name,
- teamSlug: slug,
- creatorUserId: session.user.id,
- type: "teams",
- seats: seats.toString(),
- },
- success_url: `${process.env.NEXTAUTH_URL}/teams/${slug}?success=true`,
- cancel_url: `${process.env.NEXTAUTH_URL}/teams?cancelled=true`,
- allow_promotion_codes: true,
});
return NextResponse.json({
- checkoutUrl: checkoutSession.url,
- sessionId: checkoutSession.id,
+ team: {
+ id: team.id,
+ name: team.name,
+ slug: team.slug,
+ },
});
} catch (error) {
console.error("Error creating team:", error);
@@ -190,4 +133,3 @@ export async function POST(request: NextRequest) {
);
}
}
-
diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx
index 80e2f2d..0a6c010 100644
--- a/src/app/blog/[slug]/page.tsx
+++ b/src/app/blog/[slug]/page.tsx
@@ -264,9 +264,6 @@ export default async function BlogPostPage({ params }: PageProps) {
-
- Pricing
-
Blueprints
diff --git a/src/app/blog/admin/[slug]/edit/page.tsx b/src/app/blog/admin/[slug]/edit/page.tsx
index d66badb..9d040ed 100644
--- a/src/app/blog/admin/[slug]/edit/page.tsx
+++ b/src/app/blog/admin/[slug]/edit/page.tsx
@@ -299,9 +299,6 @@ export default function EditBlogPostPage({ params }: PageProps) {
-
- Pricing
-
Blueprints
diff --git a/src/app/blog/admin/new/page.tsx b/src/app/blog/admin/new/page.tsx
index e2483a6..42b3a8c 100644
--- a/src/app/blog/admin/new/page.tsx
+++ b/src/app/blog/admin/new/page.tsx
@@ -178,9 +178,6 @@ export default function NewBlogPostPage() {
-
- Pricing
-
Blueprints
diff --git a/src/app/blog/admin/page.tsx b/src/app/blog/admin/page.tsx
index a1cd98c..e260967 100644
--- a/src/app/blog/admin/page.tsx
+++ b/src/app/blog/admin/page.tsx
@@ -167,9 +167,6 @@ export default function BlogAdminPage() {
-
- Pricing
-
Blueprints
diff --git a/src/app/blueprints/[id]/edit/page.tsx b/src/app/blueprints/[id]/edit/page.tsx
index 29d957f..cd2e1c0 100644
--- a/src/app/blueprints/[id]/edit/page.tsx
+++ b/src/app/blueprints/[id]/edit/page.tsx
@@ -814,7 +814,7 @@ export default function EditBlueprintPage() {
{!canCreatePaidBlueprints && (
- Upgrade to Teams to create paid blueprints and earn 70% of each sale.
+ Upgrade to Teams to create paid blueprints and earn 70% of each sale.
)}
diff --git a/src/app/blueprints/create/page.tsx b/src/app/blueprints/create/page.tsx
index 68b5fa1..0a2e461 100644
--- a/src/app/blueprints/create/page.tsx
+++ b/src/app/blueprints/create/page.tsx
@@ -1116,7 +1116,7 @@ export default function ShareBlueprintPage() {
{!canCreatePaidBlueprints && (
- Upgrade to Teams to create paid blueprints and earn 70% of each sale.
+ Upgrade to Teams to create paid blueprints and earn 70% of each sale.
)}
diff --git a/src/app/docs/ai-features/page.tsx b/src/app/docs/ai-features/page.tsx
index 2d07dd8..a210d19 100644
--- a/src/app/docs/ai-features/page.tsx
+++ b/src/app/docs/ai-features/page.tsx
@@ -23,7 +23,7 @@ export default function AIFeaturesOverviewPage() {
Teams Exclusive: AI features are available only to Teams
subscribers.{" "}
-
+
Upgrade to Teams β
diff --git a/src/app/docs/faq/billing/page.tsx b/src/app/docs/faq/billing/page.tsx
index a2f476a..4c71f56 100644
--- a/src/app/docs/faq/billing/page.tsx
+++ b/src/app/docs/faq/billing/page.tsx
@@ -79,11 +79,7 @@ export default function BillingFAQPage() {
Do you offer annual billing?
- Yes! You can choose between monthly and annual billing on our{" "}
-
- pricing page
-
- . Annual plans offer a 10% discount compared to monthly billing:
+ Yes! You can choose between monthly and annual billing. Annual plans offer a 10% discount compared to monthly billing:
Teams: β¬108/seat/year (β¬9/seat/month) vs β¬10/seat/month
diff --git a/src/app/docs/marketplace/page.tsx b/src/app/docs/marketplace/page.tsx
index 47e6e7e..e4d7d52 100644
--- a/src/app/docs/marketplace/page.tsx
+++ b/src/app/docs/marketplace/page.tsx
@@ -180,10 +180,10 @@ export default function MarketplaceOverviewPage() {
- View Pricing
+ Get Started
diff --git a/src/app/docs/marketplace/pricing/page.tsx b/src/app/docs/marketplace/pricing/page.tsx
index 24f4687..961389d 100644
--- a/src/app/docs/marketplace/pricing/page.tsx
+++ b/src/app/docs/marketplace/pricing/page.tsx
@@ -209,8 +209,8 @@ export default function PricingPage() {
-
- View Pricing Page
+
+ Get Started
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 484d2c5..d8634ef 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -49,19 +49,10 @@ const softwareJsonLd = {
"@type": "Organization",
name: "GeiserCloud",
},
- offers: {
- "@type": "AggregateOffer",
- priceCurrency: "EUR",
- lowPrice: "0",
- highPrice: "10",
- offerCount: "4",
- },
featureList: [
"IDE-agnostic AI configuration",
"Wizard-based setup",
"Blueprint marketplace",
- "Team collaboration",
- "SSO support",
],
screenshot: "https://lynxprompt.com/og-image.png",
};
diff --git a/src/app/pricing/layout.tsx b/src/app/pricing/layout.tsx
deleted file mode 100644
index c0c1a6e..0000000
--- a/src/app/pricing/layout.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-import type { Metadata } from "next";
-
-export const metadata: Metadata = {
- title: "Pricing",
- description:
- "Simple, transparent pricing for LynxPrompt. Free for individuals with full access. Teams plan for organizations with AI features, SSO, and shared blueprints.",
- keywords: [
- "LynxPrompt pricing",
- "AI IDE pricing",
- "Cursor rules pricing",
- "developer tools pricing",
- "subscription plans",
- ],
- openGraph: {
- title: "Pricing - LynxPrompt",
- description:
- "Simple, transparent pricing. Start free, upgrade as you grow. Pro, Max, and Teams plans available.",
- type: "website",
- },
- twitter: {
- card: "summary",
- title: "Pricing - LynxPrompt",
- description:
- "Simple, transparent pricing. Start free, upgrade as you grow.",
- },
- alternates: {
- canonical: "https://lynxprompt.com/pricing",
- },
-};
-
-// JSON-LD for pricing page (SoftwareApplication with offers)
-const pricingJsonLd = {
- "@context": "https://schema.org",
- "@type": "WebPage",
- name: "LynxPrompt Pricing",
- description: "Pricing plans for LynxPrompt AI IDE configuration generator",
- mainEntity: {
- "@type": "SoftwareApplication",
- name: "LynxPrompt",
- applicationCategory: "DeveloperApplication",
- operatingSystem: "Web",
- offers: [
- {
- "@type": "Offer",
- name: "Free",
- price: "0",
- priceCurrency: "EUR",
- description: "Full wizard, download configs for 30+ platforms, create and sell blueprints, API access",
- },
- {
- "@type": "Offer",
- name: "Teams",
- price: "10",
- priceCurrency: "EUR",
- priceSpecification: {
- "@type": "UnitPriceSpecification",
- price: "10",
- priceCurrency: "EUR",
- unitCode: "MON",
- referenceQuantity: {
- "@type": "QuantitativeValue",
- value: "1",
- unitCode: "MON",
- },
- },
- description: "Everything in Free, plus AI features, team blueprints, SSO, centralized billing",
- },
- {
- "@type": "Offer",
- name: "Teams",
- price: "10",
- priceCurrency: "EUR",
- priceSpecification: {
- "@type": "UnitPriceSpecification",
- price: "10",
- priceCurrency: "EUR",
- unitCode: "MON",
- referenceQuantity: {
- "@type": "QuantitativeValue",
- value: "1",
- unitCode: "MON",
- },
- },
- description: "Team blueprints, SSO, centralized billing, per-seat pricing",
- },
- ],
- },
-};
-
-export default function PricingLayout({
- children,
-}: {
- children: React.ReactNode;
-}) {
- return (
- <>
-
- {children}
- >
- );
-}
-
-
-
-
-
-
-
-
diff --git a/src/app/pricing/page.tsx b/src/app/pricing/page.tsx
deleted file mode 100644
index 0d1e700..0000000
--- a/src/app/pricing/page.tsx
+++ /dev/null
@@ -1,640 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { useSession } from "next-auth/react";
-import Link from "next/link";
-import { Button } from "@/components/ui/button";
-import { Sparkles, Check, X, Zap, ArrowRight, Users } from "lucide-react";
-import { PageHeader } from "@/components/page-header";
-import { Footer } from "@/components/footer";
-import { PLAN_PRICES } from "@/lib/stripe";
-
-// Note: Metadata is handled in layout.tsx for client components
-
-type BillingInterval = "monthly" | "annual";
-
-// Format price showing decimals only when needed (e.g., β¬4.50 but β¬5)
-const formatEuros = (cents: number): string => {
- const euros = cents / 100;
- // If there's a fractional part, show 2 decimal places; otherwise show integer
- return euros % 1 === 0 ? `β¬${euros}` : `β¬${euros.toFixed(2)}`;
-};
-
-// Prices in cents from stripe.ts
-const getPriceDisplay = (interval: BillingInterval) => {
- const prices = PLAN_PRICES.teams;
- if (interval === "annual") {
- // Show monthly equivalent for annual
- const monthlyEquivalent = prices.annual / 12;
- return formatEuros(monthlyEquivalent);
- }
- return formatEuros(prices.monthly);
-};
-
-const getOriginalMonthlyPrice = () => {
- return formatEuros(PLAN_PRICES.teams.monthly);
-};
-
-const getAnnualTotal = () => {
- return formatEuros(PLAN_PRICES.teams.annual);
-};
-
-const getTiers = (interval: BillingInterval) => [
- {
- name: "Free",
- price: "β¬0",
- period: "",
- originalPrice: null,
- annualTotal: null,
- description: "Full access to all wizard features. Perfect for individual developers.",
- icon: Zap,
- highlighted: true,
- iconStyle: "primary",
- badge: "Most Popular",
- features: [
- { text: "Full wizard", included: true },
- { text: "Download configs for all platforms", included: true },
- { text: "Browse & create blueprints", included: true },
- { text: "API access for automation", included: true },
- { text: "Save wizard drafts", included: true },
- { text: "Sell blueprints on marketplace", included: true },
- { text: "Unlimited config downloads", included: true },
- { text: "30+ supported AI platforms", included: true },
- { text: "Community support", included: true },
- ],
- cta: "Get Started",
- ctaLink: "/auth/signin",
- },
- {
- name: "Teams",
- price: getPriceDisplay(interval),
- period: "/seat/month",
- originalPrice: interval === "annual" ? getOriginalMonthlyPrice() : null,
- annualTotal: interval === "annual" ? `${getAnnualTotal()}/seat` : null,
- description:
- "For organizations that need AI assistance, centralized management, and SSO",
- icon: Users,
- highlighted: false,
- iconStyle: "teams",
- badge: "Enterprise",
- features: [
- { text: "Everything in Free", included: true },
- { text: "AI-powered blueprint editing", included: true },
- { text: "AI wizard assistant", included: true },
- { text: "Team-shared blueprints", included: true },
- { text: "SSO (SAML, OIDC, LDAP)", included: true },
- { text: "Centralized billing", included: true },
- { text: "Only pay for active users", included: true },
- { text: "Priority support", included: true },
- ],
- cta: "Start Teams Trial",
- ctaLink: "/teams",
- },
-];
-
-const COMPARISON_FEATURES = [
- { name: "Full wizard", free: true, teams: true },
- { name: "All platforms (30+ IDEs)", free: true, teams: true },
- { name: "API access", free: true, teams: true },
- { name: "Unlimited downloads", free: true, teams: true },
- { name: "Create private blueprints", free: true, teams: true },
- { name: "Browse & download blueprints", free: true, teams: true },
- { name: "Sell blueprints on marketplace", free: true, teams: true },
- { name: "Save wizard drafts", free: true, teams: true },
- { name: "Community support", free: true, teams: true },
- { name: "AI-powered editing", free: "-", teams: true },
- { name: "AI wizard assistant", free: "-", teams: true },
- { name: "Team-shared blueprints", free: "-", teams: true },
- { name: "SSO (SAML/OIDC/LDAP)", free: "-", teams: true },
- { name: "Centralized billing", free: "-", teams: true },
- { name: "Active user billing only", free: "-", teams: true },
- { name: "Priority support", free: "-", teams: true },
-];
-
-export default function PricingPage() {
- const { status } = useSession();
- const isAuthenticated = status === "authenticated";
- const [billingInterval, setBillingInterval] = useState("monthly");
-
- const TIERS = getTiers(billingInterval);
-
- // Dynamic CTA links based on auth status and billing interval
- const getCtaLink = (tierName: string) => {
- if (tierName === "Teams") {
- return `/teams?interval=${billingInterval}`; // Teams has its own signup flow
- }
- if (!isAuthenticated) {
- return "/auth/signin";
- }
- return "/dashboard";
- };
-
- return (
-
- {/* Header */}
-
-
- {/* Hero */}
-
-
-
-
-
- Simple, transparent pricing
-
-
- Full access for everyone
-
-
- All users get the complete wizard experience. Teams adds AI assistance and enterprise features.
-
-
- {/* Billing Interval Toggle (only affects Teams pricing) */}
-
-
- setBillingInterval("monthly")}
- className={`relative rounded-full px-5 py-2 text-sm font-medium transition-all ${
- billingInterval === "monthly"
- ? "bg-background text-foreground shadow-sm"
- : "text-muted-foreground hover:text-foreground"
- }`}
- >
- Monthly
-
- setBillingInterval("annual")}
- className={`relative rounded-full px-5 py-2 text-sm font-medium transition-all ${
- billingInterval === "annual"
- ? "bg-background text-foreground shadow-sm"
- : "text-muted-foreground hover:text-foreground"
- }`}
- >
- Annual
-
-
- {billingInterval === "annual" && (
-
-
-
-
-
- Save 10% on Teams
-
- )}
-
- {billingInterval === "annual" && (
-
- Teams billed annually. Annual subscriptions cannot be canceled mid-cycle.
-
- )}
-
-
-
-
- {/* Pricing Cards */}
-
-
-
- {TIERS.map((tier) => (
-
- {tier.badge && (
-
-
- {tier.badge}
-
-
- )}
-
-
-
-
-
- {tier.originalPrice && (
-
- {tier.originalPrice}
-
- )}
- {tier.price}
- {tier.period}
-
-
- {tier.annualTotal && (
-
- {tier.annualTotal} billed annually
-
- )}
-
-
- {tier.description}
-
-
-
-
-
- {tier.features.map((feature, idx) => (
-
- {feature.included ? (
-
- ) : (
-
- )}
-
- {feature.text}
-
-
- ))}
-
-
-
-
-
-
- {isAuthenticated && tier.name === "Free"
- ? "Go to Dashboard"
- : isAuthenticated && tier.name === "Teams"
- ? "Upgrade to Teams"
- : tier.cta}
-
-
-
-
-
- ))}
-
-
-
-
- {/* Feature Comparison Table */}
-
-
-
-
- Feature Comparison
-
-
-
-
-
-
-
-
-
-
-
- Feature
- Free
- Teams
-
-
-
- {COMPARISON_FEATURES.map((feature, idx) => (
-
- {feature.name}
-
- {typeof feature.free === "boolean" ? (
- feature.free ? (
-
- ) : (
-
- )
- ) : (
-
- {feature.free}
-
- )}
-
-
- {typeof feature.teams === "boolean" ? (
- feature.teams ? (
-
- ) : (
-
- )
- ) : (
-
- {feature.teams}
-
- )}
-
-
- ))}
-
-
-
-
-
-
-
- {/* FAQ Section */}
-
-
-
-
- Frequently Asked Questions
-
-
-
-
-
- Why is most of LynxPrompt free?
-
- β
-
-
-
- We believe everyone should have access to great AI IDE configurations. The wizard,
- all 30+ platform outputs, blueprint creation, API access, and selling on the marketplace
- are all free. Teams is for organizations that need AI assistance (which costs us money
- to provide) and enterprise features like SSO.
-
-
-
-
-
- What's the difference between Free and Teams?
-
- β
-
-
-
- Free gives you the full wizard, all platform outputs,
- API access, blueprint creation and selling, and draft saving. Teams adds
- AI-powered editing (we use Claude to help you write better configs), team-shared blueprints,
- SSO integration, centralized billing, and only charges for active users.
-
-
-
-
-
- Can I sell my own blueprints?
-
- β
-
-
-
- Yes! All users can create and sell blueprints on the marketplace. You keep
- 70% of every sale . Minimum price is β¬5, minimum payout is β¬5 via PayPal.
-
-
-
-
-
- How does Teams billing work?
-
- β
-
-
-
- Teams is billed at β¬10 per seat per month , with a minimum of 3 seats.
- You only pay for active users β team members who haven't logged in
- during the billing cycle aren't charged. If you add users mid-cycle, you pay a
- prorated amount for the remaining days. Unused seat credits roll over to the next cycle.
- Annual billing gets 10% off .
-
-
-
-
-
- What SSO providers does Teams support?
-
- β
-
-
-
- Teams supports SAML 2.0 (Okta, Azure AD, OneLogin),
- OpenID Connect (Google Workspace, Auth0), and
- LDAP/Active Directory . Team admins can configure SSO
- from the team settings dashboard. You can also restrict sign-ups to
- specific email domains.
-
-
-
-
-
- Can team members share blueprints privately?
-
- β
-
-
-
- Yes! Team members can set blueprints to three visibility levels:
- Private (only you), Team (all team members),
- or Public (everyone). Team blueprints are perfect for sharing
- internal coding standards, company-specific configurations, and proprietary workflows.
-
-
-
-
-
- What can I do with API access?
-
- β
-
-
-
- All users get API access to programmatically manage their blueprints.
- You can list, create, update, and delete blueprints via REST API, making it easy to
- sync your AI config files from CI/CD pipelines or scripts. Use the wizard's
- "Auto update via API" feature to auto-generate sync commands. See our{" "}
-
- API documentation
- {" "}
- for details.
-
-
-
-
-
- Should I use the CLI or the Web Wizard?
-
- β
-
-
-
- Both offer full feature parity β the same wizards, options, and
- output are available in both. Use the CLI (npx lynxprompt)
- if you prefer working in your terminal, want to automate config generation in scripts,
- or have direct access to your project files. Use the Web Wizard if
- you prefer a visual interface, want to preview your config in real-time, or are
- exploring LynxPrompt for the first time. AI editing is only available to Teams users
- in both CLI and Web.
-
-
-
-
-
- What payment methods do you accept?
-
- β
-
-
-
- We accept all major credit cards via Stripe. Cryptocurrency payments are planned.
-
-
-
-
-
-
-
- {/* CTA */}
-
- {/* Decorative blurs */}
-
-
- {/* Hexagon pattern - left side */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Hexagon pattern - right side */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Ready to configure your AI IDE?
-
-
- Join thousands of developers using LynxPrompt to get the most out of AI coding assistants.
-
-
-
-
- {isAuthenticated ? "Go to Dashboard" : "Get Started Free"}
-
-
-
- Browse Blueprints
-
-
-
-
-
-
-
- );
-}
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx
index 3e2dc6d..fbf890d 100644
--- a/src/app/settings/page.tsx
+++ b/src/app/settings/page.tsx
@@ -2822,12 +2822,12 @@ function ApiTokensSection({ setError, setSuccess }: ApiTokensSectionProps) {
API tokens allow you to programmatically manage your blueprints via the command line or CI/CD pipelines.
Upgrade to Pro, Max, or Teams to unlock API access.
-
-
+
+
- View Plans
-
-
+ Sign Up
+
+
diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts
index 857a8d1..cccdf1d 100644
--- a/src/app/sitemap.ts
+++ b/src/app/sitemap.ts
@@ -13,12 +13,6 @@ export default async function sitemap(): Promise {
changeFrequency: "weekly",
priority: 1,
},
- {
- url: `${baseUrl}/pricing`,
- lastModified: new Date(),
- changeFrequency: "monthly",
- priority: 0.9,
- },
{
url: `${baseUrl}/blueprints`,
lastModified: new Date(),
diff --git a/src/app/support/[id]/page.tsx b/src/app/support/[id]/page.tsx
index 61f2c77..74eef18 100644
--- a/src/app/support/[id]/page.tsx
+++ b/src/app/support/[id]/page.tsx
@@ -321,9 +321,6 @@ function PostDetailPageContent({
-
- Pricing
-
Blueprints
diff --git a/src/app/teams/page.tsx b/src/app/teams/page.tsx
index b0554fb..c1585cc 100644
--- a/src/app/teams/page.tsx
+++ b/src/app/teams/page.tsx
@@ -171,9 +171,6 @@ export default function TeamsPage() {
)}
-
- Compare All Plans
-
diff --git a/src/components/page-header.tsx b/src/components/page-header.tsx
index 347e4ed..21e877d 100644
--- a/src/components/page-header.tsx
+++ b/src/components/page-header.tsx
@@ -14,7 +14,6 @@ interface NavItem {
const NAV_ITEMS: NavItem[] = [
{ href: "/wizard", label: "Wizard" },
- { href: "/pricing", label: "Pricing" },
{ href: "/blueprints", label: "Blueprints" },
{ href: "/docs", label: "Docs" },
{ href: "/blog", label: "Blog" },
diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts
index 4067736..2328c84 100644
--- a/src/lib/stripe.ts
+++ b/src/lib/stripe.ts
@@ -1,7 +1,5 @@
import Stripe from "stripe";
-// Only initialize Stripe if the secret key is available
-// This allows the app to build even without Stripe credentials
const stripeSecretKey = process.env.STRIPE_SECRET_KEY;
export const stripe = stripeSecretKey
@@ -9,7 +7,7 @@ export const stripe = stripeSecretKey
apiVersion: "2026-01-28.clover",
typescript: true,
})
- : (null as unknown as Stripe); // Type assertion for build-time
+ : (null as unknown as Stripe);
export function ensureStripe(): Stripe {
if (!stripe) {
@@ -17,75 +15,3 @@ export function ensureStripe(): Stripe {
}
return stripe;
}
-
-// Price IDs - Now only Teams (Users/Free don't need Stripe)
-export const STRIPE_PRICE_IDS = {
- // Teams pricing (per seat)
- teams_seat_monthly: process.env.STRIPE_PRICE_TEAMS_SEAT_MONTHLY || "",
- teams_seat_annual: process.env.STRIPE_PRICE_TEAMS_SEAT_ANNUAL || "",
- // Legacy - kept for backwards compatibility during transition
- pro_monthly: process.env.STRIPE_PRICE_PRO_MONTHLY || "",
- max_monthly: process.env.STRIPE_PRICE_MAX_MONTHLY || "",
- pro_annual: process.env.STRIPE_PRICE_PRO_ANNUAL || "",
- max_annual: process.env.STRIPE_PRICE_MAX_ANNUAL || "",
-} as const;
-
-export type SubscriptionPlan = "free" | "teams";
-export type BillingInterval = "monthly" | "annual";
-
-// Pricing in cents - Now only Teams
-export const PLAN_PRICES = {
- teams: {
- monthly: 1000, // β¬10/seat
- annual: 10800, // β¬108/seat (β¬9/month - 10% off)
- },
-} as const;
-
-export interface SubscriptionInfo {
- plan: SubscriptionPlan;
- status: "active" | "canceled" | "past_due" | "unpaid" | "trialing" | null;
- currentPeriodEnd: Date | null;
- cancelAtPeriodEnd: boolean;
- stripeCustomerId: string | null;
- stripeSubscriptionId: string | null;
- billingInterval: BillingInterval;
-}
-
-export interface TeamsSubscriptionInfo extends SubscriptionInfo {
- plan: "teams";
- teamId: string;
- totalSeats: number;
- activeSeats: number;
- billingCycleStart: Date | null;
- billingInterval: BillingInterval;
-}
-
-// Map Stripe price ID to plan name
-export function getPlanFromPriceId(priceId: string): SubscriptionPlan {
- if (priceId === STRIPE_PRICE_IDS.teams_seat_monthly || priceId === STRIPE_PRICE_IDS.teams_seat_annual) return "teams";
- // Legacy mapping - treat old pro/max as free (they get full access now anyway)
- return "free";
-}
-
-// Get billing interval from price ID
-export function getIntervalFromPriceId(priceId: string): BillingInterval {
- if (priceId === STRIPE_PRICE_IDS.teams_seat_annual) {
- return "annual";
- }
- return "monthly";
-}
-
-// Get price ID for a plan and interval
-export function getPriceIdForPlan(plan: SubscriptionPlan, interval: BillingInterval = "monthly"): string | null {
- switch (plan) {
- case "teams":
- return interval === "annual" ? STRIPE_PRICE_IDS.teams_seat_annual : STRIPE_PRICE_IDS.teams_seat_monthly;
- default:
- return null; // Free users don't need Stripe
- }
-}
-
-// Teams-specific: Calculate seats to bill (minimum 3)
-export function calculateBillableSeats(activeSeats: number): number {
- return Math.max(activeSeats, 3);
-}
diff --git a/src/lib/subscription.ts b/src/lib/subscription.ts
index dbaaba8..4ee5759 100644
--- a/src/lib/subscription.ts
+++ b/src/lib/subscription.ts
@@ -1,14 +1,10 @@
/**
* Subscription tier utilities
- *
- * NEW PRICING MODEL (January 2026):
- * - Users (free): Full wizard access, all features EXCEPT AI
- * - Teams: All features including AI, SSO, team-shared blueprints
- *
- * All users can now access the full wizard (basic + intermediate + advanced).
- * AI features are the only restriction for non-Teams users.
- *
- * ADMIN and SUPERADMIN roles automatically get Teams-level features.
+ *
+ * SIMPLIFIED (billing removed):
+ * All users get full access to all features.
+ * AI access will be controlled by ENABLE_AI env var.
+ * Teams are organizational units only (no billing).
*/
export type SubscriptionTier = "free" | "teams";
@@ -16,36 +12,20 @@ export type WizardTier = "basic" | "intermediate" | "advanced";
export type UserRole = "USER" | "ADMIN" | "SUPERADMIN";
/**
- * Teams pricing: β¬10/seat/month (minimum 3 seats)
- * Only active users (logged in during billing period) are charged
- */
-export const TEAMS_PRICE_PER_SEAT = 1000; // β¬10.00 in cents
-export const TEAMS_MIN_SEATS = 3;
-export const TEAMS_AI_LIMIT_PER_USER = 500; // β¬5.00 max AI spend per user/month
-
-/**
- * Blueprint limits per tier
- * All users now get the same generous limits
+ * Blueprint limits (same for all users)
*/
export const BLUEPRINT_LIMITS = {
- MAX_LINES: 10000, // Maximum lines per blueprint (all tiers)
+ MAX_LINES: 10000,
MAX_COUNT: {
- free: 5000, // Users: 5,000 blueprints (was 50 for free)
- teams: 10000, // Teams users: 10,000 blueprints
+ free: 5000,
+ teams: 10000,
},
} as const;
-/**
- * Get maximum blueprint count for a tier
- */
export function getMaxBlueprintCount(tier: SubscriptionTier): number {
return BLUEPRINT_LIMITS.MAX_COUNT[tier] || BLUEPRINT_LIMITS.MAX_COUNT.free;
}
-/**
- * Check if content exceeds line limit
- * @returns Number of lines if valid, or negative number indicating excess
- */
export function checkBlueprintLineCount(content: string): { valid: boolean; lineCount: number; maxLines: number } {
const lineCount = content.split("\n").length;
return {
@@ -56,117 +36,38 @@ export function checkBlueprintLineCount(content: string): { valid: boolean; line
}
/**
- * Get effective subscription tier based on role and subscription
- * Admins get Teams tier automatically
+ * Get effective subscription tier - always returns "free" (billing removed)
*/
export function getEffectiveTier(
- role: UserRole,
- subscriptionPlan: SubscriptionTier | "pro" | "max" // Accept legacy values
+ _role: UserRole,
+ _subscriptionPlan: string
): SubscriptionTier {
- if (role === "ADMIN" || role === "SUPERADMIN") {
- return "teams";
- }
- // Map legacy plans to new model
- if (subscriptionPlan === "pro" || subscriptionPlan === "max") {
- return "free"; // Legacy pro/max users are now regular users with full access
- }
- return subscriptionPlan === "teams" ? "teams" : "free";
+ return "free";
}
-/**
- * Check if a tier has Teams-level features (AI, SSO, team sharing)
- */
-export function hasTeamsFeatures(tier: SubscriptionTier): boolean {
- return tier === "teams";
+export function hasTeamsFeatures(_tier: SubscriptionTier): boolean {
+ return true;
}
-/**
- * Check if user can access AI features (Teams only)
- */
-export function canAccessAI(tier: SubscriptionTier): boolean {
- return tier === "teams";
+export function canAccessAI(_tier: SubscriptionTier): boolean {
+ return true;
}
-/**
- * Check if user can access a specific wizard tier
- * NEW: All users can access ALL wizard tiers
- */
export function canAccessWizard(
_effectiveTier: SubscriptionTier,
_wizardTier: WizardTier
): boolean {
- // All users can access all wizard tiers now
return true;
}
-/**
- * Get the required tier for a wizard level
- * NEW: All wizard levels are available to all users
- */
export function getRequiredTier(_wizardTier: WizardTier): SubscriptionTier {
- // All wizard tiers are now free
return "free";
}
-/**
- * Get available wizard tiers for a subscription level
- * NEW: All users get all tiers
- */
export function getAvailableWizards(_effectiveTier: SubscriptionTier): WizardTier[] {
- // All users get all wizard tiers
return ["basic", "intermediate", "advanced"];
}
-/**
- * Check if user is an admin (ADMIN or SUPERADMIN)
- */
export function isAdminRole(role: UserRole): boolean {
return role === "ADMIN" || role === "SUPERADMIN";
}
-
-/**
- * Check if user is on a Teams plan
- */
-export function isTeamsPlan(tier: SubscriptionTier): boolean {
- return tier === "teams";
-}
-
-/**
- * Calculate prorated amount for adding seats mid-cycle
- * @param daysRemaining Days left in billing cycle
- * @param totalDays Total days in billing cycle (usually 30)
- * @param newSeats Number of new seats to add
- * @returns Amount in cents to charge
- */
-export function calculateProratedAmount(
- daysRemaining: number,
- totalDays: number,
- newSeats: number
-): number {
- const dailyRate = TEAMS_PRICE_PER_SEAT / totalDays;
- return Math.round(dailyRate * daysRemaining * newSeats);
-}
-
-/**
- * Calculate credit for inactive seats
- * @param billedSeats Seats that were billed
- * @param activeSeats Seats that were actually used
- * @returns Credit amount in cents (for next cycle)
- */
-export function calculateInactiveCredit(
- billedSeats: number,
- activeSeats: number
-): number {
- // Minimum 3 seats always billed
- const effectiveActive = Math.max(activeSeats, TEAMS_MIN_SEATS);
- if (billedSeats <= effectiveActive) return 0;
-
- const unusedSeats = billedSeats - effectiveActive;
- return unusedSeats * TEAMS_PRICE_PER_SEAT;
-}
-
-// Legacy compatibility - keep hasMaxFeatures for code that might still reference it
-/** @deprecated Use hasTeamsFeatures instead */
-export function hasMaxFeatures(tier: SubscriptionTier | "pro" | "max"): boolean {
- return tier === "teams";
-}
From fb9207e8ebf6bc294c004d4199bfe83679d3bea8 Mon Sep 17 00:00:00 2001
From: GeiserX <9169332+GeiserX@users.noreply.github.com>
Date: Wed, 25 Feb 2026 11:14:55 +0100
Subject: [PATCH 04/10] feat: add feature flags system and wire toggles across
app
- Create src/lib/feature-flags.ts with all ENABLE_* env vars
- Create FeatureFlagsProvider for client-side flag access
- Expose flags via /api/config/public endpoint
- Dynamic navigation: Blog hidden when ENABLE_BLOG=false
- Gate AI routes behind ENABLE_AI flag
- Gate Stripe/billing routes behind ENABLE_STRIPE flag
- Guard blog routes via layout (ENABLE_BLOG)
- Guard support forum routes via layout (ENABLE_SUPPORT_FORUM)
- Fix all TypeScript errors from schema billing field removal
- Remove billing section from settings, simplify team management
- Update CLI whoami/wizard to remove plan display
- Clean up auth session types (remove subscription fields)
---
cli/src/api.ts | 7 +-
cli/src/commands/login.ts | 25 +-
cli/src/commands/whoami.ts | 26 +-
cli/src/commands/wizard.ts | 7 +-
prisma/migrations/create-lynxprompt-team.ts | 3 -
src/app/api/ai/edit-blueprint/route.ts | 8 +
src/app/api/billing/checkout/route.ts | 8 +
src/app/api/billing/portal/route.ts | 8 +
src/app/api/billing/status/route.ts | 8 -
src/app/api/billing/webhook/route.ts | 8 +
src/app/api/config/public/route.ts | 11 +-
src/app/api/teams/[teamId]/route.ts | 24 +-
src/app/api/v1/user/route.ts | 11 +-
src/app/blog/layout.tsx | 3 +
src/app/docs/ai-features/page.tsx | 12 +-
src/app/layout.tsx | 5 +-
src/app/settings/page.tsx | 713 ++++--------------
src/app/support/layout.tsx | 11 +
src/app/teams/[slug]/page.tsx | 107 +--
src/components/page-header.tsx | 16 +-
.../providers/feature-flags-provider.tsx | 69 ++
src/lib/auth.ts | 16 -
src/lib/feature-flags.ts | 62 ++
src/types/next-auth.d.ts | 9 -
24 files changed, 370 insertions(+), 807 deletions(-)
create mode 100644 src/app/support/layout.tsx
create mode 100644 src/components/providers/feature-flags-provider.tsx
create mode 100644 src/lib/feature-flags.ts
diff --git a/cli/src/api.ts b/cli/src/api.ts
index 1a2f6ad..b70abd0 100644
--- a/cli/src/api.ts
+++ b/cli/src/api.ts
@@ -73,12 +73,7 @@ export interface UserResponse {
display_name: string | null;
persona: string | null;
skill_level: string | null;
- subscription: {
- plan: string;
- status: string | null;
- interval: string | null;
- current_period_end: string | null;
- };
+ plan: string;
stats: {
blueprints_count: number;
};
diff --git a/cli/src/commands/login.ts b/cli/src/commands/login.ts
index d03cdae..4474519 100644
--- a/cli/src/commands/login.ts
+++ b/cli/src/commands/login.ts
@@ -130,38 +130,25 @@ interface UserInfo {
}
function displayWelcome(user: UserInfo): void {
- const plan = user.plan?.toUpperCase() || "FREE";
const name = user.name || user.email.split("@")[0];
- // Plan colors and emojis - simplified to Users and Teams only
- const planConfig: Record string; emoji: string; badge: string }> = {
- FREE: { color: chalk.gray, emoji: "π", badge: "Users" },
- TEAMS: { color: chalk.cyan, emoji: "π₯", badge: "Teams" },
- };
-
- // Map legacy PRO/MAX to FREE (Users)
- const effectivePlan = plan === "PRO" || plan === "MAX" ? "FREE" : plan;
- const config = planConfig[effectivePlan] || planConfig.FREE;
- const W = 46; // inner width (46 to make square with 48 total)
+ const W = 46;
const b = chalk.bold;
const pad = (s: string, len: number) => s + " ".repeat(Math.max(0, len - s.length));
console.log();
console.log(b("β" + "β".repeat(W) + "β"));
console.log(b("β") + " ".repeat(W) + b("β"));
- console.log(b("β") + pad(` ${config.emoji} Welcome to LynxPrompt CLI!`, W) + b("β"));
+ console.log(b("β") + pad(" π± Welcome to LynxPrompt CLI!", W) + b("β"));
console.log(b("β") + " ".repeat(W) + b("β"));
console.log(b("β") + pad(` User: ${name}`, W) + b("β"));
- console.log(b("β") + pad(` Plan: ${config.badge}`, W) + b("β"));
console.log(b("β") + " ".repeat(W) + b("β"));
console.log(b("β" + "β".repeat(W) + "β"));
console.log();
- // Show capabilities based on plan
console.log(chalk.bold("π Your CLI Capabilities:"));
console.log();
- // All users get these
console.log(chalk.green(" β") + " " + chalk.white("lynxp wizard") + chalk.gray(" - Interactive config wizard"));
console.log(chalk.green(" β") + " " + chalk.white("lynxp list") + chalk.gray(" - List your blueprints"));
console.log(chalk.green(" β") + " " + chalk.white("lynxp pull ") + chalk.gray(" - Download blueprints"));
@@ -171,14 +158,6 @@ function displayWelcome(user: UserInfo): void {
console.log(chalk.green(" β") + " " + chalk.white("lynxp whoami") + chalk.gray(" - Show account info"));
console.log(chalk.green(" β") + " " + chalk.white("lynxp logout") + chalk.gray(" - Sign out of CLI"));
- // Plan-specific features - Teams users get extra features
- if (effectivePlan === "TEAMS") {
- console.log();
- console.log(chalk.cyan(" β‘") + " " + chalk.white("AI-powered editing") + chalk.gray(" - AI assistant for configs"));
- console.log(chalk.cyan(" π₯") + " " + chalk.white("Team blueprints") + chalk.gray(" - Share with your team"));
- console.log(chalk.cyan(" π₯") + " " + chalk.white("SSO integration") + chalk.gray(" - Enterprise authentication"));
- }
-
console.log();
console.log(chalk.gray("Token stored securely. Run ") + chalk.cyan("lynxp --help") + chalk.gray(" to see all commands."));
console.log();
diff --git a/cli/src/commands/whoami.ts b/cli/src/commands/whoami.ts
index 65712b3..062ff5d 100644
--- a/cli/src/commands/whoami.ts
+++ b/cli/src/commands/whoami.ts
@@ -20,7 +20,7 @@ export async function whoamiCommand(): Promise {
id: user.id,
email: user.email,
name: user.name,
- plan: user.subscription.plan,
+ plan: user.plan,
});
console.log();
@@ -33,14 +33,6 @@ export async function whoamiCommand(): Promise {
if (user.display_name) {
console.log(` ${chalk.gray("Display:")} ${user.display_name}`);
}
- console.log(` ${chalk.gray("Plan:")} ${formatPlan(user.subscription.plan)}`);
- if (user.subscription.status) {
- console.log(` ${chalk.gray("Status:")} ${user.subscription.status}`);
- }
- if (user.subscription.current_period_end) {
- const endDate = new Date(user.subscription.current_period_end);
- console.log(` ${chalk.gray("Renews:")} ${endDate.toLocaleDateString()}`);
- }
console.log();
console.log(` ${chalk.gray("Blueprints:")} ${user.stats.blueprints_count}`);
console.log(` ${chalk.gray("Member since:")} ${new Date(user.created_at).toLocaleDateString()}`);
@@ -52,8 +44,7 @@ export async function whoamiCommand(): Promise {
if (error.statusCode === 401) {
console.error(chalk.red("Your session has expired. Please run 'lynxprompt login' again."));
} else if (error.statusCode === 403) {
- console.error(chalk.red("API access error. Please check your subscription."));
- console.error(chalk.gray("Visit https://lynxprompt.com/pricing for plan details."));
+ console.error(chalk.red("API access error."));
} else {
console.error(chalk.red(`Error: ${error.message}`));
}
@@ -64,19 +55,6 @@ export async function whoamiCommand(): Promise {
}
}
-function formatPlan(plan: string): string {
- const planColors: Record string> = {
- FREE: chalk.gray,
- TEAMS: chalk.cyan,
- };
-
- // Map legacy PRO/MAX to FREE display
- const displayPlan = plan === "PRO" || plan === "MAX" ? "FREE" : plan;
- const displayName = displayPlan === "FREE" ? "Users" : displayPlan;
- const colorFn = planColors[displayPlan] || chalk.white;
- return colorFn(displayName);
-}
-
diff --git a/cli/src/commands/wizard.ts b/cli/src/commands/wizard.ts
index 7ea0474..7844657 100644
--- a/cli/src/commands/wizard.ts
+++ b/cli/src/commands/wizard.ts
@@ -1142,18 +1142,13 @@ async function runWizardWithDraftProtection(options: WizardOptions): Promise {
console.log(` - ${m.user.email} (${m.role})`);
diff --git a/src/app/api/ai/edit-blueprint/route.ts b/src/app/api/ai/edit-blueprint/route.ts
index f5aa400..724ae95 100644
--- a/src/app/api/ai/edit-blueprint/route.ts
+++ b/src/app/api/ai/edit-blueprint/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { authenticateRequest } from "@/lib/api-auth";
import { prismaUsers } from "@/lib/db-users";
+import { ENABLE_AI } from "@/lib/feature-flags";
import Anthropic from "@anthropic-ai/sdk";
// Cost tracking constants (in tokens)
@@ -53,6 +54,13 @@ STRICT RULES:
Output ONLY the formatted content that can be added to an AI configuration file.`;
export async function POST(request: Request) {
+ if (!ENABLE_AI) {
+ return NextResponse.json(
+ { error: "AI features are not enabled on this instance" },
+ { status: 404 }
+ );
+ }
+
try {
// Authenticate via session OR Bearer token
const auth = await authenticateRequest(request);
diff --git a/src/app/api/billing/checkout/route.ts b/src/app/api/billing/checkout/route.ts
index 5bc5f9c..03a9685 100644
--- a/src/app/api/billing/checkout/route.ts
+++ b/src/app/api/billing/checkout/route.ts
@@ -1,10 +1,18 @@
import { NextResponse } from "next/server";
+import { ENABLE_STRIPE } from "@/lib/feature-flags";
/**
* Subscription checkout has been removed.
* This endpoint is kept for future marketplace checkout if needed.
*/
export async function POST() {
+ if (!ENABLE_STRIPE) {
+ return NextResponse.json(
+ { error: "Payments are not enabled on this instance" },
+ { status: 404 }
+ );
+ }
+
return NextResponse.json(
{ error: "Subscription billing is no longer available. All features are free for all users." },
{ status: 410 }
diff --git a/src/app/api/billing/portal/route.ts b/src/app/api/billing/portal/route.ts
index 764752f..5de0070 100644
--- a/src/app/api/billing/portal/route.ts
+++ b/src/app/api/billing/portal/route.ts
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
+import { ENABLE_STRIPE } from "@/lib/feature-flags";
/**
* Stripe Customer Portal for marketplace customers.
@@ -6,6 +7,13 @@ import { NextResponse } from "next/server";
* may still need to manage their payment methods.
*/
export async function POST() {
+ if (!ENABLE_STRIPE) {
+ return NextResponse.json(
+ { error: "Payments are not enabled on this instance" },
+ { status: 404 }
+ );
+ }
+
return NextResponse.json(
{ error: "Billing portal is no longer available." },
{ status: 410 }
diff --git a/src/app/api/billing/status/route.ts b/src/app/api/billing/status/route.ts
index 9c4ef7d..0d2420e 100644
--- a/src/app/api/billing/status/route.ts
+++ b/src/app/api/billing/status/route.ts
@@ -45,16 +45,8 @@ export async function GET() {
return NextResponse.json({
plan: "free",
- interval: "monthly",
- status: "active",
- currentPeriodEnd: null,
- cancelAtPeriodEnd: false,
- hasStripeAccount: false,
- hasActiveSubscription: true,
isAdmin,
isTeamsUser: !!teamMembership,
- pendingChange: null,
- isAnnual: false,
team: teamMembership ? {
id: teamMembership.team.id,
name: teamMembership.team.name,
diff --git a/src/app/api/billing/webhook/route.ts b/src/app/api/billing/webhook/route.ts
index e237563..96f371b 100644
--- a/src/app/api/billing/webhook/route.ts
+++ b/src/app/api/billing/webhook/route.ts
@@ -3,10 +3,18 @@ import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { ensureStripe } from "@/lib/stripe";
import { prismaUsers } from "@/lib/db-users";
+import { ENABLE_STRIPE } from "@/lib/feature-flags";
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
export async function POST(request: NextRequest) {
+ if (!ENABLE_STRIPE) {
+ return NextResponse.json(
+ { error: "Payments are not enabled on this instance" },
+ { status: 404 }
+ );
+ }
+
if (!webhookSecret) {
console.error("STRIPE_WEBHOOK_SECRET is not set");
return NextResponse.json(
diff --git a/src/app/api/config/public/route.ts b/src/app/api/config/public/route.ts
index 7cf9bf3..e6c99e9 100644
--- a/src/app/api/config/public/route.ts
+++ b/src/app/api/config/public/route.ts
@@ -1,17 +1,10 @@
import { NextResponse } from "next/server";
+import { getPublicFlags } from "@/lib/feature-flags";
-/**
- * Public config endpoint - returns NEXT_PUBLIC_* values at runtime
- * This allows these values to be set as regular env vars instead of build args
- */
export async function GET() {
return NextResponse.json({
turnstileSiteKey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || null,
umamiWebsiteId: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || null,
+ ...getPublicFlags(),
});
}
-
-
-
-
-
diff --git a/src/app/api/teams/[teamId]/route.ts b/src/app/api/teams/[teamId]/route.ts
index 9348b78..f4d1c03 100644
--- a/src/app/api/teams/[teamId]/route.ts
+++ b/src/app/api/teams/[teamId]/route.ts
@@ -104,11 +104,6 @@ export async function GET(
slug: team.slug,
logo: team.logo,
maxSeats: team.maxSeats,
- subscriptionInterval: team.subscriptionInterval, // "monthly" or "annual"
- billingCycleStart: team.billingCycleStart,
- stripeCustomerId: team.stripeCustomerId,
- stripeSubscriptionId: team.stripeSubscriptionId,
- aiUsageLimitPerUser: team.aiUsageLimitPerUser,
createdAt: team.createdAt,
memberCount: team._count.members,
blueprintCount: team._count.blueprints,
@@ -222,30 +217,13 @@ export async function DELETE(
);
}
- // Get all team members to downgrade their subscriptions
- const members = await prismaUsers.teamMember.findMany({
- where: { teamId },
- select: { userId: true },
- });
-
// Delete the team (cascades to members, invitations, etc.)
await prismaUsers.team.delete({
where: { id: teamId },
});
- // Downgrade all former team members to FREE plan
- await prismaUsers.user.updateMany({
- where: {
- id: { in: members.map((m) => m.userId) },
- subscriptionPlan: "TEAMS",
- },
- data: {
- subscriptionPlan: "FREE",
- },
- });
-
return NextResponse.json({
- message: "Team deleted successfully. All members have been downgraded to Free plan.",
+ message: "Team deleted successfully.",
});
} catch (error) {
console.error("Error deleting team:", error);
diff --git a/src/app/api/v1/user/route.ts b/src/app/api/v1/user/route.ts
index c100f15..9d6abf7 100644
--- a/src/app/api/v1/user/route.ts
+++ b/src/app/api/v1/user/route.ts
@@ -68,11 +68,7 @@ export async function GET(request: NextRequest) {
persona: true,
skillLevel: true,
subscriptionPlan: true,
- subscriptionStatus: true,
- subscriptionInterval: true,
- currentPeriodEnd: true,
createdAt: true,
- // Count user's blueprints
_count: {
select: {
templates: true,
@@ -96,12 +92,7 @@ export async function GET(request: NextRequest) {
display_name: user.displayName,
persona: user.persona,
skill_level: user.skillLevel,
- subscription: {
- plan: user.subscriptionPlan,
- status: user.subscriptionStatus,
- interval: user.subscriptionInterval,
- current_period_end: user.currentPeriodEnd?.toISOString() || null,
- },
+ plan: user.subscriptionPlan,
stats: {
blueprints_count: user._count.templates,
},
diff --git a/src/app/blog/layout.tsx b/src/app/blog/layout.tsx
index 9883582..2be711a 100644
--- a/src/app/blog/layout.tsx
+++ b/src/app/blog/layout.tsx
@@ -1,4 +1,6 @@
+import { notFound } from "next/navigation";
import type { Metadata } from "next";
+import { ENABLE_BLOG } from "@/lib/feature-flags";
export const metadata: Metadata = {
title: {
@@ -39,6 +41,7 @@ export default function BlogLayout({
}: {
children: React.ReactNode;
}) {
+ if (!ENABLE_BLOG) notFound();
return children;
}
diff --git a/src/app/docs/ai-features/page.tsx b/src/app/docs/ai-features/page.tsx
index a210d19..4f9a8b7 100644
--- a/src/app/docs/ai-features/page.tsx
+++ b/src/app/docs/ai-features/page.tsx
@@ -18,13 +18,13 @@ export default function AIFeaturesOverviewPage() {
- {/* Teams exclusive note */}
+ {/* Teams note */}
- Teams Exclusive: AI features are available only to Teams
- subscribers.{" "}
-
- Upgrade to Teams β
+ Teams Feature: AI features are available to Teams
+ members.{" "}
+
+ Learn about Teams β
@@ -166,7 +166,7 @@ export default function AIFeaturesOverviewPage() {
Unlock AI Features
- Upgrade to Teams to access AI-powered editing and assistance.
+ Join a Team to access AI-powered editing and assistance.
- {children}
+
+ {children}
+
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx
index fbf890d..9216842 100644
--- a/src/app/settings/page.tsx
+++ b/src/app/settings/page.tsx
@@ -8,7 +8,6 @@ import { Button } from "@/components/ui/button";
import {
Sparkles,
User,
- Users,
Shield,
CreditCard,
Key,
@@ -26,10 +25,7 @@ import {
Smartphone,
Laptop,
Clock,
- ExternalLink,
- Star,
Crown,
- Zap,
Variable,
X,
FileCode,
@@ -91,7 +87,7 @@ const SECTIONS = [
{ id: "accounts", label: "Linked Accounts", icon: Link2 },
{ id: "security", label: "Security", icon: Shield },
{ id: "api-tokens", label: "API Tokens", icon: Code },
- { id: "billing", label: "Billing", icon: CreditCard },
+ { id: "seller-payouts", label: "Seller Payouts", icon: CreditCard },
];
interface UserProfile {
@@ -1326,9 +1322,9 @@ function SettingsContent() {
)}
- {/* Billing Section */}
- {activeSection === "billing" && (
-
+ {/* Seller Payouts Section */}
+ {activeSection === "seller-payouts" && (
+
)}
@@ -1339,34 +1335,14 @@ function SettingsContent() {
);
}
-// Billing Section Component
-interface BillingSectionProps {
+// Seller Payouts Section Component
+interface SellerPayoutsSectionProps {
error: string | null;
setError: (error: string | null) => void;
success: string | null;
setSuccess: (success: string | null) => void;
}
-interface SubscriptionStatus {
- plan: string;
- interval?: "monthly" | "annual";
- status: string | null;
- currentPeriodEnd: string | null;
- cancelAtPeriodEnd: boolean;
- hasStripeAccount: boolean;
- hasActiveSubscription: boolean;
- isAdmin?: boolean;
- pendingChange?: string | null;
- isAnnual?: boolean;
- isTeamsUser?: boolean;
- team?: {
- id: string;
- name: string;
- role: string;
- slug: string;
- } | null;
-}
-
interface SellerEarnings {
totalEarnings: number;
totalSales: number;
@@ -1388,50 +1364,7 @@ interface PayoutHistory {
processedAt: string | null;
}
-const PLAN_DETAILS = {
- free: {
- name: "User",
- description: "Full wizard access, all features except AI",
- price: "β¬0",
- priceAnnual: "β¬0",
- icon: Star,
- color: "text-primary",
- },
- // Legacy plans mapped to User tier
- pro: {
- name: "User",
- description: "Full wizard access, all features except AI",
- price: "β¬0",
- priceAnnual: "β¬0",
- icon: Star,
- color: "text-primary",
- },
- max: {
- name: "User",
- description: "Full wizard access, all features except AI",
- price: "β¬0",
- priceAnnual: "β¬0",
- icon: Star,
- color: "text-primary",
- },
- teams: {
- name: "Teams",
- description: "All features including AI editing, SSO, and team blueprints",
- price: "β¬10/seat/month",
- priceAnnual: "β¬10/seat/month",
- icon: Users,
- color: "text-teal-500",
- },
-};
-
-function BillingSection({ setError, setSuccess }: BillingSectionProps) {
- const [subscription, setSubscription] = useState(null);
- const [loading, setLoading] = useState(true);
- const [upgrading, setUpgrading] = useState(null);
- const [openingPortal, setOpeningPortal] = useState(false);
- const [euConsent, setEuConsent] = useState(false);
-
- // Seller payout state
+function SellerPayoutsSection({ setError, setSuccess }: SellerPayoutsSectionProps) {
const [earnings, setEarnings] = useState(null);
const [payoutHistory, setPayoutHistory] = useState([]);
const [loadingEarnings, setLoadingEarnings] = useState(false);
@@ -1440,20 +1373,6 @@ function BillingSection({ setError, setSuccess }: BillingSectionProps) {
const [requestingPayout, setRequestingPayout] = useState(false);
const [showPayoutHistory, setShowPayoutHistory] = useState(false);
- const fetchSubscription = async () => {
- try {
- const res = await fetch("/api/billing/status");
- if (res.ok) {
- const data = await res.json();
- setSubscription(data);
- }
- } catch {
- setError("Failed to load subscription info");
- } finally {
- setLoading(false);
- }
- };
-
const fetchEarnings = async () => {
setLoadingEarnings(true);
try {
@@ -1529,512 +1448,188 @@ function BillingSection({ setError, setSuccess }: BillingSectionProps) {
};
useEffect(() => {
- fetchSubscription();
fetchEarnings();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
- const handlePlanChange = async (plan: string) => {
- setUpgrading(plan);
- setError(null);
-
- try {
- // If user has active subscription, use change-plan API
- if (subscription?.hasActiveSubscription) {
- // Determine if this is an upgrade (Pro β Max)
- const planOrder: Record = { free: 0, pro: 1, max: 2, teams: 3 };
- const isUpgrade = planOrder[plan] > planOrder[currentPlan];
-
- // EU Digital Content Directive: require consent for upgrades (gaining new digital content)
- if (isUpgrade && !euConsent) {
- throw new Error("Please accept the terms to proceed with your upgrade.");
- }
-
- const res = await fetch("/api/billing/change-plan", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ plan, euDigitalContentConsent: isUpgrade ? euConsent : undefined }),
- });
-
- const data = await res.json();
-
- if (!res.ok) {
- throw new Error(data.error || "Failed to change plan");
- }
-
- // Show success message based on upgrade/downgrade
- if (data.type === "upgrade") {
- setSuccess(`Upgraded to ${plan.toUpperCase()}! Changes are effective immediately.`);
- } else if (data.type === "downgrade") {
- const effectiveDate = new Date(data.effectiveDate).toLocaleDateString();
- setSuccess(`Downgrade to ${plan.toUpperCase()} scheduled. You'll keep your current plan until ${effectiveDate}.`);
- }
-
- // Refresh subscription status
- await fetchSubscription();
- } else {
- // No active subscription, use checkout
- // Check EU consent first
- if (!euConsent) {
- throw new Error("Please accept the terms to proceed with your subscription.");
- }
-
- const res = await fetch("/api/billing/checkout", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ plan, euDigitalContentConsent: euConsent }),
- });
-
- if (!res.ok) {
- const data = await res.json();
- throw new Error(data.error || "Failed to create checkout session");
- }
-
- const { url } = await res.json();
- if (url) {
- window.location.href = url;
- }
- }
- } catch (err) {
- setError(err instanceof Error ? err.message : "Failed to change plan");
- } finally {
- setUpgrading(null);
- }
- };
-
- const handleManageBilling = async () => {
- setOpeningPortal(true);
- setError(null);
-
- try {
- const res = await fetch("/api/billing/portal", {
- method: "POST",
- });
-
- if (!res.ok) {
- const data = await res.json();
- throw new Error(data.error || "Failed to open billing portal");
- }
-
- const { url } = await res.json();
- if (url) {
- window.location.href = url;
- }
- } catch (err) {
- setError(err instanceof Error ? err.message : "Failed to open billing portal");
- } finally {
- setOpeningPortal(false);
- }
- };
-
- if (loading) {
- return (
-
- );
- }
-
- const currentPlan = subscription?.plan || "free";
- const planInfo = PLAN_DETAILS[currentPlan as keyof typeof PLAN_DETAILS] || PLAN_DETAILS.free;
- const PlanIcon = planInfo.icon;
-
return (
-
Billing
+
Seller Payouts
- Manage your subscription and payment methods
+ Manage earnings from your blueprint sales
- {/* Current Plan */}
+ {/* Earnings Overview */}
-
Current Plan
-
-
-
-
+
Earnings Overview
+ {loadingEarnings ? (
+
+ ) : earnings ? (
+
+
+
Total Earnings
+
+ β¬{(earnings.totalEarnings / 100).toFixed(2)}
+
+
+ from {earnings.totalSales} sales
+
-
-
-
{planInfo.name}
- {subscription?.isAdmin && (
-
- Admin
-
- )}
-
-
- {subscription?.isAdmin
- ? "Full access granted as administrator"
- : planInfo.description}
+
+
Available Balance
+
+ β¬{(earnings.availableBalance / 100).toFixed(2)}
- {!subscription?.isAdmin && subscription?.currentPeriodEnd && subscription.status === "active" && (
-
- {subscription.cancelAtPeriodEnd
- ? `Cancels on ${new Date(subscription.currentPeriodEnd).toLocaleDateString()}`
- : `Renews on ${new Date(subscription.currentPeriodEnd).toLocaleDateString()}`}
- {subscription?.isAnnual && !subscription.cancelAtPeriodEnd && (
- (Annual commitment)
- )}
+
+ ready for payout
+
+
+
+
Paid Out
+
+ β¬{(earnings.completedPayoutAmount / 100).toFixed(2)}
+
+ {earnings.pendingPayoutAmount > 0 && (
+
+ β¬{(earnings.pendingPayoutAmount / 100).toFixed(2)} pending
)}
-
-
- {subscription?.isAdmin ? "Free" : (subscription?.isAnnual ? planInfo.priceAnnual : planInfo.price)}
-
- {!subscription?.isAdmin && subscription?.isAnnual && (
-
- Annual (10% off)
-
- )}
- {!subscription?.isAdmin && subscription?.status && subscription.status !== "active" && (
-
- {subscription.status}
-
- )}
-
-
+ ) : (
+
+ No earnings data available
+
+ )}
- {/* Pending Downgrade Notice */}
- {subscription?.pendingChange && (
-
-
-
-
-
- Downgrade Scheduled
-
-
- Your plan will change to {subscription.pendingChange.toUpperCase()} at the end of your billing period
- {subscription.currentPeriodEnd && ` (${new Date(subscription.currentPeriodEnd).toLocaleDateString()})`}.
- You'll keep {currentPlan.toUpperCase()} access until then.
-
-
-
-
- )}
-
- {/* EU Digital Content Consent - No longer needed here since Teams upgrades go to /teams page */}
- {false && currentPlan !== "teams" && !subscription?.isAdmin && !subscription?.isTeamsUser && (
-
-
- setEuConsent(e.target.checked)}
- className="mt-1 h-4 w-4 shrink-0 cursor-pointer accent-primary"
- />
-
- I consent to immediate access to digital content and acknowledge that I lose my right to
- withdraw from this purchase within 14 days once I access the subscription features.
- *
-
-
+ {/* PayPal Configuration */}
+
+
Payout Settings
+
+ Configure your PayPal email to receive payouts from blueprint sales.
+ We send payouts via PayPal within 5 business days of your request.
+
+
+ setPaypalEmail(e.target.value)}
+ placeholder="your.paypal@email.com"
+ className="flex-1 rounded-lg border bg-background px-4 py-2 focus:outline-none focus:ring-2 focus:ring-primary"
+ />
+
+ {savingPaypal ? "Saving..." : "Save"}
+
- )}
+
+ Important: Use the email associated with your PayPal account.
+
+
- {/* Upgrade to Teams - Hide for admins (they already have Teams-level access) and existing Teams users */}
- {currentPlan !== "teams" && !subscription?.isAdmin && !subscription?.isTeamsUser && (
+ {/* Request Payout */}
+ {earnings && earnings.paypalEmail && (
-
Upgrade to Teams
-
- You already have full wizard access. Teams adds AI-powered editing, SSO, and team-shared blueprints.
-
-
-
-
-
-
-
- Teams
+
Request Payout
+
+
+
Available Balance
+
+ β¬{(earnings.availableBalance / 100).toFixed(2)}
-
β¬10/seat/month
-
- AI editing, SSO, team blueprints
+
+ Minimum payout: β¬{(earnings.minimumPayout / 100).toFixed(2)}
0
+ }
>
- Learn More
+ {requestingPayout ? (
+ "Requesting..."
+ ) : earnings.pendingPayoutAmount > 0 ? (
+ "Payout Pending"
+ ) : (
+ "Request Payout"
+ )}
-
-
- )}
-
- {/* Teams Plan Info - Only for Teams users */}
- {subscription?.isTeamsUser && subscription?.team && (
-
-
-
-
-
-
-
-
Team: {subscription.team.name}
-
- Role: {subscription.team.role === "ADMIN" ? "Administrator" : "Member"}
-
-
-
-
-
- {subscription.team.role === "ADMIN" ? "Manage Team" : "View Team"}
-
-
- Your subscription is managed at the team level. {subscription.team.role === "ADMIN"
- ? "You can manage members and billing from the team page."
- : "Contact your team admin for billing inquiries."}
-
+ {earnings.availableBalance < earnings.minimumPayout && earnings.availableBalance > 0 && (
+
+ You need β¬{((earnings.minimumPayout - earnings.availableBalance) / 100).toFixed(2)} more to reach the minimum payout amount.
+
+ )}
)}
- {/* Downgrade Option removed - no longer have Pro/Max tiers */}
-
- {/* Manage Subscription */}
- {subscription?.hasStripeAccount && (
+ {/* Payout History */}
+ {payoutHistory.length > 0 && (
-
Manage Subscription
-
- Update payment methods, view invoices, or cancel your subscription
- through the Stripe customer portal.
-
-
setShowPayoutHistory(!showPayoutHistory)}
+ className="flex w-full items-center justify-between"
>
- {openingPortal ? (
- "Opening..."
- ) : (
- <>
-
- Open Billing Portal
- >
- )}
-
-
- )}
-
- {/* No Stripe Account */}
- {!subscription?.hasStripeAccount && (
-
-
Payment Methods
-
-
-
- No payment methods added
-
-
- Add a payment method when you upgrade to a paid plan
-
-
+
Payout History
+
+ {showPayoutHistory ? "Hide" : "Show"} ({payoutHistory.length})
+
+
+
+ {showPayoutHistory && (
+
+ {payoutHistory.map((payout) => (
+
+
+
+ β¬{(payout.amount / 100).toFixed(2)}
+
+
+ {new Date(payout.requestedAt).toLocaleDateString()} β {payout.paypalEmail}
+
+
+
+ {payout.status}
+
+
+ ))}
+
+ )}
)}
- {/* Billing Info */}
+ {/* Seller Info */}
- Secure payments by Stripe. We never store your card details.
- Monthly subscriptions can be canceled anytime. Annual subscriptions commit for the full year
- and cannot be canceled mid-cycle (you keep access until the period ends).
- Prices are in EUR and include VAT where applicable.
+ Revenue split: You receive 70% of each blueprint sale.
+ LynxPrompt retains 30% as a platform fee.
+ Payouts are processed via PayPal within 5 business days after request.
+ Minimum payout is β¬10.00.
-
- {/* Seller Payouts Section - All users can sell blueprints */}
-
-
-
Seller Payouts
-
- Manage earnings from your blueprint sales
-
-
-
- {/* Earnings Overview */}
-
-
Earnings Overview
- {loadingEarnings ? (
-
- ) : earnings ? (
-
-
-
Total Earnings
-
- β¬{(earnings.totalEarnings / 100).toFixed(2)}
-
-
- from {earnings.totalSales} sales
-
-
-
-
Available Balance
-
- β¬{(earnings.availableBalance / 100).toFixed(2)}
-
-
- ready for payout
-
-
-
-
Paid Out
-
- β¬{(earnings.completedPayoutAmount / 100).toFixed(2)}
-
- {earnings.pendingPayoutAmount > 0 && (
-
- β¬{(earnings.pendingPayoutAmount / 100).toFixed(2)} pending
-
- )}
-
-
- ) : (
-
- No earnings data available
-
- )}
-
-
- {/* PayPal Configuration */}
-
-
Payout Settings
-
- Configure your PayPal email to receive payouts from blueprint sales.
- We send payouts via PayPal within 5 business days of your request.
-
-
- setPaypalEmail(e.target.value)}
- placeholder="your.paypal@email.com"
- className="flex-1 rounded-lg border bg-background px-4 py-2 focus:outline-none focus:ring-2 focus:ring-primary"
- />
-
- {savingPaypal ? "Saving..." : "Save"}
-
-
-
- Important: Use the email associated with your PayPal account.
-
-
-
- {/* Request Payout */}
- {earnings && earnings.paypalEmail && (
-
-
Request Payout
-
-
-
Available Balance
-
- β¬{(earnings.availableBalance / 100).toFixed(2)}
-
-
- Minimum payout: β¬{(earnings.minimumPayout / 100).toFixed(2)}
-
-
-
0
- }
- >
- {requestingPayout ? (
- "Requesting..."
- ) : earnings.pendingPayoutAmount > 0 ? (
- "Payout Pending"
- ) : (
- "Request Payout"
- )}
-
-
- {earnings.availableBalance < earnings.minimumPayout && earnings.availableBalance > 0 && (
-
- You need β¬{((earnings.minimumPayout - earnings.availableBalance) / 100).toFixed(2)} more to reach the minimum payout amount.
-
- )}
-
- )}
-
- {/* Payout History */}
- {payoutHistory.length > 0 && (
-
-
setShowPayoutHistory(!showPayoutHistory)}
- className="flex w-full items-center justify-between"
- >
- Payout History
-
- {showPayoutHistory ? "Hide" : "Show"} ({payoutHistory.length})
-
-
-
- {showPayoutHistory && (
-
- {payoutHistory.map((payout) => (
-
-
-
- β¬{(payout.amount / 100).toFixed(2)}
-
-
- {new Date(payout.requestedAt).toLocaleDateString()} β {payout.paypalEmail}
-
-
-
- {payout.status}
-
-
- ))}
-
- )}
-
- )}
-
- {/* Seller Info */}
-
-
- Revenue split: You receive 70% of each blueprint sale.
- LynxPrompt retains 30% as a platform fee.
- Payouts are processed via PayPal within 5 business days after request.
- Minimum payout is β¬10.00.
-
-
-
);
}
@@ -2679,25 +2274,11 @@ function ApiTokensSection({ setError, setSuccess }: ApiTokensSectionProps) {
const [createdToken, setCreatedToken] = useState
(null);
const [showToken, setShowToken] = useState(false);
const [copied, setCopied] = useState(false);
- const [subscriptionPlan, setSubscriptionPlan] = useState("FREE");
useEffect(() => {
fetchTokens();
- fetchSubscription();
}, []);
- const fetchSubscription = async () => {
- try {
- const res = await fetch("/api/billing/status");
- if (res.ok) {
- const data = await res.json();
- setSubscriptionPlan(data.plan || "FREE");
- }
- } catch {
- // Ignore errors
- }
- };
-
const fetchTokens = async () => {
setLoading(true);
try {
diff --git a/src/app/support/layout.tsx b/src/app/support/layout.tsx
new file mode 100644
index 0000000..0edb23b
--- /dev/null
+++ b/src/app/support/layout.tsx
@@ -0,0 +1,11 @@
+import { notFound } from "next/navigation";
+import { ENABLE_SUPPORT_FORUM } from "@/lib/feature-flags";
+
+export default function SupportLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ if (!ENABLE_SUPPORT_FORUM) notFound();
+ return children;
+}
diff --git a/src/app/teams/[slug]/page.tsx b/src/app/teams/[slug]/page.tsx
index 5406a0c..b1d8552 100644
--- a/src/app/teams/[slug]/page.tsx
+++ b/src/app/teams/[slug]/page.tsx
@@ -19,11 +19,9 @@ import {
Loader2,
ArrowLeft,
Building2,
- CreditCard,
Copy,
Check,
Camera,
- ImageIcon,
} from "lucide-react";
import { SSOConfigPanel } from "@/components/sso-config";
@@ -58,8 +56,6 @@ interface Team {
slug: string;
logo: string | null;
maxSeats: number;
- subscriptionInterval: "monthly" | "annual" | null;
- aiUsageLimitPerUser: number;
createdAt: string;
members: TeamMember[];
invitations: TeamInvitation[];
@@ -153,12 +149,6 @@ export default function TeamManagementPage() {
}
};
- const [showPaymentPrompt, setShowPaymentPrompt] = useState(false);
- const [paymentDetails, setPaymentDetails] = useState<{
- additionalSeatsNeeded: number;
- pendingEmail: string;
- } | null>(null);
-
const handleInvite = async (e: React.FormEvent) => {
e.preventDefault();
if (!team || !inviteEmail.trim()) return;
@@ -166,7 +156,6 @@ export default function TeamManagementPage() {
setInviting(true);
setInviteError(null);
setInviteSuccess(null);
- setShowPaymentPrompt(false);
try {
const res = await fetch(`/api/teams/${team.id}/invitations`, {
@@ -177,24 +166,13 @@ export default function TeamManagementPage() {
const data = await res.json();
- if (res.status === 402 && data.code === "SEATS_REQUIRED") {
- // Need to purchase additional seat
- setPaymentDetails({
- additionalSeatsNeeded: data.details.additionalSeatsNeeded,
- pendingEmail: inviteEmail,
- });
- setShowPaymentPrompt(true);
- setInviting(false);
- return;
- }
-
if (!res.ok) {
throw new Error(data.error || "Failed to send invitation");
}
setInviteSuccess(`Invitation sent to ${inviteEmail}`);
setInviteEmail("");
- fetchTeam(); // Refresh team data
+ fetchTeam();
} catch (err) {
setInviteError(err instanceof Error ? err.message : "Failed to send invitation");
} finally {
@@ -202,15 +180,6 @@ export default function TeamManagementPage() {
}
};
- const handlePurchaseSeats = async () => {
- if (!team || !paymentDetails) return;
-
- // Redirect to billing page to add seats
- // After seat increase, they can retry the invitation
- const newSeats = (team.maxSeats || 3) + paymentDetails.additionalSeatsNeeded;
- router.push(`/teams/${team.slug}?tab=billing&seats=${newSeats}`);
- };
-
const handleCopyInviteLink = async (token: string) => {
const link = `${window.location.origin}/teams/join?token=${token}`;
await navigator.clipboard.writeText(link);
@@ -533,49 +502,20 @@ export default function TeamManagementPage() {
{inviteSuccess && (
{inviteSuccess}
)}
-
- {showPaymentPrompt && paymentDetails && (
-
-
- You need to purchase {paymentDetails.additionalSeatsNeeded} additional seat{paymentDetails.additionalSeatsNeeded > 1 ? "s" : ""} to invite {paymentDetails.pendingEmail} .
-
-
-
-
- Purchase Seat ({team?.subscriptionInterval === "annual" ? "β¬9/seat (annual)" : "β¬10/seat"})
-
- setShowPaymentPrompt(false)}
- >
- Cancel
-
-
-
- )}
- {!showPaymentPrompt && (
-
- {inviting ? (
- <>
-
- Sending...
- >
- ) : (
- <>
-
- Send Invitation
- >
- )}
-
- )}
+
+ {inviting ? (
+ <>
+
+ Sending...
+ >
+ ) : (
+ <>
+
+ Send Invitation
+ >
+ )}
+
)}
@@ -583,26 +523,13 @@ export default function TeamManagementPage() {
{/* Quick Stats */}
-
- Plan Details
+
+ Team Info
- Plan
- Teams
-
-
- Billing
-
- {team?.subscriptionInterval || "monthly"}
- {team?.subscriptionInterval === "annual" && (
- (10% off)
- )}
-
-
-
- Paid Seats
+ Max Seats
{team?.maxSeats}
diff --git a/src/components/page-header.tsx b/src/components/page-header.tsx
index 21e877d..fce1923 100644
--- a/src/components/page-header.tsx
+++ b/src/components/page-header.tsx
@@ -6,19 +6,13 @@ import { Menu, X, Github } from "lucide-react";
import { Logo } from "@/components/logo";
import { UserMenu } from "@/components/user-menu";
import { ThemeToggle } from "@/components/theme-toggle";
+import { useFeatureFlags } from "@/components/providers/feature-flags-provider";
interface NavItem {
href: string;
label: string;
}
-const NAV_ITEMS: NavItem[] = [
- { href: "/wizard", label: "Wizard" },
- { href: "/blueprints", label: "Blueprints" },
- { href: "/docs", label: "Docs" },
- { href: "/blog", label: "Blog" },
-];
-
interface PageHeaderProps {
/** Current page identifier (e.g., "pricing", "blueprints", "docs") */
currentPage?: string;
@@ -37,6 +31,14 @@ export function PageHeader({
showBreadcrumb = true,
}: PageHeaderProps) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
+ const { enableBlog } = useFeatureFlags();
+
+ const NAV_ITEMS: NavItem[] = [
+ { href: "/wizard", label: "Wizard" },
+ { href: "/blueprints", label: "Blueprints" },
+ { href: "/docs", label: "Docs" },
+ ...(enableBlog ? [{ href: "/blog", label: "Blog" }] : []),
+ ];
// Generate display label for breadcrumb
const displayLabel = breadcrumbLabel || (currentPage ? currentPage.charAt(0).toUpperCase() + currentPage.slice(1) : "");
diff --git a/src/components/providers/feature-flags-provider.tsx b/src/components/providers/feature-flags-provider.tsx
new file mode 100644
index 0000000..3ad216c
--- /dev/null
+++ b/src/components/providers/feature-flags-provider.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { createContext, useContext, useEffect, useState } from "react";
+
+interface FeatureFlags {
+ enableGithubOAuth: boolean;
+ enableGoogleOAuth: boolean;
+ enableEmailAuth: boolean;
+ enablePasskeys: boolean;
+ enableTurnstile: boolean;
+ enableSSO: boolean;
+ enableUserRegistration: boolean;
+ enableAI: boolean;
+ enableBlog: boolean;
+ enableSupportForum: boolean;
+ enableStripe: boolean;
+ appName: string;
+ appUrl: string;
+ appLogoUrl: string;
+}
+
+const defaultFlags: FeatureFlags = {
+ enableGithubOAuth: false,
+ enableGoogleOAuth: false,
+ enableEmailAuth: true,
+ enablePasskeys: true,
+ enableTurnstile: false,
+ enableSSO: false,
+ enableUserRegistration: true,
+ enableAI: false,
+ enableBlog: false,
+ enableSupportForum: false,
+ enableStripe: false,
+ appName: "LynxPrompt",
+ appUrl: "http://localhost:3000",
+ appLogoUrl: "",
+};
+
+const FeatureFlagsContext = createContext
(defaultFlags);
+
+export function useFeatureFlags() {
+ return useContext(FeatureFlagsContext);
+}
+
+export function FeatureFlagsProvider({
+ children,
+ initialFlags,
+}: {
+ children: React.ReactNode;
+ initialFlags?: Partial;
+}) {
+ const [flags, setFlags] = useState({
+ ...defaultFlags,
+ ...initialFlags,
+ });
+
+ useEffect(() => {
+ fetch("/api/config/public")
+ .then((res) => res.json())
+ .then((data) => setFlags((prev) => ({ ...prev, ...data })))
+ .catch(() => {});
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index c09ce73..f71e786 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -405,10 +405,7 @@ export const authOptions: NextAuthOptions = {
skillLevel: true,
profileCompleted: true,
authenticators: { select: { id: true } },
- // Subscription fields
subscriptionPlan: true,
- subscriptionStatus: true,
- subscriptionInterval: true,
},
});
@@ -420,11 +417,7 @@ export const authOptions: NextAuthOptions = {
session.user.persona = dbUser?.persona || null;
session.user.skillLevel = dbUser?.skillLevel || null;
session.user.profileCompleted = dbUser?.profileCompleted || false;
-
- // Subscription fields
session.user.subscriptionPlan = dbUser?.subscriptionPlan || "FREE";
- session.user.subscriptionStatus = dbUser?.subscriptionStatus || null;
- session.user.subscriptionInterval = dbUser?.subscriptionInterval || null;
// Check if user has passkeys and if verification is needed
const hasPasskeys = (dbUser?.authenticators?.length ?? 0) > 0;
@@ -450,8 +443,6 @@ export const authOptions: NextAuthOptions = {
session.user.hasPasskeys = false;
session.user.requiresPasskeyCheck = false;
session.user.subscriptionPlan = "FREE";
- session.user.subscriptionStatus = null;
- session.user.subscriptionInterval = null;
}
}
// For JWT sessions (Passkey)
@@ -465,10 +456,7 @@ export const authOptions: NextAuthOptions = {
session.user.skillLevel = (token.skillLevel as string) || null;
session.user.profileCompleted =
(token.profileCompleted as boolean) || false;
- // Subscription fields
session.user.subscriptionPlan = (token.subscriptionPlan as string) || "FREE";
- session.user.subscriptionStatus = (token.subscriptionStatus as string) || null;
- session.user.subscriptionInterval = (token.subscriptionInterval as string) || null;
// Passkey login sessions are already verified
session.user.hasPasskeys = true;
session.user.requiresPasskeyCheck = false;
@@ -489,8 +477,6 @@ export const authOptions: NextAuthOptions = {
skillLevel: true,
profileCompleted: true,
subscriptionPlan: true,
- subscriptionStatus: true,
- subscriptionInterval: true,
},
});
token.image = dbUser?.image || null;
@@ -500,8 +486,6 @@ export const authOptions: NextAuthOptions = {
token.skillLevel = dbUser?.skillLevel || null;
token.profileCompleted = dbUser?.profileCompleted || false;
token.subscriptionPlan = dbUser?.subscriptionPlan || "FREE";
- token.subscriptionStatus = dbUser?.subscriptionStatus || null;
- token.subscriptionInterval = dbUser?.subscriptionInterval || null;
}
return token;
},
diff --git a/src/lib/feature-flags.ts b/src/lib/feature-flags.ts
new file mode 100644
index 0000000..93119c9
--- /dev/null
+++ b/src/lib/feature-flags.ts
@@ -0,0 +1,62 @@
+/**
+ * Feature flags system for LynxPrompt self-hosting configuration.
+ * All flags are controlled via environment variables with sensible defaults
+ * for minimal self-hosted deployments.
+ */
+
+function envBool(key: string, defaultValue: boolean): boolean {
+ const val = process.env[key];
+ if (val === undefined || val === "") return defaultValue;
+ return val === "true" || val === "1";
+}
+
+// Auth
+export const ENABLE_GITHUB_OAUTH = envBool("ENABLE_GITHUB_OAUTH", false);
+export const ENABLE_GOOGLE_OAUTH = envBool("ENABLE_GOOGLE_OAUTH", false);
+export const ENABLE_EMAIL_AUTH = envBool("ENABLE_EMAIL_AUTH", true);
+export const ENABLE_PASSKEYS = envBool("ENABLE_PASSKEYS", true);
+export const ENABLE_TURNSTILE = envBool("ENABLE_TURNSTILE", false);
+export const ENABLE_SSO = envBool("ENABLE_SSO", false);
+export const ENABLE_USER_REGISTRATION = envBool("ENABLE_USER_REGISTRATION", true);
+
+// AI
+export const ENABLE_AI = envBool("ENABLE_AI", false);
+export const AI_MODEL = process.env.AI_MODEL || "claude-3-5-haiku-latest";
+
+// Content
+export const ENABLE_BLOG = envBool("ENABLE_BLOG", false);
+export const ENABLE_SUPPORT_FORUM = envBool("ENABLE_SUPPORT_FORUM", false);
+
+// Marketplace / Payments
+export const ENABLE_STRIPE = envBool("ENABLE_STRIPE", false);
+
+// Branding
+export const APP_NAME = process.env.APP_NAME || "LynxPrompt";
+export const APP_URL = process.env.APP_URL || process.env.NEXTAUTH_URL || "http://localhost:3000";
+export const APP_LOGO_URL = process.env.APP_LOGO_URL || "";
+
+// Analytics
+export const UMAMI_SCRIPT_URL = process.env.UMAMI_SCRIPT_URL || "";
+
+/**
+ * Public feature flags exposed to the client via /api/config/public.
+ * Only include flags safe to expose publicly.
+ */
+export function getPublicFlags() {
+ return {
+ enableGithubOAuth: ENABLE_GITHUB_OAUTH,
+ enableGoogleOAuth: ENABLE_GOOGLE_OAUTH,
+ enableEmailAuth: ENABLE_EMAIL_AUTH,
+ enablePasskeys: ENABLE_PASSKEYS,
+ enableTurnstile: ENABLE_TURNSTILE,
+ enableSSO: ENABLE_SSO,
+ enableUserRegistration: ENABLE_USER_REGISTRATION,
+ enableAI: ENABLE_AI,
+ enableBlog: ENABLE_BLOG,
+ enableSupportForum: ENABLE_SUPPORT_FORUM,
+ enableStripe: ENABLE_STRIPE,
+ appName: APP_NAME,
+ appUrl: APP_URL,
+ appLogoUrl: APP_LOGO_URL,
+ };
+}
diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts
index a48b771..8be1d99 100644
--- a/src/types/next-auth.d.ts
+++ b/src/types/next-auth.d.ts
@@ -9,18 +9,13 @@ declare module "next-auth" {
email?: string | null;
image?: string | null;
role?: UserRole | string;
- // Profile fields
displayName?: string | null;
persona?: string | null;
skillLevel?: string | null;
profileCompleted?: boolean;
- // Passkey 2FA fields
hasPasskeys?: boolean;
requiresPasskeyCheck?: boolean;
- // Subscription fields
subscriptionPlan?: SubscriptionPlan | string;
- subscriptionStatus?: string | null;
- subscriptionInterval?: string | null;
};
}
@@ -33,14 +28,10 @@ declare module "next-auth/jwt" {
interface JWT {
role?: UserRole | string;
image?: string | null;
- // Profile fields
displayName?: string | null;
persona?: string | null;
skillLevel?: string | null;
profileCompleted?: boolean;
- // Subscription fields
subscriptionPlan?: SubscriptionPlan | string;
- subscriptionStatus?: string | null;
- subscriptionInterval?: string | null;
}
}
From 8af4fc50cbb4957c6424e25a8f3716de27d2db73 Mon Sep 17 00:00:00 2001
From: GeiserX <9169332+GeiserX@users.noreply.github.com>
Date: Wed, 25 Feb 2026 11:22:15 +0100
Subject: [PATCH 05/10] feat: wire auth/AI flags, consolidate DB, create
self-host compose
Auth configurability:
- Make OAuth providers conditional on ENABLE_* flags in NextAuth config
- Make Turnstile pass-through when disabled
- Conditionally render login buttons based on enabled providers
- Gate passkey and SSO routes behind feature flags
- Block new user registration when ENABLE_USER_REGISTRATION=false
AI configurability:
- Use AI_MODEL env var instead of hardcoded model name
- Hide AI edit panels when ENABLE_AI=false
Database consolidation:
- Rewrite env.example for v2.0 (single DB defaults, feature flags)
- Create docker-compose.selfhost.yml (minimal 1-Postgres deployment)
- Replace Percona pg_tde with postgres:18-alpine in dev compose
- Add auto-migration to entrypoint.sh (prisma migrate deploy)
- Add UMAMI_SCRIPT_URL build arg to Dockerfile
---
Dockerfile | 2 +
docker-compose.selfhost.yml | 48 ++++
docker-compose.yml | 41 ++--
entrypoint.sh | 8 +-
env.example | 121 ++++------
src/app/api/ai/edit-blueprint/route.ts | 4 +-
.../passkey/authenticate/options/route.ts | 5 +
.../auth/passkey/authenticate/verify/route.ts | 5 +
src/app/api/auth/passkey/list/route.ts | 9 +
.../auth/passkey/register/options/route.ts | 5 +
.../api/auth/passkey/register/verify/route.ts | 10 +-
src/app/api/auth/sso/initiate/route.ts | 16 +-
src/app/api/auth/sso/lookup/route.ts | 10 +-
src/app/auth/signin/page.tsx | 125 ++++++----
src/app/blueprints/[id]/edit/page.tsx | 6 +-
src/app/blueprints/create/page.tsx | 6 +-
src/app/wizard/page.tsx | 6 +-
src/components/turnstile.tsx | 14 +-
src/lib/auth.ts | 223 ++++++++++--------
src/lib/turnstile.ts | 10 +-
20 files changed, 415 insertions(+), 259 deletions(-)
create mode 100644 docker-compose.selfhost.yml
diff --git a/Dockerfile b/Dockerfile
index 648283d..8591999 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -52,9 +52,11 @@ RUN npx prisma generate --config=prisma/prisma.config-app.ts & \
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY
ARG NEXT_PUBLIC_SENTRY_DSN
+ARG UMAMI_SCRIPT_URL
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=${NEXT_PUBLIC_TURNSTILE_SITE_KEY}
ENV NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN}
+ENV UMAMI_SCRIPT_URL=${UMAMI_SCRIPT_URL}
ENV NEXT_TELEMETRY_DISABLED=1
ENV TSC_COMPILE_ON_ERROR=true
# Disable Turbopack for production builds (fixes font resolution issues in Next.js 16)
diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml
new file mode 100644
index 0000000..2071d73
--- /dev/null
+++ b/docker-compose.selfhost.yml
@@ -0,0 +1,48 @@
+# docker-compose.selfhost.yml β Minimal self-hosted LynxPrompt deployment
+# Usage: docker compose -f docker-compose.selfhost.yml up -d
+#
+# Required env vars (set in .env or environment):
+# NEXTAUTH_SECRET β generate with: openssl rand -base64 32
+# ADMIN_EMAIL β your email (auto-promoted to superadmin)
+#
+# Optional:
+# DB_PASSWORD β database password (default: changeme)
+# APP_URL β your domain (default: http://localhost:3000)
+
+services:
+ postgres:
+ image: postgres:18-alpine
+ restart: unless-stopped
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+ environment:
+ POSTGRES_DB: lynxprompt
+ POSTGRES_USER: lynxprompt
+ POSTGRES_PASSWORD: ${DB_PASSWORD:-changeme}
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U lynxprompt -d lynxprompt"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+
+ lynxprompt:
+ image: drumsergio/lynxprompt:2.0.0
+ restart: unless-stopped
+ ports:
+ - "${PORT:-3000}:3000"
+ depends_on:
+ postgres:
+ condition: service_healthy
+ environment:
+ DATABASE_URL_APP: postgresql://lynxprompt:${DB_PASSWORD:-changeme}@postgres:5432/lynxprompt?schema=public
+ DATABASE_URL_USERS: postgresql://lynxprompt:${DB_PASSWORD:-changeme}@postgres:5432/lynxprompt?schema=public
+ DATABASE_URL_BLOG: postgresql://lynxprompt:${DB_PASSWORD:-changeme}@postgres:5432/lynxprompt?schema=public
+ DATABASE_URL_SUPPORT: postgresql://lynxprompt:${DB_PASSWORD:-changeme}@postgres:5432/lynxprompt?schema=public
+ NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
+ NEXTAUTH_URL: ${APP_URL:-http://localhost:3000}
+ APP_URL: ${APP_URL:-http://localhost:3000}
+ SUPERADMIN_EMAIL: ${ADMIN_EMAIL:-}
+ NODE_ENV: production
+
+volumes:
+ pgdata:
diff --git a/docker-compose.yml b/docker-compose.yml
index 4de7032..efc034b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -13,14 +13,11 @@
services:
# ==========================================================================
# APP DATABASE - System templates, platforms, languages
- # Uses Percona PostgreSQL 18 with pg_tde for Transparent Data Encryption
# ==========================================================================
postgres-app:
- image: percona/percona-distribution-postgresql:18
+ image: postgres:18-alpine
container_name: lynxprompt-postgres-app
restart: unless-stopped
- command: >
- -c shared_preload_libraries=pg_tde
logging:
driver: "json-file"
options:
@@ -30,9 +27,9 @@ services:
POSTGRES_DB: lynxprompt_app
POSTGRES_USER: lynxprompt_app
POSTGRES_PASSWORD: ${POSTGRES_APP_PASSWORD:-dev_app_password_change_me}
+ PGDATA: /data/pgdata
volumes:
- - postgres_app_data:/data/db
- - postgres_app_keyring:/keyring
+ - postgres_app_data:/data
networks:
- lynxprompt
healthcheck:
@@ -51,14 +48,11 @@ services:
# ==========================================================================
# USER DATABASE - Golden data (users, sessions, projects)
- # Uses Percona PostgreSQL 18 with pg_tde for Transparent Data Encryption
# ==========================================================================
postgres-users:
- image: percona/percona-distribution-postgresql:18
+ image: postgres:18-alpine
container_name: lynxprompt-postgres-users
restart: unless-stopped
- command: >
- -c shared_preload_libraries=pg_tde
logging:
driver: "json-file"
options:
@@ -68,9 +62,9 @@ services:
POSTGRES_DB: lynxprompt_users
POSTGRES_USER: lynxprompt_users
POSTGRES_PASSWORD: ${POSTGRES_USERS_PASSWORD:-dev_users_password_change_me}
+ PGDATA: /data/pgdata
volumes:
- - postgres_users_data:/data/db
- - postgres_users_keyring:/keyring
+ - postgres_users_data:/data
networks:
- lynxprompt
healthcheck:
@@ -234,20 +228,37 @@ services:
TURNSTILE_SECRET_KEY: ${TURNSTILE_SECRET_KEY:-}
# Anthropic API for AI features (configure in .env)
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
+ AI_MODEL: ${AI_MODEL:-claude-3-5-haiku-latest}
# Sentry/GlitchTip (configure in .env)
SENTRY_DSN: ${SENTRY_DSN:-}
NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN:-}
+ # Analytics
+ UMAMI_SCRIPT_URL: ${UMAMI_SCRIPT_URL:-}
+ NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-}
+ # Feature flags
+ ENABLE_GITHUB_OAUTH: ${ENABLE_GITHUB_OAUTH:-false}
+ ENABLE_GOOGLE_OAUTH: ${ENABLE_GOOGLE_OAUTH:-false}
+ ENABLE_EMAIL_AUTH: ${ENABLE_EMAIL_AUTH:-true}
+ ENABLE_PASSKEYS: ${ENABLE_PASSKEYS:-true}
+ ENABLE_TURNSTILE: ${ENABLE_TURNSTILE:-false}
+ ENABLE_SSO: ${ENABLE_SSO:-false}
+ ENABLE_USER_REGISTRATION: ${ENABLE_USER_REGISTRATION:-true}
+ ENABLE_AI: ${ENABLE_AI:-false}
+ ENABLE_BLOG: ${ENABLE_BLOG:-false}
+ ENABLE_SUPPORT_FORUM: ${ENABLE_SUPPORT_FORUM:-false}
+ ENABLE_STRIPE: ${ENABLE_STRIPE:-false}
+ # Branding
+ APP_NAME: ${APP_NAME:-LynxPrompt}
+ APP_URL: ${APP_URL:-http://localhost:3000}
+ APP_LOGO_URL: ${APP_LOGO_URL:-}
# App
NODE_ENV: ${NODE_ENV:-development}
- MOCK: ${MOCK:-false}
SUPERADMIN_EMAIL: ${SUPERADMIN_EMAIL:-}
UPLOAD_DIR: /data/uploads/blog
volumes:
postgres_app_data:
- postgres_app_keyring:
postgres_users_data:
- postgres_users_keyring:
postgres_blog_data:
postgres_support_data:
uploads_data:
diff --git a/entrypoint.sh b/entrypoint.sh
index a3edc69..71eeac6 100644
--- a/entrypoint.sh
+++ b/entrypoint.sh
@@ -3,7 +3,11 @@ set -e
echo "Starting LynxPrompt..."
-# Note: Database migrations should be run manually or via a separate init container
-# Tables are expected to already exist. Use prisma db push locally before deploying.
+echo "Running database migrations..."
+npx prisma migrate deploy --config=prisma/prisma.config-app.ts 2>&1 || echo "App DB migration: no pending migrations"
+npx prisma migrate deploy --config=prisma/prisma.config-users.ts 2>&1 || echo "Users DB migration: no pending migrations"
+npx prisma migrate deploy --config=prisma/prisma.config-blog.ts 2>&1 || echo "Blog DB migration: no pending migrations"
+npx prisma migrate deploy --config=prisma/prisma.config-support.ts 2>&1 || echo "Support DB migration: no pending migrations"
+echo "Migrations complete."
exec node server.js
diff --git a/env.example b/env.example
index 77ca6b7..c52bee5 100644
--- a/env.example
+++ b/env.example
@@ -1,111 +1,94 @@
# =============================================================================
-# LynxPrompt Environment Variables
+# LynxPrompt v2.0 Environment Variables
# Copy this file to .env and fill in your values
# =============================================================================
# =============================================================================
-# DATABASES (Four-database architecture)
+# DATABASE
# =============================================================================
-# For docker-compose: passwords must match POSTGRES_*_PASSWORD below
-# For local dev without docker: adjust hosts/ports as needed
+# By default, all schemas use a single PostgreSQL database.
+# For advanced setups, you can point each to a different database.
+DATABASE_URL_APP=postgresql://lynxprompt:changeme@localhost:5432/lynxprompt?schema=public
+DATABASE_URL_USERS=postgresql://lynxprompt:changeme@localhost:5432/lynxprompt?schema=public
+DATABASE_URL_BLOG=postgresql://lynxprompt:changeme@localhost:5432/lynxprompt?schema=public
+DATABASE_URL_SUPPORT=postgresql://lynxprompt:changeme@localhost:5432/lynxprompt?schema=public
-# APP Database - System templates, platforms, languages (can be recreated from seed)
-DATABASE_URL_APP=postgresql://lynxprompt_app:dev_app_password_change_me@localhost:5432/lynxprompt_app?schema=public
-
-# USERS Database - User data, sessions, preferences (GOLDEN - critical backups!)
-DATABASE_URL_USERS=postgresql://lynxprompt_users:dev_users_password_change_me@localhost:5433/lynxprompt_users?schema=public
-
-# BLOG Database - Blog posts and content
-DATABASE_URL_BLOG=postgresql://lynxprompt_blog:dev_blog_password_change_me@localhost:5434/lynxprompt_blog?schema=public
-
-# SUPPORT Database - Feedback forum data (bugs, suggestions, votes)
-DATABASE_URL_SUPPORT=postgresql://lynxprompt_support:dev_support_password_change_me@localhost:5435/lynxprompt_support?schema=public
-
-# Database passwords for docker-compose (must match URLs above)
-POSTGRES_APP_PASSWORD=dev_app_password_change_me
-POSTGRES_USERS_PASSWORD=dev_users_password_change_me
-POSTGRES_BLOG_PASSWORD=dev_blog_password_change_me
-POSTGRES_SUPPORT_PASSWORD=dev_support_password_change_me
+# Password for docker-compose PostgreSQL container
+POSTGRES_PASSWORD=changeme
# =============================================================================
-# NEXTAUTH - Authentication
+# AUTHENTICATION
# =============================================================================
-# Generate with: openssl rand -base64 32
-NEXTAUTH_SECRET=your-super-secret-key-change-this-in-production
+# Required: generate with `openssl rand -base64 32`
+NEXTAUTH_SECRET=your-super-secret-key-change-this
NEXTAUTH_URL=http://localhost:3000
-# GitHub OAuth
-# Create at: https://github.com/settings/developers
-# - Set Homepage URL to: http://localhost:3000
-# - Set Authorization callback URL to: http://localhost:3000/api/auth/callback/github
+# GitHub OAuth (optional, set ENABLE_GITHUB_OAUTH=true to activate)
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
-# Google OAuth (optional)
-# Create at: https://console.cloud.google.com/apis/credentials
-# - Set Authorized redirect URIs to: http://localhost:3000/api/auth/callback/google
+# Google OAuth (optional, set ENABLE_GOOGLE_OAUTH=true to activate)
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
-# =============================================================================
-# EMAIL / SMTP (for magic links)
-# =============================================================================
-# For Gmail: Enable 2FA and create App Password at https://myaccount.google.com/apppasswords
+# Email / SMTP (for magic link login)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
-SMTP_FROM=noreply@lynxprompt.com
+SMTP_FROM=noreply@example.com
SMTP_FROM_NAME=LynxPrompt
# =============================================================================
-# APPLICATION
+# FEATURE FLAGS (all have sensible defaults for self-hosting)
# =============================================================================
-NODE_ENV=development
-MOCK=false
+# Auth methods
+ENABLE_GITHUB_OAUTH=false
+ENABLE_GOOGLE_OAUTH=false
+ENABLE_EMAIL_AUTH=true
+ENABLE_PASSKEYS=true
+ENABLE_TURNSTILE=false
+ENABLE_SSO=false
+ENABLE_USER_REGISTRATION=true
-# Auto-promote this email to SUPERADMIN on first sign-in
-SUPERADMIN_EMAIL=
-
-# =============================================================================
-# STRIPE - Payment Processing
-# =============================================================================
-# Get keys from: https://dashboard.stripe.com/apikeys
-STRIPE_SECRET_KEY=sk_test_...
-STRIPE_WEBHOOK_SECRET=whsec_...
+# AI features (requires ANTHROPIC_API_KEY)
+ENABLE_AI=false
+AI_MODEL=claude-3-5-haiku-latest
+ANTHROPIC_API_KEY=
-# Monthly price IDs (create in Stripe Dashboard)
-STRIPE_PRICE_PRO_MONTHLY=price_...
-STRIPE_PRICE_MAX_MONTHLY=price_...
-STRIPE_PRICE_TEAMS_SEAT_MONTHLY=price_...
+# Content modules
+ENABLE_BLOG=false
+ENABLE_SUPPORT_FORUM=false
-# Annual price IDs (10% discount: Pro β¬54/year, Max β¬216/year, Teams β¬324/seat/year)
-STRIPE_PRICE_PRO_ANNUAL=price_...
-STRIPE_PRICE_MAX_ANNUAL=price_...
-STRIPE_PRICE_TEAMS_SEAT_ANNUAL=price_...
+# Marketplace payments (Stripe)
+ENABLE_STRIPE=false
+STRIPE_SECRET_KEY=
+STRIPE_WEBHOOK_SECRET=
# =============================================================================
-# CLOUDFLARE TURNSTILE - Anti-bot Protection
+# BRANDING
# =============================================================================
-# Get keys from: https://dash.cloudflare.com/turnstile
-NEXT_PUBLIC_TURNSTILE_SITE_KEY=
-TURNSTILE_SECRET_KEY=
+APP_NAME=LynxPrompt
+APP_URL=http://localhost:3000
+APP_LOGO_URL=
# =============================================================================
-# AI FEATURES
+# ADMIN
# =============================================================================
-# Anthropic API for AI-powered blueprint editing (MAX tier)
-ANTHROPIC_API_KEY=
+# Auto-promote this email to SUPERADMIN on first sign-in
+SUPERADMIN_EMAIL=
# =============================================================================
-# ERROR TRACKING (optional)
+# OPTIONAL SERVICES
# =============================================================================
-# Sentry or GlitchTip DSN
+# Cloudflare Turnstile (requires ENABLE_TURNSTILE=true)
+NEXT_PUBLIC_TURNSTILE_SITE_KEY=
+TURNSTILE_SECRET_KEY=
+
+# Error tracking (Sentry-compatible, e.g. GlitchTip)
SENTRY_DSN=
NEXT_PUBLIC_SENTRY_DSN=
-# =============================================================================
-# ANALYTICS (optional)
-# =============================================================================
-# Umami (privacy-focused, self-hosted)
+# Analytics (Umami, self-hosted)
+UMAMI_SCRIPT_URL=
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
diff --git a/src/app/api/ai/edit-blueprint/route.ts b/src/app/api/ai/edit-blueprint/route.ts
index 724ae95..91a4496 100644
--- a/src/app/api/ai/edit-blueprint/route.ts
+++ b/src/app/api/ai/edit-blueprint/route.ts
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { authenticateRequest } from "@/lib/api-auth";
import { prismaUsers } from "@/lib/db-users";
-import { ENABLE_AI } from "@/lib/feature-flags";
+import { ENABLE_AI, AI_MODEL } from "@/lib/feature-flags";
import Anthropic from "@anthropic-ai/sdk";
// Cost tracking constants (in tokens)
@@ -190,7 +190,7 @@ export async function POST(request: Request) {
const anthropic = new Anthropic({ apiKey });
const response = await anthropic.messages.create({
- model: "claude-3-5-haiku-latest",
+ model: AI_MODEL,
max_tokens: isWizardMode ? 200 : 8000,
// Use content blocks with cache_control for prompt caching
system: [
diff --git a/src/app/api/auth/passkey/authenticate/options/route.ts b/src/app/api/auth/passkey/authenticate/options/route.ts
index be76a18..2a5da9c 100644
--- a/src/app/api/auth/passkey/authenticate/options/route.ts
+++ b/src/app/api/auth/passkey/authenticate/options/route.ts
@@ -1,12 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
import { webAuthnConfig } from "@/lib/auth";
import { prismaUsers } from "@/lib/db-users";
+import { ENABLE_PASSKEYS } from "@/lib/feature-flags";
import {
generateAuthenticationOptions,
type AuthenticatorTransportFuture,
} from "@simplewebauthn/server";
export async function POST(request: NextRequest) {
+ if (!ENABLE_PASSKEYS) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+
try {
const { email } = await request.json();
diff --git a/src/app/api/auth/passkey/authenticate/verify/route.ts b/src/app/api/auth/passkey/authenticate/verify/route.ts
index f18326f..efdd9c9 100644
--- a/src/app/api/auth/passkey/authenticate/verify/route.ts
+++ b/src/app/api/auth/passkey/authenticate/verify/route.ts
@@ -2,9 +2,14 @@ import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions, webAuthnConfig } from "@/lib/auth";
import { prismaUsers } from "@/lib/db-users";
+import { ENABLE_PASSKEYS } from "@/lib/feature-flags";
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
export async function POST(request: NextRequest) {
+ if (!ENABLE_PASSKEYS) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+
try {
const session = await getServerSession(authOptions);
diff --git a/src/app/api/auth/passkey/list/route.ts b/src/app/api/auth/passkey/list/route.ts
index e068119..33d9d0d 100644
--- a/src/app/api/auth/passkey/list/route.ts
+++ b/src/app/api/auth/passkey/list/route.ts
@@ -2,8 +2,13 @@ import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prismaUsers } from "@/lib/db-users";
+import { ENABLE_PASSKEYS } from "@/lib/feature-flags";
export async function GET() {
+ if (!ENABLE_PASSKEYS) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+
try {
const session = await getServerSession(authOptions);
@@ -35,6 +40,10 @@ export async function GET() {
}
export async function DELETE(request: Request) {
+ if (!ENABLE_PASSKEYS) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+
try {
const session = await getServerSession(authOptions);
diff --git a/src/app/api/auth/passkey/register/options/route.ts b/src/app/api/auth/passkey/register/options/route.ts
index a28074c..f167e5d 100644
--- a/src/app/api/auth/passkey/register/options/route.ts
+++ b/src/app/api/auth/passkey/register/options/route.ts
@@ -2,12 +2,17 @@ import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions, webAuthnConfig } from "@/lib/auth";
import { prismaUsers } from "@/lib/db-users";
+import { ENABLE_PASSKEYS } from "@/lib/feature-flags";
import {
generateRegistrationOptions,
type AuthenticatorTransportFuture,
} from "@simplewebauthn/server";
export async function POST() {
+ if (!ENABLE_PASSKEYS) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+
try {
const session = await getServerSession(authOptions);
diff --git a/src/app/api/auth/passkey/register/verify/route.ts b/src/app/api/auth/passkey/register/verify/route.ts
index df81ceb..ee909a0 100644
--- a/src/app/api/auth/passkey/register/verify/route.ts
+++ b/src/app/api/auth/passkey/register/verify/route.ts
@@ -2,17 +2,21 @@ import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions, webAuthnConfig } from "@/lib/auth";
import { prismaUsers } from "@/lib/db-users";
+import { ENABLE_PASSKEYS } from "@/lib/feature-flags";
import { verifyRegistrationResponse } from "@simplewebauthn/server";
-// SECURITY: Sanitize user input to prevent XSS
function sanitizeString(input: string, maxLength: number = 100): string {
return input
- .replace(/[<>'"&]/g, "") // Remove potentially dangerous characters
+ .replace(/[<>'"&]/g, "")
.trim()
- .slice(0, maxLength); // Limit length
+ .slice(0, maxLength);
}
export async function POST(request: NextRequest) {
+ if (!ENABLE_PASSKEYS) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+
try {
const session = await getServerSession(authOptions);
diff --git a/src/app/api/auth/sso/initiate/route.ts b/src/app/api/auth/sso/initiate/route.ts
index 3e9a69b..317cf79 100644
--- a/src/app/api/auth/sso/initiate/route.ts
+++ b/src/app/api/auth/sso/initiate/route.ts
@@ -1,21 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { prismaUsers } from "@/lib/db-users";
+import { ENABLE_SSO } from "@/lib/feature-flags";
/**
* POST /api/auth/sso/initiate - Initiate SSO authentication
- * Body: { teamSlug: string, callbackUrl: string }
- *
- * This endpoint handles the SSO flow initiation:
- * - SAML: Generates AuthnRequest and redirects to IdP
- * - OIDC: Redirects to authorization endpoint
- * - LDAP: Returns form for username/password (handled client-side)
- *
- * TODO: Implement actual SSO provider integrations:
- * - SAML: Use @node-saml/node-saml
- * - OIDC: Use openid-client or NextAuth OIDC provider
- * - LDAP: Use ldapjs for direct authentication
*/
export async function POST(request: NextRequest) {
+ if (!ENABLE_SSO) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+
try {
const { teamSlug, callbackUrl } = await request.json();
diff --git a/src/app/api/auth/sso/lookup/route.ts b/src/app/api/auth/sso/lookup/route.ts
index 92fd06c..8aae845 100644
--- a/src/app/api/auth/sso/lookup/route.ts
+++ b/src/app/api/auth/sso/lookup/route.ts
@@ -1,15 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { prismaUsers } from "@/lib/db-users";
+import { ENABLE_SSO } from "@/lib/feature-flags";
/**
* POST /api/auth/sso/lookup - Check if an email domain has SSO configured
- * Body: { email: string }
- *
- * Returns:
- * - { hasSSO: false } if no SSO for this domain
- * - { hasSSO: true, teamSlug, teamName, provider } if SSO is configured
*/
export async function POST(request: NextRequest) {
+ if (!ENABLE_SSO) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+
try {
const { email } = await request.json();
diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx
index b2b14a4..ecd4f42 100644
--- a/src/app/auth/signin/page.tsx
+++ b/src/app/auth/signin/page.tsx
@@ -8,12 +8,21 @@ import { Button } from "@/components/ui/button";
import { Mail, Github, Chrome, ArrowLeft, Loader2, Terminal, CheckCircle, Building2, KeyRound } from "lucide-react";
import { Logo } from "@/components/logo";
import { Turnstile } from "@/components/turnstile";
+import { useFeatureFlags } from "@/components/providers/feature-flags-provider";
function SignInContent() {
const searchParams = useSearchParams();
const error = searchParams.get("error");
const cliSession = searchParams.get("cli_session");
const { data: session, status } = useSession();
+ const {
+ enableGithubOAuth,
+ enableGoogleOAuth,
+ enableEmailAuth,
+ enableTurnstile,
+ enableSSO,
+ enableUserRegistration,
+ } = useFeatureFlags();
// ALL useState hooks must be declared before any conditional returns (React rules of hooks)
const [cliAuthComplete, setCliAuthComplete] = useState(false);
@@ -134,8 +143,7 @@ function SignInContent() {
return "/dashboard"; // Default safe redirect
})();
- // Turnstile is always enabled for magic link (component handles bypass internally)
- const turnstileEnabled = true;
+ const turnstileEnabled = enableTurnstile;
const handleMagicLink = async (e: React.FormEvent) => {
e.preventDefault();
@@ -325,9 +333,11 @@ function SignInContent() {
{error === "OAuthAccountNotLinked"
? "This email is already associated with another account."
- : error === "Configuration"
- ? "Server configuration error. Please try again later."
- : "An error occurred. Please try again."}
+ : error === "RegistrationDisabled"
+ ? "New account registration is currently disabled. Only existing users can sign in."
+ : error === "Configuration"
+ ? "Server configuration error. Please try again later."
+ : "An error occurred. Please try again."}
)}
@@ -340,47 +350,53 @@ function SignInContent() {
{/* OAuth Buttons */}
-
handleOAuth("github")}
- disabled={isLoading}
- >
- {loadingProvider === "github" ? (
-
- ) : (
-
- )}
- Continue with GitHub
-
-
handleOAuth("google")}
- disabled={isLoading}
- >
- {loadingProvider === "google" ? (
-
- ) : (
-
- )}
- Continue with Google
-
+ {enableGithubOAuth && (
+
handleOAuth("github")}
+ disabled={isLoading}
+ >
+ {loadingProvider === "github" ? (
+
+ ) : (
+
+ )}
+ Continue with GitHub
+
+ )}
+ {enableGoogleOAuth && (
+
handleOAuth("google")}
+ disabled={isLoading}
+ >
+ {loadingProvider === "google" ? (
+
+ ) : (
+
+ )}
+ Continue with Google
+
+ )}
{/* Teams SSO */}
-
setShowSSO(!showSSO)}
- disabled={isLoading}
- >
-
- Teams SSO
- Enterprise
-
+ {enableSSO && (
+
setShowSSO(!showSSO)}
+ disabled={isLoading}
+ >
+
+ Teams SSO
+ Enterprise
+
+ )}
{/* SSO Expanded Section */}
- {showSSO && (
+ {enableSSO && showSSO && (
Enter your work email to sign in with your organization's SSO.
@@ -450,15 +466,17 @@ function SignInContent() {
)}
- {/* Divider */}
-
+ {/* Divider - only show if both OAuth and email are enabled */}
+ {(enableGithubOAuth || enableGoogleOAuth || enableSSO) && enableEmailAuth && (
+
+ )}
{/* Magic Link */}
-