diff --git a/.env.example b/.env.example index 284f267..287ea44 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,15 @@ DROPS_GUEST_COOKIE_SECRET= # May equal DROPS_GUEST_COOKIE_SECRET during an MVP, but should be independently rotated in production. DROPS_ACCOUNT_COOKIE_SECRET= +# Google OIDC profile sign-in. Register the exact callback +# https:///api/auth/google/callback in Google Cloud Console. +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +# Independent 32+ byte AES-GCM key for encrypted account-scoped connection envelopes. +# Never reuse a public capability secret and never expose this value to the browser. +DROPS_CONNECTION_VAULT_KEY= + # Stable 32+ byte HMAC secret for browser-held publish management capabilities. # Rotating it makes existing managed public links read-only in their original browsers. DROPS_PUBLISH_CAPABILITY_SECRET= diff --git a/README.md b/README.md index 885a723..a87b227 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ must not be used to restore an older editor layout. - 12 distinct standalone crypto product runtimes - platform-funded guest and signed-in member AI planning with explicit daily quotas and a deterministic no-key fallback compiler - OpenRouter PKCE sign-in with an HttpOnly Studio identity while the provider key remains session-only +- Google OIDC profile sign-in with private project sync and opt-in encrypted, + account-scoped connection restoration; guests remain session-only - bounded design enhancement through OpenAI, Anthropic, OpenRouter Free, Kimi or a custom OpenAI-compatible model - universal Experience Director for every recipe: archetype, layout, data view, engagement loop, audience, primary loop and editable product modules diff --git a/app/api/account/connections/route.ts b/app/api/account/connections/route.ts new file mode 100644 index 0000000..0e4198a --- /dev/null +++ b/app/api/account/connections/route.ts @@ -0,0 +1,117 @@ +import { NextRequest, NextResponse } from "next/server.js"; + +import { + deleteStudioConnection, + saveStudioConnection, + StudioAccountStateUnavailableError, +} from "@/db/studio-account-state"; +import { + resolveStudioAccount, + STUDIO_ACCOUNT_COOKIE, +} from "@/lib/access-tier"; +import { consumeRequestLimit, requestIdentity } from "@/lib/request-rate-limit"; +import { + connectionVaultConfigured, + isStudioConnectionProvider, + publicConnectionStatuses, +} from "@/lib/studio-account-state"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const MAX_BODY_BYTES = 40 * 1_024; + +function response(payload: unknown, status = 200) { + return NextResponse.json(payload, { status, headers: { "cache-control": "no-store" } }); +} + +function sameOrigin(request: NextRequest): boolean { + if (request.headers.get("sec-fetch-site")?.toLowerCase() === "cross-site") { + return false; + } + const origin = request.headers.get("origin"); + if (!origin) return false; + try { + const host = request.headers.get("host")?.split(",")[0]?.trim(); + const protocol = + request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim().replace(/:$/, "") + || request.nextUrl.protocol.replace(/:$/, ""); + const visibleOrigin = host ? `${protocol}://${host}` : request.nextUrl.origin; + const parsedOrigin = new URL(origin).origin; + return parsedOrigin === request.nextUrl.origin || parsedOrigin === visibleOrigin; + } catch { + return false; + } +} + +async function body(request: NextRequest): Promise | null> { + const declared = Number(request.headers.get("content-length") ?? 0); + if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) return null; + const raw = await request.text().catch(() => ""); + if (new TextEncoder().encode(raw).byteLength > MAX_BODY_BYTES) return null; + try { + const parsed = JSON.parse(raw) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as Record + : null; + } catch { + return null; + } +} + +function account(request: NextRequest) { + return resolveStudioAccount(request.cookies.get(STUDIO_ACCOUNT_COOKIE)?.value); +} + +export async function PUT(request: NextRequest) { + const actor = account(request); + if (!actor) return response({ error: "Sign in to remember this connection." }, 401); + if (!sameOrigin(request)) return response({ error: "Cross-origin connection storage rejected." }, 403); + if (!connectionVaultConfigured()) return response({ error: "Encrypted connection vault is not configured." }, 503); + const limit = await consumeRequestLimit({ + identity: requestIdentity(request), + namespace: "account-connection-write", + max: 20, + windowMs: 10 * 60 * 1_000, + }).catch(() => "unavailable" as const); + if (limit === "limited") return response({ error: "Too many connection changes. Try again later." }, 429); + if (limit === "unavailable" && process.env.NODE_ENV === "production") { + return response({ error: "Connection write protection is temporarily unavailable." }, 503); + } + const input = await body(request); + if (!input || !isStudioConnectionProvider(input.provider) || typeof input.credential !== "string") { + return response({ error: "Connection payload is invalid." }, 400); + } + try { + const state = await saveStudioConnection(actor.identity, { + provider: input.provider, + credential: input.credential, + ...(typeof input.model === "string" ? { model: input.model } : {}), + ...(typeof input.endpoint === "string" ? { endpoint: input.endpoint } : {}), + ...(typeof input.label === "string" ? { label: input.label } : {}), + }); + return response({ saved: true, connections: publicConnectionStatuses(state) }); + } catch (error) { + return response( + { error: error instanceof Error ? error.message : "Connection could not be stored." }, + error instanceof StudioAccountStateUnavailableError ? 503 : 400, + ); + } +} + +export async function DELETE(request: NextRequest) { + const actor = account(request); + if (!actor) return response({ error: "Sign in to change remembered connections." }, 401); + if (!sameOrigin(request)) return response({ error: "Cross-origin connection storage rejected." }, 403); + const provider = request.nextUrl.searchParams.get("provider"); + if (!isStudioConnectionProvider(provider)) return response({ error: "Connection provider is invalid." }, 400); + try { + const state = await deleteStudioConnection(actor.identity, provider); + return response({ deleted: true, connections: publicConnectionStatuses(state) }); + } catch (error) { + return response( + { error: error instanceof Error ? error.message : "Connection could not be removed." }, + error instanceof StudioAccountStateUnavailableError ? 503 : 400, + ); + } +} diff --git a/app/api/account/route.ts b/app/api/account/route.ts new file mode 100644 index 0000000..ab0e5fa --- /dev/null +++ b/app/api/account/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server.js"; + +import { + readStudioAccountState, + StudioAccountStateUnavailableError, +} from "@/db/studio-account-state"; +import { + resolveStudioAccount, + STUDIO_ACCOUNT_COOKIE, +} from "@/lib/access-tier"; +import { + connectionVaultConfigured, + publicConnectionStatuses, +} from "@/lib/studio-account-state"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function GET(request: NextRequest) { + const account = resolveStudioAccount(request.cookies.get(STUDIO_ACCOUNT_COOKIE)?.value); + if (!account) { + return NextResponse.json( + { authenticated: false, profile: null, connections: [] }, + { headers: { "cache-control": "no-store" } }, + ); + } + try { + const state = await readStudioAccountState(account.identity); + return NextResponse.json( + { + authenticated: true, + account: { provider: account.provider }, + profile: state.profile ?? { + provider: account.provider, + name: account.provider === "google" ? "Google member" : "OpenRouter member", + }, + connections: publicConnectionStatuses(state), + vault: { available: connectionVaultConfigured() }, + }, + { headers: { "cache-control": "no-store" } }, + ); + } catch (error) { + const unavailable = error instanceof StudioAccountStateUnavailableError; + return NextResponse.json( + { + authenticated: true, + account: { provider: account.provider }, + profile: null, + connections: [], + vault: { available: false }, + error: unavailable ? error.message : "Studio account state could not be read.", + }, + { status: 503, headers: { "cache-control": "no-store" } }, + ); + } +} diff --git a/app/api/agent/plan/route.ts b/app/api/agent/plan/route.ts index b09cc1a..572be57 100644 --- a/app/api/agent/plan/route.ts +++ b/app/api/agent/plan/route.ts @@ -26,6 +26,7 @@ import { RequestBodyBoundaryError, } from "../../../../lib/http-request-boundary.ts"; import { secretFreeRuntimeMessage } from "../../../../lib/project-runtime-adapter.ts"; +import { readStudioConnectionSecret } from "../../../../db/studio-account-state.ts"; export const runtime = "nodejs"; @@ -481,7 +482,11 @@ export async function POST(request: NextRequest) { const account = resolveStudioAccount(request.cookies.get(STUDIO_ACCOUNT_COOKIE)?.value); - const openRouterKey = requestCredential(request, "x-openrouter-key"); + const rememberedProvider = body?.provider === "openrouter" && account + ? await readStudioConnectionSecret(account.identity, "openrouter").catch(() => null) + : null; + const openRouterKey = requestCredential(request, "x-openrouter-key") + || rememberedProvider?.credential; if (openRouterKey) { try { const model = body?.model?.trim() || "openrouter/free"; @@ -500,7 +505,11 @@ export async function POST(request: NextRequest) { } const directProvider = ["openai", "anthropic", "kimi"].includes(body?.provider ?? "") ? body?.provider as DirectProvider : null; - const directKey = requestCredential(request, "x-provider-key"); + const rememberedDirect = directProvider && account + ? await readStudioConnectionSecret(account.identity, directProvider).catch(() => null) + : null; + const directKey = requestCredential(request, "x-provider-key") + || rememberedDirect?.credential; if (directProvider && !directKey) { return NextResponse.json({ error: `Connect ${directProvider} with an API key before using it.` }, { status: 400 }); } diff --git a/app/api/auth/google/callback/route.ts b/app/api/auth/google/callback/route.ts new file mode 100644 index 0000000..d297e4c --- /dev/null +++ b/app/api/auth/google/callback/route.ts @@ -0,0 +1,88 @@ +import { NextRequest, NextResponse } from "next/server.js"; + +import { saveStudioAccountProfile } from "@/db/studio-account-state"; +import { + createStudioAccountCookie, + resolveAccountCookieSecret, + STUDIO_ACCOUNT_COOKIE, +} from "@/lib/access-tier"; +import { + exchangeGoogleAuthorizationCode, + GOOGLE_OIDC_TRANSACTION_COOKIE, + readGoogleOidcTransaction, +} from "@/lib/google-oidc"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +function clearTransaction(response: NextResponse) { + response.cookies.set(GOOGLE_OIDC_TRANSACTION_COOKIE, "", { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + expires: new Date(0), + path: "/api/auth/google", + }); +} + +function redirectError(request: NextRequest, code: string) { + const response = NextResponse.redirect(new URL(`/?auth=${encodeURIComponent(code)}`, request.nextUrl.origin)); + clearTransaction(response); + return response; +} + +export async function GET(request: NextRequest) { + const clientId = process.env.GOOGLE_CLIENT_ID?.trim() ?? ""; + const clientSecret = process.env.GOOGLE_CLIENT_SECRET?.trim() ?? ""; + const signingSecret = resolveAccountCookieSecret(); + if (!clientId || !clientSecret || !signingSecret) return redirectError(request, "google-unavailable"); + const transaction = readGoogleOidcTransaction( + request.cookies.get(GOOGLE_OIDC_TRANSACTION_COOKIE)?.value, + signingSecret, + ); + const state = request.nextUrl.searchParams.get("state") ?? ""; + const code = request.nextUrl.searchParams.get("code") ?? ""; + if (!transaction || state !== transaction.state || !code || code.length > 4_096) { + return redirectError(request, "google-state-invalid"); + } + try { + const identity = await exchangeGoogleAuthorizationCode({ + code, + clientId, + clientSecret, + redirectUri: `${request.nextUrl.origin}/api/auth/google/callback`, + transaction, + }); + const accountCookie = createStudioAccountCookie( + { provider: "google", subject: identity.subject }, + signingSecret, + ); + const account = await import("@/lib/access-tier").then(({ readStudioAccountCookie }) => + readStudioAccountCookie(accountCookie, signingSecret), + ); + if (!account) throw new Error("The signed Studio account could not be created."); + await saveStudioAccountProfile(account.identity, { + provider: "google", + subject: identity.subject, + name: identity.name, + ...(identity.email ? { email: identity.email } : {}), + ...(identity.picture ? { picture: identity.picture } : {}), + }); + const returnTo = new URL(transaction.returnTo, request.nextUrl.origin); + returnTo.searchParams.set("auth", "google-connected"); + const response = NextResponse.redirect(returnTo); + response.cookies.set(STUDIO_ACCOUNT_COOKIE, accountCookie, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + maxAge: 60 * 60 * 24 * 90, + path: "/", + }); + clearTransaction(response); + response.headers.set("cache-control", "no-store"); + return response; + } catch (error) { + console.error("[google-auth] callback failed", error instanceof Error ? error.message : "unknown"); + return redirectError(request, "google-failed"); + } +} diff --git a/app/api/auth/google/start/route.ts b/app/api/auth/google/start/route.ts new file mode 100644 index 0000000..51efc74 --- /dev/null +++ b/app/api/auth/google/start/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server.js"; + +import { resolveAccountCookieSecret } from "@/lib/access-tier"; +import { + createGoogleOidcTransaction, + GOOGLE_OIDC_TRANSACTION_COOKIE, + GOOGLE_OIDC_TRANSACTION_TTL_SECONDS, + googleAuthorizationUrl, + serializeGoogleOidcTransaction, +} from "@/lib/google-oidc"; +import { safeSameOriginReturnPath } from "@/lib/safe-return-to"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function GET(request: NextRequest) { + const clientId = process.env.GOOGLE_CLIENT_ID?.trim() ?? ""; + const secret = resolveAccountCookieSecret(); + if (!clientId || !secret) { + return NextResponse.redirect(new URL("/?auth=google-unavailable", request.nextUrl.origin)); + } + const returnTo = safeSameOriginReturnPath( + request.nextUrl.searchParams.get("returnTo"), + request.nextUrl.origin, + ); + const transaction = createGoogleOidcTransaction(returnTo, request.nextUrl.origin); + const redirectUri = `${request.nextUrl.origin}/api/auth/google/callback`; + const response = NextResponse.redirect(googleAuthorizationUrl({ clientId, redirectUri, transaction })); + response.cookies.set( + GOOGLE_OIDC_TRANSACTION_COOKIE, + serializeGoogleOidcTransaction(transaction, secret), + { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + maxAge: GOOGLE_OIDC_TRANSACTION_TTL_SECONDS, + path: "/api/auth/google", + }, + ); + response.headers.set("cache-control", "no-store"); + response.headers.set("referrer-policy", "no-referrer"); + return response; +} diff --git a/app/api/auth/openrouter/exchange/route.ts b/app/api/auth/openrouter/exchange/route.ts index 400ba97..c3aa3fe 100644 --- a/app/api/auth/openrouter/exchange/route.ts +++ b/app/api/auth/openrouter/exchange/route.ts @@ -3,8 +3,10 @@ import { createStudioAccountCookie, memberProjectSyncReadiness, resolveAccountCookieSecret, + resolveStudioAccount, STUDIO_ACCOUNT_COOKIE, } from "../../../../../lib/access-tier.ts"; +import { saveStudioConnection } from "../../../../../db/studio-account-state.ts"; import { consumeRequestLimit, requestIdentity } from "../../../../../lib/request-rate-limit.ts"; export const runtime = "nodejs"; @@ -90,26 +92,43 @@ export async function POST(request: NextRequest) { ); } - const accountCookie = createStudioAccountCookie({ provider: "openrouter", subject: payload.user_id }, secret); - // The API key is returned once to the initiating browser and is never persisted server-side. + const existingAccount = resolveStudioAccount( + request.cookies.get(STUDIO_ACCOUNT_COOKIE)?.value, + ); + const accountCookie = existingAccount + ? request.cookies.get(STUDIO_ACCOUNT_COOKIE)?.value ?? "" + : createStudioAccountCookie({ provider: "openrouter", subject: payload.user_id }, secret); + const account = existingAccount ?? resolveStudioAccount(accountCookie); + if (account) { + await saveStudioConnection(account.identity, { + provider: "openrouter", + credential: payload.key, + model: "openrouter/free", + label: "OpenRouter OAuth", + }).catch(() => undefined); + } + // The API key is returned once to the initiating browser. For a signed-in + // Studio profile it is also stored only as an AES-GCM encrypted vault entry. const result = NextResponse.json( { key: payload.key, account: { - provider: "openrouter", + provider: account?.provider ?? "openrouter", connected: true, projectSync: memberProjectSyncReadiness(), }, }, { headers: { "cache-control": "no-store" } }, ); - result.cookies.set(STUDIO_ACCOUNT_COOKIE, accountCookie, { - httpOnly: true, - sameSite: "lax", - secure: process.env.NODE_ENV === "production", - maxAge: 60 * 60 * 24 * 90, - path: "/", - }); + if (!existingAccount) { + result.cookies.set(STUDIO_ACCOUNT_COOKIE, accountCookie, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + maxAge: 60 * 60 * 24 * 90, + path: "/", + }); + } return result; } catch (error) { console.error("[openrouter-auth] key exchange failed", error); diff --git a/app/api/builder/agent/route.ts b/app/api/builder/agent/route.ts index 19303a5..6fd3893 100644 --- a/app/api/builder/agent/route.ts +++ b/app/api/builder/agent/route.ts @@ -39,11 +39,11 @@ import { SnapshotBuilderProjectRepository, BuilderRouteError, builderActor, - builderCredentials, builderJson, builderRouteError, consumeBuilderLimit, readBuilderBody, + rememberedBuilderConnection, requireBuilderSameOrigin, } from "../shared.ts"; @@ -152,14 +152,19 @@ export async function handleBuilderAgentRequest( // Approval evidence is resolved server-side. Tool names are intentionally // absent from the public JSON body so a model cannot approve its own call. const approvedTools = await (dependencies.resolveApprovedTools?.(request) ?? []); + const remembered = await rememberedBuilderConnection( + request, + parsed.data.provider, + ); const agentRequest = { ...parsed.data, + provider: remembered.selection, approvedTools: [...approvedTools], }; const agentDependencies = { services: session, audit, - credentials: builderCredentials(request), + credentials: remembered.credentials, deterministicFallback: dependencies.deterministicFallback ?? materializedProjectDeterministicFallback, diff --git a/app/api/builder/shared.ts b/app/api/builder/shared.ts index b7ba6ab..0ab9e16 100644 --- a/app/api/builder/shared.ts +++ b/app/api/builder/shared.ts @@ -1,9 +1,11 @@ import { NextRequest, NextResponse } from "next/server.js"; import { GUEST_IDENTITY_COOKIE, + resolveStudioAccount, resolveStudioProjectActor, STUDIO_ACCOUNT_COOKIE, } from "../../../lib/access-tier.ts"; +import { readStudioConnectionSecret } from "../../../db/studio-account-state.ts"; import { ProjectRuntimeProviderError, ProjectRuntimeUnavailableError, @@ -25,6 +27,7 @@ import type { BuilderAgentAuditSink, BuilderProjectRepository, BuilderProviderCredentials, + BuilderProviderSelection, } from "../../../lib/builder-agent/types.ts"; import { BuilderModelUnavailableError } from "../../../lib/builder-agent/providers.ts"; @@ -236,6 +239,54 @@ export function builderCredentials(request: NextRequest): BuilderProviderCredent }; } +export async function rememberedBuilderConnection( + request: NextRequest, + selection: BuilderProviderSelection, +): Promise<{ + credentials: BuilderProviderCredentials; + selection: BuilderProviderSelection; +}> { + const credentials = builderCredentials(request); + if ( + selection.provider === "free" + || selection.provider === "gateway" + || (selection.provider === "openrouter" && credentials.openRouterKey) + || (["openai", "anthropic", "kimi"].includes(selection.provider) + && credentials.apiKey) + || (selection.provider === "custom" && credentials.apiKey && selection.baseUrl) + ) { + return { credentials, selection }; + } + const account = resolveStudioAccount( + request.cookies.get(STUDIO_ACCOUNT_COOKIE)?.value, + ); + if (!account) return { credentials, selection }; + const provider = selection.provider; + if (!["openai", "anthropic", "openrouter", "kimi", "custom"].includes(provider)) { + return { credentials, selection }; + } + const remembered = await readStudioConnectionSecret( + account.identity, + provider as "openai" | "anthropic" | "openrouter" | "kimi" | "custom", + ).catch(() => null); + if (!remembered) return { credentials, selection }; + return { + credentials: { + ...credentials, + ...(provider === "openrouter" + ? { openRouterKey: credentials.openRouterKey ?? remembered.credential } + : { apiKey: credentials.apiKey ?? remembered.credential }), + }, + selection: { + ...selection, + ...(selection.model ? {} : remembered.model ? { model: remembered.model } : {}), + ...(provider === "custom" && !selection.baseUrl && remembered.endpoint + ? { baseUrl: remembered.endpoint } + : {}), + }, + }; +} + export function builderRouteError(error: unknown) { if (error instanceof BuilderRouteError) { return builderJson({ code: error.code, error: error.message }, error.status); diff --git a/app/api/dropstab/route.ts b/app/api/dropstab/route.ts index a899ba6..b817300 100644 --- a/app/api/dropstab/route.ts +++ b/app/api/dropstab/route.ts @@ -1,10 +1,22 @@ import { NextRequest, NextResponse } from "next/server.js"; import { DROPSTAB_MAX_ATTEMPTS, dropsTabErrorHttpStatus, fetchDropsTabIntelligence } from "../../../lib/dropstab-client.ts"; +import { readStudioConnectionSecret } from "../../../db/studio-account-state.ts"; +import { + resolveStudioAccount, + STUDIO_ACCOUNT_COOKIE, +} from "../../../lib/access-tier.ts"; export const dynamic = "force-dynamic"; export async function GET(request: NextRequest) { - const key = request.headers.get("x-dropstab-api-key")?.trim(); + const account = resolveStudioAccount( + request.cookies.get(STUDIO_ACCOUNT_COOKIE)?.value, + ); + const remembered = account + ? await readStudioConnectionSecret(account.identity, "dropstab").catch(() => null) + : null; + const key = request.headers.get("x-dropstab-api-key")?.trim() + || remembered?.credential; if (!key) return NextResponse.json({ error: "A DropsTab API key is required." }, { status: 401 }); try { @@ -22,7 +34,7 @@ export async function GET(request: NextRequest) { maxAttemptsPerRequest: DROPSTAB_MAX_ATTEMPTS, requestBudget: "One required coins request plus up to three independent enrichment requests per explicit refresh.", }, - }, { headers: { "cache-control": "private, no-store", vary: "x-dropstab-api-key" } }); + }, { headers: { "cache-control": "private, no-store", vary: "Cookie, x-dropstab-api-key" } }); } catch (error) { const message = error instanceof Error ? error.message : "DropsTab is temporarily unreachable."; const status = dropsTabErrorHttpStatus(error); diff --git a/app/api/telegram/account/create-channel/route.ts b/app/api/telegram/account/create-channel/route.ts index 987f0ca..4470d31 100644 --- a/app/api/telegram/account/create-channel/route.ts +++ b/app/api/telegram/account/create-channel/route.ts @@ -7,6 +7,11 @@ import { telegramAccountRequestErrorResponse, } from "@/lib/telegram-account-request"; import { consumeRequestLimit, requestIdentity } from "@/lib/request-rate-limit"; +import { readStudioConnectionSecret } from "@/db/studio-account-state"; +import { + resolveStudioAccount, + STUDIO_ACCOUNT_COOKIE, +} from "@/lib/access-tier"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -18,7 +23,15 @@ export async function POST(request: NextRequest) { } catch (error) { return telegramAccountRequestErrorResponse(error); } - const accountToken = typeof body?.accountToken === "string" ? body.accountToken : ""; + const account = resolveStudioAccount( + request.cookies.get(STUDIO_ACCOUNT_COOKIE)?.value, + ); + const remembered = account + ? await readStudioConnectionSecret(account.identity, "telegram").catch(() => null) + : null; + const accountToken = (typeof body?.accountToken === "string" ? body.accountToken : "") + || remembered?.credential + || ""; const requestId = typeof body?.requestId === "string" ? body.requestId : ""; if (!accountToken) return telegramAccountJson({ error: "Connect your Telegram account first." }, 400); if (!/^[a-f0-9-]{16,64}$/i.test(requestId)) return telegramAccountJson({ error: "Start a fresh channel creation request." }, 400); diff --git a/app/api/telegram/account/sign-in/route.ts b/app/api/telegram/account/sign-in/route.ts index 445c906..5b50d45 100644 --- a/app/api/telegram/account/sign-in/route.ts +++ b/app/api/telegram/account/sign-in/route.ts @@ -7,6 +7,11 @@ import { telegramAccountRequestErrorResponse, } from "@/lib/telegram-account-request"; import { consumeRequestLimit, requestIdentity } from "@/lib/request-rate-limit"; +import { saveStudioConnection } from "@/db/studio-account-state"; +import { + resolveStudioAccount, + STUDIO_ACCOUNT_COOKIE, +} from "@/lib/access-tier"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -28,6 +33,16 @@ export async function POST(request: NextRequest) { if (limit === "unavailable") return telegramAccountJson({ error: "Secure Telegram sign-in is temporarily unavailable." }, 503); try { const result = await signInTelegramAccount(flowToken, phoneCode, password); + const account = resolveStudioAccount( + request.cookies.get(STUDIO_ACCOUNT_COOKIE)?.value, + ); + if (account && "accountToken" in result && typeof result.accountToken === "string") { + await saveStudioConnection(account.identity, { + provider: "telegram", + credential: result.accountToken, + label: "Telegram account session", + }).catch(() => undefined); + } return telegramAccountJson(result); } catch (error) { return telegramAccountJson({ error: error instanceof Error ? error.message : "Telegram sign-in failed." }, 422); diff --git a/app/api/telegram/account/status/route.ts b/app/api/telegram/account/status/route.ts index a81d1c9..8921056 100644 --- a/app/api/telegram/account/status/route.ts +++ b/app/api/telegram/account/status/route.ts @@ -1,6 +1,11 @@ import { NextRequest } from "next/server.js"; import { inspectTelegramAccountToken } from "@/lib/telegram-account"; +import { readStudioConnectionSecret } from "@/db/studio-account-state"; +import { + resolveStudioAccount, + STUDIO_ACCOUNT_COOKIE, +} from "@/lib/access-tier"; import { readTelegramAccountJson, telegramAccountJson, @@ -17,7 +22,15 @@ export async function POST(request: NextRequest) { } catch (error) { return telegramAccountRequestErrorResponse(error); } - const token = typeof body?.accountToken === "string" ? body.accountToken : ""; + const account = resolveStudioAccount( + request.cookies.get(STUDIO_ACCOUNT_COOKIE)?.value, + ); + const remembered = account + ? await readStudioConnectionSecret(account.identity, "telegram").catch(() => null) + : null; + const token = (typeof body?.accountToken === "string" ? body.accountToken : "") + || remembered?.credential + || ""; if (!token) return telegramAccountJson({ connected: false }); try { return telegramAccountJson({ connected: true, account: inspectTelegramAccountToken(token) }); diff --git a/app/styles/drops-studio.dialogs.css b/app/styles/drops-studio.dialogs.css index b644c02..fff0971 100644 --- a/app/styles/drops-studio.dialogs.css +++ b/app/styles/drops-studio.dialogs.css @@ -2,9 +2,10 @@ .connections-dialog, .projects-dialog, .success-dialog { animation: dialog-in .22s ease-out; background: #fff; border: 1px solid rgba(255,255,255,.5); border-radius: 22px; box-shadow: 0 30px 90px rgba(5, 17, 39, .28); max-height: min(760px, calc(100vh - 40px)); overflow: auto; padding: 0; z-index: 90; }@keyframes dialog-in { from { opacity: 0; } } .connections-dialog { max-width: 920px; width: calc(100vw - 40px); }.projects-dialog { max-width: 560px; padding-bottom: 20px; width: calc(100vw - 40px); }.success-dialog { max-width: 470px; padding: 32px; text-align: center; width: calc(100vw - 40px); } .dialog-top { align-items: flex-start; border-bottom: 1px solid var(--line); display: flex; justify-content: space-between; padding: 24px 25px 20px; }.dialog-top > div { display: flex; flex-direction: column; }.dialog-top h2 { font-size: 21px; letter-spacing: -.035em; margin: 0; }.dialog-top p { color: var(--muted); font-size: 12px; line-height: 1.45; margin: 5px 0 0; max-width: 610px; }.dialog-close { align-items: center; background: #f4f6fa; border: 0; border-radius: 9px; cursor: pointer; display: flex; height: 34px; justify-content: center; width: 34px; }.dialog-close svg { height: 17px; width: 17px; } +.studio-account-card { align-items: center; background: #f7f9fc; border-bottom: 1px solid var(--line); display: grid; gap: 11px; grid-template-columns: auto minmax(0, 1fr) auto; padding: 13px 25px; }.studio-account-card.connected { background: linear-gradient(90deg, #f2f7ff, #f8f5ff); }.studio-account-avatar { align-items: center; background: #e9eff8; border: 1px solid #d7e1ef; border-radius: 50%; color: #4a5c75; display: flex; font-size: 13px; font-weight: 800; height: 38px; justify-content: center; width: 38px; }.studio-account-card.connected .studio-account-avatar { background: linear-gradient(135deg, #265ee9, #7c4dff); border-color: transparent; color: #fff; }.studio-account-avatar svg { height: 18px; width: 18px; }.studio-account-card > div { display: flex; flex-direction: column; min-width: 0; }.studio-account-card strong { color: #1f304b; font-size: 14px; }.studio-account-card small { color: #5b6b82; font-size: 12px; margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.studio-account-card > button { align-items: center; background: #fff; border: 1px solid #d5dfed; border-radius: 9px; color: #334660; cursor: pointer; display: flex; font-size: 12px; font-weight: 700; gap: 6px; min-height: 38px; padding: 0 12px; }.studio-account-card > button:hover { border-color: #a9bded; color: var(--blue); }.studio-account-card > button svg { height: 15px; width: 15px; } .connection-layout { display: grid; grid-template-columns: 250px 1fr; min-height: 470px; }.provider-list { background: #f7f9fc; border-right: 1px solid var(--line); display: flex; flex-direction: column; gap: 4px; padding: 15px; }.provider-list button { align-items: center; background: transparent; border: 1px solid transparent; border-radius: 11px; cursor: pointer; display: flex; gap: 10px; min-height: 51px; padding: 7px 9px; text-align: left; }.provider-list button:hover { background: white; }.provider-list button.active { background: white; border-color: #c9d7f3; box-shadow: 0 6px 15px rgba(45, 70, 110, .07); }.provider-list button > span { align-items: center; background: #edf1f7; border-radius: 8px; display: flex; height: 33px; justify-content: center; width: 33px; }.provider-list button.active > span { background: #eaf1ff; color: var(--blue); }.provider-list button > span svg { height: 16px; width: 16px; }.provider-list button > div { display: flex; flex: 1; flex-direction: column; }.provider-list strong { font-size: 12px; }.provider-list small { color: #8590a1; font-size: 12px; margin-top: 2px; }.provider-check { color: var(--green); height: 14px; width: 14px; } .provider-detail { padding: 35px 38px; }.detail-icon { align-items: center; background: #ebf2ff; border-radius: 14px; color: var(--blue); display: flex; height: 55px; justify-content: center; width: 55px; }.detail-copy > span { color: var(--blue); display: block; font-size: 12px; font-weight: 780; letter-spacing: .1em; margin-top: 18px; text-transform: uppercase; }.detail-copy h3 { font-size: 25px; letter-spacing: -.04em; margin: 5px 0 8px; }.detail-copy p { color: var(--muted); font-size: 12px; line-height: 1.55; margin: 0 0 21px; max-width: 480px; }.key-field { display: block; margin-top: 12px; }.key-field > span { color: #56647a; display: block; font-size: 12px; font-weight: 680; margin: 0 0 6px 2px; }.key-field > div { align-items: center; border: 1px solid #dce3ef; border-radius: 10px; display: flex; gap: 8px; padding: 0 11px; }.key-field input { border: 1px solid #dce3ef; border-radius: 10px; font-size: 12px; height: 43px; outline: 0; padding: 0 12px; width: 100%; }.key-field > div input { border: 0; padding: 0; }.key-field > div svg { color: #8d98a9; flex: 0 0 auto; }.privacy-note { align-items: flex-start; background: #f5f8fd; border-radius: 10px; color: #65748b; display: flex; gap: 8px; margin-top: 16px; padding: 11px; }.privacy-note svg { color: var(--green); flex: 0 0 auto; }.privacy-note p { font-size: 12px; line-height: 1.45; margin: 0; }.privacy-note strong { color: #35455d; }.provider-detail-actions { align-items: center; display: flex; justify-content: space-between; margin-top: 23px; }.provider-detail-actions a { align-items: center; color: #64728a; display: flex; font-size: 12px; gap: 4px; text-decoration: none; }.provider-detail-actions button { align-items: center; background: var(--blue); border: 0; border-radius: 9px; color: white; cursor: pointer; display: flex; font-size: 12px; font-weight: 680; gap: 6px; min-height: 39px; padding: 0 14px; }.provider-detail-actions button:disabled { opacity: .62; } -.project-list { display: grid; gap: 8px; padding: 17px 20px; }.project-list button { align-items: center; background: white; border: 1px solid var(--line); border-radius: 12px; cursor: pointer; display: flex; gap: 11px; padding: 12px; text-align: left; }.project-list button:hover { border-color: #b8caff; }.project-list button > span { align-items: center; background: #edf3ff; border-radius: 9px; color: var(--blue); display: flex; height: 37px; justify-content: center; width: 37px; }.project-list button > div { display: flex; flex: 1; flex-direction: column; }.project-list strong { font-size: 12px; }.project-list small { color: #8490a3; font-size: 12px; margin-top: 4px; }.project-list button > svg { color: #8d98a9; height: 16px; width: 16px; }.empty-projects { align-items: center; color: #8190a5; display: flex; flex-direction: column; padding: 55px 20px; text-align: center; }.empty-projects > svg { color: #aebbd0; height: 35px; width: 35px; }.empty-projects strong { color: #34445d; font-size: 14px; margin-top: 13px; }.empty-projects p { font-size: 12px; line-height: 1.5; max-width: 260px; } +.project-list { display: grid; gap: 8px; padding: 17px 20px; }.project-list-row { align-items: stretch; display: grid; gap: 8px; grid-template-columns: minmax(0, 1fr) 44px; }.project-open-button { align-items: center; background: white; border: 1px solid var(--line); border-radius: 12px; cursor: pointer; display: flex; gap: 11px; min-height: 58px; padding: 12px; text-align: left; }.project-open-button:hover { border-color: #b8caff; }.project-open-button > span { align-items: center; background: #edf3ff; border-radius: 9px; color: var(--blue); display: flex; flex: 0 0 auto; height: 37px; justify-content: center; width: 37px; }.project-open-button > div { display: flex; flex: 1; flex-direction: column; min-width: 0; }.project-list strong { font-size: 14px; }.project-list small { color: #71809a; font-size: 12px; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.project-open-button > svg { color: #71809a; flex: 0 0 auto; height: 17px; width: 17px; }.project-delete-button { align-items: center; align-self: stretch; background: #fff; border: 1px solid var(--line); border-radius: 12px; color: #8d98a9; cursor: pointer; display: flex; justify-content: center; min-height: 44px; min-width: 44px; }.project-delete-button:hover { background: #fff2f3; border-color: #efb8bf; color: var(--danger); }.project-delete-button svg,.delete-all-projects svg { height: 17px; width: 17px; }.delete-all-projects { align-items: center; background: transparent; border: 0; border-radius: 10px; color: #a33745; cursor: pointer; display: inline-flex; font-size: 14px; font-weight: 700; gap: 8px; justify-content: center; justify-self: end; min-height: 44px; padding: 0 10px; }.delete-all-projects:hover { background: #fff2f3; }.empty-projects { align-items: center; color: #71809a; display: flex; flex-direction: column; padding: 55px 20px; text-align: center; }.empty-projects > svg { color: #aebbd0; height: 35px; width: 35px; }.empty-projects strong { color: #34445d; font-size: 14px; margin-top: 13px; }.empty-projects p { font-size: 14px; line-height: 1.5; max-width: 300px; } .project-storage-status { align-items: center; background: #f2f5fa; border: 1px solid #dce5f2; border-radius: 999px; color: #52617a; display: inline-flex; font-size: 12px; font-weight: 700; gap: 6px; margin-top: 12px; min-height: 32px; padding: 4px 10px; }.project-storage-status svg { height: 14px; width: 14px; }.project-storage-status.synced { background: #eaf8f1; border-color: #bfe8d5; color: #087449; } .success-icon { align-items: center; background: #e8f8f0; border-radius: 50%; color: var(--green); display: flex; height: 58px; justify-content: center; margin: 0 auto 17px; width: 58px; }.success-dialog h2 { font-size: 24px; letter-spacing: -.04em; margin: 0; }.success-dialog > p { color: var(--muted); font-size: 12px; line-height: 1.55; margin: 9px auto 20px; max-width: 355px; }.success-summary { display: grid; gap: 7px; grid-template-columns: repeat(3, 1fr); margin-bottom: 19px; }.success-summary span { align-items: center; background: #f6f8fc; border-radius: 9px; color: #536178; display: flex; flex-direction: column; font-size: 12px; gap: 6px; padding: 10px 5px; }.success-summary svg { color: var(--blue); height: 17px; width: 17px; }.success-primary, .success-secondary { align-items: center; border-radius: 10px; cursor: pointer; display: flex; font-size: 12px; font-weight: 670; gap: 6px; height: 45px; justify-content: center; width: 100%; }.success-primary { background: var(--blue); border: 0; color: white; }.success-secondary { background: white; border: 1px solid var(--line); color: #536178; margin-top: 8px; }.success-primary svg, .success-secondary svg { height: 15px; width: 15px; } .toast { align-items: center; background: #0e1a33; border: 1px solid rgba(255,255,255,.1); border-radius: 11px; bottom: 25px; box-shadow: 0 15px 40px rgba(8, 18, 38, .25); color: white; display: flex; font-size: 12px; gap: 8px; left: 50%; max-width: calc(100vw - 32px); opacity: 0; padding: 12px 15px; pointer-events: none; position: fixed; transform: translate(-50%, 12px); transition: .22s; z-index: 120; }.toast.visible { opacity: 1; transform: translate(-50%, 0); } diff --git a/app/styles/drops-studio.responsive.css b/app/styles/drops-studio.responsive.css index 349362a..e6a31d0 100644 --- a/app/styles/drops-studio.responsive.css +++ b/app/styles/drops-studio.responsive.css @@ -15,6 +15,7 @@ .studio-header nav button:hover, .studio-header nav a:hover { background: #f3f6fb; } .studio-header nav .mobile-nav-connections { display: flex; } .header-actions { min-width: 0; } + .account-profile-button > span:last-child { display: none; } .mobile-menu { display: flex; } .api-vault-button { display: none; } .studio-grid { display: flex; flex-direction: column; padding: 52px 22px 72px; } @@ -74,6 +75,8 @@ .preview-action { min-width: 28%; } .studio-footer { gap: 26px; } .connection-layout { display: block; } + .studio-account-card { align-items: flex-start; grid-template-columns: auto minmax(0, 1fr); padding: 13px 16px; } + .studio-account-card > button { grid-column: 1 / -1; justify-content: center; width: 100%; } .provider-list { border-bottom: 1px solid var(--line); border-right: 0; flex-direction: row; overflow-x: auto; } .provider-list button { flex: 0 0 145px; } .provider-detail { padding: 25px 19px; } diff --git a/app/styles/drops-studio.shell.css b/app/styles/drops-studio.shell.css index 4b51221..2b28a5a 100644 --- a/app/styles/drops-studio.shell.css +++ b/app/styles/drops-studio.shell.css @@ -28,7 +28,13 @@ .studio-header nav button:hover, .studio-header nav a:hover { color: var(--blue); } .studio-header nav button span { align-items: center; background: #eaf0fb; border-radius: 99px; display: inline-flex; font-size: 12px; height: 18px; justify-content: center; min-width: 18px; } .studio-header nav .mobile-nav-connections { display: none; } -.header-actions { align-items: center; display: flex; justify-content: flex-end; min-width: 218px; } +.header-actions { align-items: center; display: flex; gap: 8px; justify-content: flex-end; min-width: 218px; } +.account-profile-button { align-items: center; background: transparent; border: 0; border-radius: 11px; color: #42516a; cursor: pointer; display: flex; font-size: 13px; font-weight: 650; gap: 8px; max-width: 150px; min-height: 40px; padding: 4px 8px; } +.account-profile-button:hover { background: #f1f5fb; color: var(--blue); } +.account-profile-button > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.account-avatar { align-items: center; background: #edf2fa; border: 1px solid #d9e3f1; border-radius: 50%; color: #4a5b75; display: flex; flex: 0 0 auto; font-size: 12px; height: 31px; justify-content: center; width: 31px; } +.account-profile-button.connected .account-avatar { background: linear-gradient(135deg, #265ee9, #7c4dff); border-color: transparent; color: white; } +.account-avatar svg { height: 16px; width: 16px; } .api-vault-button { align-items: center; background: #fff; border: 1px solid #dbe3ef; border-radius: 11px; cursor: pointer; display: flex; font-size: 13px; font-weight: 650; gap: 7px; padding: 10px 14px; box-shadow: 0 5px 16px rgba(40, 60, 100, .05); } .api-vault-button:hover { border-color: #adc4ff; color: var(--blue); } .mobile-menu { background: transparent; border: 0; cursor: pointer; display: none; padding: 7px; } diff --git a/app/styles/project-studio.chrome.css b/app/styles/project-studio.chrome.css index 17b0b88..927db37 100644 --- a/app/styles/project-studio.chrome.css +++ b/app/styles/project-studio.chrome.css @@ -33,6 +33,10 @@ .workspace-actions button svg { height: 13px; width: 13px; } .workspace-actions .publish-top { background: linear-gradient(135deg,#3c79ff,#2462ed); border-color: transparent; box-shadow: 0 7px 20px rgba(49,108,255,.24); color: white; } .workspace-actions .publish-top svg:last-child { border-left: 1px solid rgba(255,255,255,.25); box-sizing: content-box; margin-left: 3px; padding-left: 7px; } +.workspace-actions .workspace-account-action { gap: 8px; max-width: 170px; padding: 0 10px 0 5px; } +.workspace-account-action > span:last-child { max-width: 108px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.workspace-account-avatar { align-items: center; background: linear-gradient(145deg,#e8f3ff,#dce8ff); border: 1px solid #c4d6f6; border-radius: 9px; color: #245fdf; display: flex; flex: 0 0 auto; font-size: 12px; font-weight: 820; height: 32px; justify-content: center; width: 32px; } +.workspace-account-avatar svg { height: 15px!important; width: 15px!important; } .project-studio-layout { display: grid; diff --git a/app/styles/project-studio.responsive.css b/app/styles/project-studio.responsive.css index 828245b..81acbf2 100644 --- a/app/styles/project-studio.responsive.css +++ b/app/styles/project-studio.responsive.css @@ -24,6 +24,14 @@ padding-inline: 10px; } + .workspace-actions .workspace-account-action > span:last-child { + display: none; + } + + .workspace-actions .workspace-account-action { + padding: 0 5px; + } + .stage-toolbar button { min-width: 44px; } diff --git a/app/styles/project-studio.runtime.css b/app/styles/project-studio.runtime.css index 536559c..a33646e 100644 --- a/app/styles/project-studio.runtime.css +++ b/app/styles/project-studio.runtime.css @@ -29,6 +29,10 @@ .assistant-guide > strong { color: #253b61; font-size: 12px; }.assistant-guide > p { color: #718099; font-size: 12px; line-height: 1.45; margin: -1px 0 3px; }.assistant-guide > span { align-items: center; background: white; border: 1px solid #e5eaf3; border-radius: 8px; display: grid; gap: 2px 7px; grid-template-columns: 24px 1fr; padding: 7px; }.assistant-guide > span svg { color: #6656de; grid-row: span 2; height: 12px; justify-self: center; width: 12px; }.assistant-guide > span b { color: #465c7a; font-size: 12px; }.assistant-guide > span small { color: #8b97a8; font-size: 12px; } .conversation article { display: grid; gap: 6px; margin-bottom: 14px; } .conversation article > span { align-items: center; color: #728198; display: flex; font-size: 12px; font-weight: 780; gap: 5px; text-transform: uppercase; }.conversation article > span svg { color: #6c56dc; height: 11px; width: 11px; }.conversation article.user > span { justify-content: flex-end; }.conversation article > p { background: #f4f6f9; border-radius: 4px 11px 11px 11px; color: #42556f; font-size: 12px; line-height: 1.55; margin: 0; padding: 9px 10px; }.conversation article.user > p { background: #316cff; border-radius: 11px 4px 11px 11px; color: white; justify-self: end; max-width: 88%; }.conversation article.thinking > p { color: #718099; font-style: italic; } +.conversation article.build-event { grid-template-columns: 26px minmax(0,1fr); gap: 8px; margin: 8px 0; } +.conversation article.build-event > span { align-items: center; background: #edf3ff; border: 1px solid #ccdcfa; border-radius: 8px; color: #316cff; grid-column: 1; height: 26px; justify-content: center; overflow: hidden; width: 26px; } +.conversation article.build-event > span svg { height: 12px; width: 12px; } +.conversation article.build-event > p { align-self: center; background: transparent; border: 0; color: #5f7088; grid-column: 2; padding: 1px 0; } .proposal-card { border: 1px solid #cbd8f2; border-radius: 11px; box-shadow: 0 7px 18px rgba(43,68,108,.06); overflow: hidden; }.proposal-card > header { align-items: center; background: #f3f6fd; border-bottom: 1px solid #dae3f3; display: flex; justify-content: space-between; padding: 9px; }.proposal-card > header span { align-items: center; display: flex; gap: 6px; }.proposal-card > header svg { color: #684de1; height: 12px; width: 12px; }.proposal-card > header strong { font-size: 12px; }.proposal-card > header b { background: #eeeaff; border-radius: 99px; color: #684de1; font-size: 12px; padding: 4px 6px; }.proposal-card ul { display: grid; gap: 6px; list-style: none; margin: 0; padding: 10px; }.proposal-card li { align-items: flex-start; color: #5a6d85; display: flex; font-size: 12px; gap: 6px; line-height: 1.4; }.proposal-card li svg { color: #1aa26e; flex: 0 0 auto; height: 10px; width: 10px; }.proposal-card > div { border-top: 1px solid #e6ebf2; display: flex; gap: 7px; justify-content: flex-end; padding: 8px; }.proposal-card > div button { align-items: center; background: white; border: 1px solid #d8e1ed; border-radius: 7px; color: #65758b; display: flex; font-size: 12px; font-weight: 720; gap: 5px; min-height: 28px; padding: 0 9px; }.proposal-card > div button:last-child { background: var(--ps-blue); border-color: var(--ps-blue); color: white; }.proposal-card > div svg { height: 10px; width: 10px; } .quick-prompts { border-top: 1px solid #eef1f5; display: flex; flex: 0 0 auto; gap: 5px; overflow-x: auto; padding: 8px 10px 5px; }.quick-prompts button { background: #f4f6fa; border: 1px solid #e0e6ee; border-radius: 99px; color: #65758a; flex: 0 0 auto; font-size: 12px; min-height: 25px; padding: 5px 8px; }.quick-prompts button:hover { border-color: #9eb7e5; color: #2e63cc; } .chat-composer { border: 1px solid #cfd9e7; border-radius: 12px; box-shadow: 0 8px 20px rgba(36,58,92,.08); margin: 7px 10px 8px; overflow: hidden; }.chat-composer textarea { border: 0; color: #213550; font-size: 12px; outline: none; padding: 10px; resize: none; width: 100%; }.chat-composer footer { align-items: center; background: #fafbfd; border-top: 1px solid #eef1f5; display: flex; justify-content: space-between; padding: 6px 7px; }.chat-composer footer span { align-items: center; color: #7f8da0; display: flex; font-size: 12px; gap: 5px; }.chat-composer footer span svg { color: #7658e8; height: 10px; width: 10px; }.chat-composer footer button { align-items: center; background: var(--ps-blue); border: 0; border-radius: 7px; color: white; display: flex; height: 29px; justify-content: center; width: 29px; }.chat-composer footer button svg { height: 12px; width: 12px; } diff --git a/components/drops-studio-dialogs.tsx b/components/drops-studio-dialogs.tsx index 9b2f97d..73a10df 100644 --- a/components/drops-studio-dialogs.tsx +++ b/components/drops-studio-dialogs.tsx @@ -13,10 +13,13 @@ import { Database, ExternalLink, LoaderCircle, + LogOut, LockKeyhole, Rocket, Save, Sparkles, + Trash2, + UserRound, X, } from "lucide-react"; import { ProviderModelPicker } from "@/components/provider-model-picker"; @@ -34,6 +37,10 @@ import { type ProviderModelCatalog, } from "@/lib/provider-models"; import type { GeneratedProject } from "@/lib/project-types"; +import { + studioAccountDisplayName, + studioAccountInitial, +} from "@/lib/studio-account-profile"; type ProviderId = | "free" @@ -79,10 +86,20 @@ interface DropsStudioDialogsProps { onCustomEndpointChange: (value: string) => void; onConnectOpenRouter: () => void; memberConnected: boolean; + accountProfile: { + provider: "google" | "openrouter"; + name: string; + email?: string; + picture?: string; + } | null; + onSignInGoogle: () => void; + onSignOut: () => void; projectSyncAvailable: boolean; onDisconnectOpenRouter: () => void; onConnectProvider: () => void; onOpenProject: (id: string) => void; + onDeleteProject: (project: GeneratedProject) => Promise; + onDeleteAllProjects: () => Promise; } export function DropsStudioDialogs({ @@ -109,11 +126,17 @@ export function DropsStudioDialogs({ onCustomEndpointChange, onConnectOpenRouter, memberConnected, + accountProfile, + onSignInGoogle, + onSignOut, projectSyncAvailable, onDisconnectOpenRouter, onConnectProvider, onOpenProject, + onDeleteProject, + onDeleteAllProjects, }: DropsStudioDialogsProps) { + const accountDisplayName = studioAccountDisplayName(accountProfile?.name); return ( <> Connections Hub Connect AI, DropsTab data, Telegram accounts and bots. - Sensitive credentials remain scoped to this browser session - and are never compiled into public projects. + {accountProfile + ? " Verified credentials are encrypted for this account and never enter generated source, logs, exports or public projects." + : " Sensitive credentials stay in this browser session until you sign in, and are never compiled into public projects."} +
+ + {accountProfile + ? studioAccountInitial(accountProfile.name) + : } + +
+ {accountProfile + ? accountDisplayName + : "Your Drops Studio profile"} + + {accountProfile?.email + ?? (accountProfile + ? "Private projects and encrypted connections sync to this account." + : "Sign in with Google to restore projects and verified connections on another device.")} + +
+ +
{providers.map((item) => ( @@ -221,18 +270,20 @@ export function DropsStudioDialogs({
- {memberConnected ? "Studio member session connected" : "Connect account in one click"} + {connections.openrouter ? "OpenRouter connected" : "Connect OpenRouter in one click"} - {memberConnected - ? "Your signed member identity is active. The OpenRouter key still stays only in this browser tab." - : "OpenRouter creates a user-controlled key. It stays only in this browser tab."} + {connections.openrouter + ? memberConnected + ? "The credential is available to this tab and encrypted in your signed-in account vault." + : "The credential is available only in this browser tab." + : "OpenRouter creates a user-controlled key for the models you choose."}
- - {memberConnected ? "Use the session key below or switch back to Free Auto." : "or use an existing API key below"} + {connections.openrouter ? "Switch models below or return to Free Auto." : "or use an existing API key below"}
)} {provider.endpoint && ( @@ -289,9 +340,11 @@ export function DropsStudioDialogs({

- Session-only storage. The key is never - written to the project, database or local project - history. + {memberConnected ? "Encrypted account vault." : "Session-only storage."}{" "} + The key is never written to project files, logs, ZIPs or checkpoints. + {memberConnected + ? " After verification it is encrypted server-side and can be removed here." + : " Sign in to remember it across sessions."} {provider.id === "custom" ? " Custom requests go directly from your browser to the endpoint you choose." : ""} @@ -378,26 +431,44 @@ export function DropsStudioDialogs({ {projects.map((project) => { const projectPreset = getProjectPreset(project.spec.presetId); return ( - +

+ + +
); })} +
) : (
diff --git a/components/drops-studio.tsx b/components/drops-studio.tsx index 93c1cbb..7fc98da 100644 --- a/components/drops-studio.tsx +++ b/components/drops-studio.tsx @@ -25,6 +25,7 @@ import { Sparkles, Sun, TableProperties, + UserRound, UsersRound, WalletCards, WandSparkles, @@ -52,15 +53,20 @@ import type { ProjectQualityReport, } from "@/lib/project-types"; import { + deleteProjectSafely, readProjectsFromStore, saveProjectSafely, } from "@/lib/project-store"; import { + deleteMemberProjectFromCloud, listMemberProjectsFromCloud, materializeMemberProject, saveMemberProjectToCloud, } from "@/lib/member-project-sync-client"; -import { saveProjectV2ToCloud } from "@/lib/project-v2-sync-client"; +import { + deleteProjectV2FromCloud, + saveProjectV2ToCloud, +} from "@/lib/project-v2-sync-client"; import { customProductPreset, defaultPresetId, getProjectPreset, presets, type PresetId } from "@/lib/presets"; import { isModelProviderId, @@ -71,6 +77,11 @@ import { type ProviderModelCatalog, } from "@/lib/provider-models"; import { parseStudioConnectionHandoff } from "@/lib/studio-connection-handoff"; +import { safeSameOriginReturnPath } from "@/lib/safe-return-to"; +import { + studioAccountDisplayName, + studioAccountInitial, +} from "@/lib/studio-account-profile"; // Defer the Connections Hub and My Projects overlays until either is opened. const DropsStudioDialogs = dynamic( @@ -126,6 +137,20 @@ interface StudioAccessStatus { account?: { connected?: boolean; projectSync?: boolean }; } +interface StudioAccountProfileView { + provider: "google" | "openrouter"; + name: string; + email?: string; + picture?: string; +} + +interface StudioAccountConnectionView { + provider: Exclude | "telegram"; + connected: boolean; + model?: string; + endpointHost?: string; +} + const initialBuildActivity: BuildActivityItem[] = [ { id: "intent", @@ -385,6 +410,9 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { const [isPlaying, setIsPlaying] = useState(false); const [toast, setToast] = useState(""); const [connectionOpen, setConnectionOpen] = useState(false); + const [connectionReturnTo, setConnectionReturnTo] = useState( + null, + ); const [telegramProjectSlug, setTelegramProjectSlug] = useState( null, ); @@ -414,6 +442,8 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { const [guestRemaining, setGuestRemaining] = useState(null); const [platformAiAvailable, setPlatformAiAvailable] = useState(false); const [memberConnected, setMemberConnected] = useState(false); + const [accountProfile, setAccountProfile] = + useState(null); const [projectSyncAvailable, setProjectSyncAvailable] = useState(false); const [planLabel, setPlanLabel] = useState("Ready to build"); const [buildActivity, setBuildActivity] = useState([]); @@ -475,9 +505,78 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { }; }, []); + const hydrateAccountState = useCallback(async () => { + const response = await fetch("/api/account", { + credentials: "same-origin", + cache: "no-store", + headers: { accept: "application/json" }, + }); + if (response.status === 401) { + setAccountProfile(null); + return; + } + const payload = (await response.json().catch(() => ({}))) as { + profile?: StudioAccountProfileView | null; + connections?: StudioAccountConnectionView[]; + }; + if (!response.ok) return; + setAccountProfile(payload.profile ?? null); + const remembered = payload.connections ?? []; + setConnections((current) => { + const next = { ...current }; + for (const connection of remembered) { + if (connection.provider !== "telegram" && connection.provider in next) { + next[connection.provider as Exclude] = connection.connected; + } + } + return next; + }); + for (const connection of remembered) { + if ( + connection.connected + && connection.model + && isModelProviderId(connection.provider) + ) { + window.sessionStorage.setItem( + `drops-studio:${connection.provider}:model`, + connection.model, + ); + } + } + if (!window.sessionStorage.getItem("drops-studio:active-brain")) { + const preferred = remembered.find( + (connection) => + connection.connected && isModelProviderId(connection.provider), + ); + if (preferred && isModelProviderId(preferred.provider)) { + window.sessionStorage.setItem( + "drops-studio:active-brain", + preferred.provider, + ); + setActiveBrain(preferred.provider); + } + } + }, []); + useEffect(() => { const timer = window.setTimeout(async () => { const params = new URLSearchParams(window.location.search); + const requestedReturnTo = safeSameOriginReturnPath( + params.get("returnTo"), + window.location.origin, + "", + ); + if (requestedReturnTo) { + setConnectionReturnTo(requestedReturnTo); + } + const authState = params.get("auth"); + if (authState === "google-connected") { + setToast("Google profile connected. Private projects and verified connections can now follow your account."); + } else if (authState === "google-unavailable") { + setToast("Google sign-in needs GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in Vercel."); + } else if (authState?.startsWith("google-")) { + setToast("Google sign-in could not be verified. Please try again."); + } const presetParam = params.get("preset"); const requestedCatalogPreset = presets.find( (preset) => preset.id === presetParam, @@ -592,6 +691,9 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { if (accessResponse.ok && accessPayload.access) { hydratedAccess = accessPayload.access; const accessState = applyAccessStatus(hydratedAccess); + if (accessState.authenticated) { + await hydrateAccountState().catch(() => undefined); + } if (accessState.authenticated && accessState.projectSync) { try { const cloud = await listMemberProjectsFromCloud(); @@ -669,7 +771,7 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { } }, 0); return () => window.clearTimeout(timer); - }, [applyAccessStatus]); + }, [applyAccessStatus, hydrateAccountState]); useEffect(() => { if (!toast) return; @@ -808,7 +910,11 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { : (window.sessionStorage.getItem(`drops-studio:${activeBrain}`) ?? ""); - if (activeBrain !== "free" && !key) { + if ( + activeBrain !== "free" + && !key + && (activeBrain === "custom" || !connections[activeBrain]) + ) { openProvider(activeBrain); throw new Error( `Connect ${providerList.find((item) => item.id === activeBrain)?.name ?? "this AI"} first.`, @@ -949,8 +1055,8 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { "content-type": "application/json", "x-drops-guest": guestIdRef.current, }; - if (activeBrain === "openrouter") headers["x-openrouter-key"] = key; - else if (["openai", "anthropic", "kimi"].includes(activeBrain)) + if (activeBrain === "openrouter" && key) headers["x-openrouter-key"] = key; + else if (["openai", "anthropic", "kimi"].includes(activeBrain) && key) headers["x-provider-key"] = key; const response = await fetch("/api/agent/plan", { method: "POST", @@ -1056,7 +1162,24 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { status: index === 0 ? "active" : "queued", })), ); - const spec = await planPrompt(); + let spec: GeneratedProjectSpec | null = null; + if (mode === "build") { + if (!prompt.trim()) { + setActivity( + "intent", + "done", + `${selectedPreset.title} selected from the recipe catalog`, + ); + setActivity( + "blueprint", + "done", + "Recipe screens, interactions and integrations are editable in Studio", + ); + await buildProject(); + return; + } + } + spec = await planPrompt(); if (!spec) { setActivity("intent", "failed", "The product brief needs attention"); return; @@ -1139,6 +1262,85 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { setConnectionOpen(true); } + function closeConnectionsHub() { + setConnectionOpen(false); + if (connectionReturnTo) { + window.location.assign(connectionReturnTo); + return; + } + const url = new URL(window.location.href); + for (const key of ["connections", "provider", "flow", "project", "returnTo", "auth"]) { + url.searchParams.delete(key); + } + window.history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`); + } + + function startGoogleSignIn() { + const returnTo = safeSameOriginReturnPath( + `${window.location.pathname}${window.location.search}`, + window.location.origin, + ); + window.location.assign( + `/api/auth/google/start?returnTo=${encodeURIComponent(returnTo)}`, + ); + } + + async function signOutStudioAccount() { + const response = await fetch("/api/auth/session", { + method: "DELETE", + credentials: "same-origin", + }).catch(() => null); + if (!response?.ok) { + setToast("Could not sign out. Your account session was left unchanged."); + return; + } + setAccountProfile(null); + setMemberConnected(false); + setProjectSyncAvailable(false); + const accountBackedProviders = [ + "dropstab", + "openai", + "anthropic", + "openrouter", + "kimi", + "custom", + ] as const; + setConnections((current) => { + const next = { ...current }; + for (const provider of accountBackedProviders) { + if (!window.sessionStorage.getItem(`drops-studio:${provider}`)?.trim()) { + next[provider] = false; + } + } + return next; + }); + if ( + activeBrain !== "free" + && activeBrain !== "dropsbot" + && !window.sessionStorage.getItem(`drops-studio:${activeBrain}`)?.trim() + ) { + setActiveBrain("free"); + window.sessionStorage.setItem("drops-studio:active-brain", "free"); + } + setToast("Signed out. Browser projects and session-only connections remain available."); + } + + async function rememberConnection(input: { + provider: Exclude; + credential: string; + model?: string; + endpoint?: string; + }): Promise { + if (!memberConnected) return false; + const response = await fetch("/api/account/connections", { + method: "PUT", + credentials: "same-origin", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }).catch(() => null); + return Boolean(response?.ok); + } + async function connectOpenRouterAccount() { const bytes = crypto.getRandomValues(new Uint8Array(48)); const verifier = btoa(String.fromCharCode(...bytes)) @@ -1163,16 +1365,12 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { } async function disconnectOpenRouterAccount() { - let response: Response; - try { - response = await fetch("/api/auth/session", { method: "DELETE" }); - } catch { - setToast("Could not disconnect OpenRouter. Your current session was left unchanged."); - return; - } - if (!response.ok) { - setToast("Could not disconnect OpenRouter. Your current session was left unchanged."); - return; + if (memberConnected) { + await fetch("/api/account/connections?provider=openrouter", { + method: "DELETE", + credentials: "same-origin", + headers: { accept: "application/json" }, + }).catch(() => undefined); } for (const key of [ "drops-studio:openrouter", @@ -1191,18 +1389,7 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { return next; }); setProviderId("free"); - try { - const accessResponse = await fetch("/api/access", { cache: "no-store" }); - const payload = await accessResponse.json() as { access?: StudioAccessStatus }; - if (!accessResponse.ok || !payload.access) throw new Error("Access status unavailable."); - applyAccessStatus(payload.access); - } catch { - setMemberConnected(false); - setPlatformAiAvailable(false); - setGuestRemaining(null); - setPlanLabel("Local compiler ready"); - } - setToast("OpenRouter and the signed-in member session were disconnected from this browser."); + setToast("OpenRouter disconnected. Your Studio profile remains signed in."); } async function connectProvider() { @@ -1211,7 +1398,7 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { setActiveBrain("free"); window.sessionStorage.setItem("drops-studio:active-brain", "free"); setToast("Free Auto is ready."); - setConnectionOpen(false); + closeConnectionsHub(); return; } if (providerId === "dropsbot") { @@ -1227,7 +1414,7 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { setToast( "Official Drops Bot opened. Telegram account verification remains separate; follow the documented Profile steps before treating alerts as configured.", ); - setConnectionOpen(false); + closeConnectionsHub(); return; } const connectionKey = @@ -1265,10 +1452,18 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { window.sessionStorage.setItem("drops-studio:active-brain", "custom"); setConnections((current) => ({ ...current, custom: true })); setActiveBrain("custom"); + const remembered = await rememberConnection({ + provider: "custom", + credential: connectionKey, + model: providerModel.trim(), + endpoint: customEndpoint.trim(), + }); setToast( - "Custom API configured for this tab. It will be called directly by your browser when you plan.", + remembered + ? "Custom API verified for this tab and encrypted in your Studio account vault." + : "Custom API configured for this tab. Sign in to remember it across sessions.", ); - setConnectionOpen(false); + closeConnectionsHub(); return; } setTestingConnection(true); @@ -1356,15 +1551,28 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { `drops-studio:${providerId}:model`, ); } + const remembered = await rememberConnection({ + provider: providerId, + credential: connectionKey, + ...(selectedModel ? { model: selectedModel } : {}), + }); setToast( returnedModels.length - ? `${provider.name} verified. Choose from ${verifiedCatalog?.totalModelCount ?? returnedModels.length} provider-returned models.` + ? `${provider.name} verified${remembered ? " and encrypted for your account" : " for this tab"}. Choose from ${verifiedCatalog?.totalModelCount ?? returnedModels.length} provider-returned models.` : `${provider.name} verified, but no model list was returned. Enter the exact model ID to continue.`, ); return; } - setToast(`${provider.name} verified and connected for this browser tab.`); - setConnectionOpen(false); + const remembered = await rememberConnection({ + provider: providerId, + credential: connectionKey, + }); + setToast( + remembered + ? `${provider.name} verified and encrypted for your Studio account.` + : `${provider.name} verified and connected for this browser tab.`, + ); + closeConnectionsHub(); } catch (error) { setToast( error instanceof Error ? error.message : "Connection test failed.", @@ -1376,7 +1584,7 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { async function refreshMarket() { const key = window.sessionStorage.getItem("drops-studio:dropstab"); - if (!key) { + if (!key && !connections.dropstab) { openProvider("dropstab"); setToast( "Connect a DropsTab API key to switch this preview to live data.", @@ -1386,7 +1594,7 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { setToast("Refreshing live DropsTab data…"); try { const response = await fetch("/api/dropstab", { - headers: { "x-dropstab-api-key": key }, + ...(key ? { headers: { "x-dropstab-api-key": key } } : {}), }); const payload = await response.json(); if (!response.ok) @@ -1479,16 +1687,11 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { origin: window.location.origin, }); - // Every Build action goes through the authoritative server BuildRun. - // The planner already produced a rich specification, so Free/Gateway and - // custom-endpoint plans are validated and inspected without spending a - // redundant model call. Connected first-party providers receive one - // bounded enhancement plus at most one repair attempt. - const buildProvider = provider === "custom" ? "free" : provider; - const providerKey = - buildProvider === "free" - ? "" - : window.sessionStorage.getItem(`drops-studio:${buildProvider}`) || ""; + // The initial release inspection is deterministic and server-owned so + // the browser can open Studio quickly without executing generated code. + // The selected BYOK provider is used by the visible Project V2 agent + // loop after navigation, where file edits, Sandbox checks and repairs + // are streamed into the Director conversation. const buildResponse = await fetch("/api/generate", { method: "POST", headers: { @@ -1496,13 +1699,12 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { "x-drops-session": guestIdRef.current, }, body: JSON.stringify({ - provider: buildProvider, - ...(providerKey ? { key: providerKey } : {}), - model, + provider: "free", + model: "Free Auto", prompt: prompt || selectedPreset.description, spec, }), - signal: AbortSignal.timeout(55_000), + signal: AbortSignal.timeout(20_000), }); const buildPayload = (await buildResponse.json().catch(() => ({}))) as { spec?: GeneratedProjectSpec; @@ -1582,14 +1784,27 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { html, projectV2, quality, + conversation: [ + { + id: `user-${projectId}`, + role: "user", + content: + prompt.trim() || targetPreset.title.replace(/^Build Your\s+/i, "Build "), + createdAt: now, + }, + { + id: `assistant-${projectId}`, + role: "assistant", + content: `I prepared an editable ${spec.blueprint.productType} plan with ${spec.blueprint.screens.length} screens and ${spec.blueprint.interactions.length} working interactions. I’m opening Studio now; the isolated build, checks, preview and any repair will continue visibly in this chat.`, + createdAt: now, + }, + ], createdAt: now, updatedAt: now, }; - const studioHref = `/studio/${project.id}`; - const studioWarmup = Promise.allSettled([ - Promise.resolve().then(() => router.prefetch(studioHref)), - warmProjectExperience(spec), - ]); + const studioHref = `/studio/${project.id}?panel=director&autobuild=1`; + void router.prefetch(studioHref); + void warmProjectExperience(spec); const stored = await saveProjectSafely(project, { expectedUpdatedAt: null, }); @@ -1600,9 +1815,6 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { } let builderSnapshotSaved = false; try { - // /api/access creates and signs the anonymous actor cookie when this - // is a first-visit build. Project V2 storage remains private and - // actor-scoped for guests as well as signed-in members. const accessResponse = await fetch("/api/access", { credentials: "same-origin", cache: "no-store", @@ -1612,11 +1824,9 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { access?: StudioAccessStatus; }; const privateProjectStorageAvailable = Boolean( - accessResponse.ok - && ( - accessPayload.access?.projectSync - ?? accessPayload.access?.account?.projectSync - ), + accessResponse.ok && + (accessPayload.access?.projectSync ?? + accessPayload.access?.account?.projectSync), ); if (privateProjectStorageAvailable) { await saveProjectV2ToCloud(projectV2, 0); @@ -1638,16 +1848,13 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { setProjects(next); setToast( serverBuildWarning - ? `${serverBuildWarning} Opening your editable live project…` + ? `${serverBuildWarning} Opening Studio for the isolated build…` : cloudSaved - ? "Project built and saved to your account. Opening the live workspace…" + ? "Editable plan saved to your account. Opening the live build conversation…" : builderSnapshotSaved - ? "Project V2 saved for its isolated build. Opening the live workspace…" - : projectSyncAvailable - ? "Project built and saved in this browser. Cloud sync will retry in Studio…" - : "Opening your editable live project…", + ? "Editable Project V2 saved. Opening the live build conversation…" + : "Editable plan created. Opening Studio while the verified build continues…", ); - await studioWarmup; setBuilding(false); router.push(studioHref); } catch (error) { @@ -1736,6 +1943,56 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { setToast(`${label} added to the blueprint. Nothing was executed.`); } + const deleteProjectRecord = useCallback(async (project: GeneratedProject) => { + const result = await deleteProjectSafely(project.id, { + expectedUpdatedAt: project.updatedAt, + }); + if (result.status === "conflict") { + throw new Error("This project changed in another tab. Reload before deleting it."); + } + try { + if (projectSyncAvailable) { + await deleteProjectV2FromCloud(project.id); + } + if (memberConnected) { + await deleteMemberProjectFromCloud(project.id); + } + } catch (error) { + const restored = await saveProjectSafely(project, { + expectedUpdatedAt: null, + }).catch(() => null); + if (restored?.status === "saved") setProjects(restored.projects); + throw error; + } + setProjects(result.projects); + }, [memberConnected, projectSyncAvailable]); + + const deleteProjectFromLibrary = useCallback(async (project: GeneratedProject) => { + if (!window.confirm(`Delete “${project.spec.name}”? Its Sandbox and private snapshots will also be removed.`)) { + return; + } + try { + await deleteProjectRecord(project); + setToast(`${project.spec.name} deleted.`); + } catch (error) { + setToast(error instanceof Error ? error.message : "Project deletion failed."); + } + }, [deleteProjectRecord]); + + const deleteAllProjectsFromLibrary = useCallback(async () => { + if (!projects.length) return; + if (!window.confirm(`Delete all ${projects.length} projects? This permanently removes their private snapshots and Sandboxes.`)) { + return; + } + try { + for (const project of [...projects]) await deleteProjectRecord(project); + setProjects([]); + setToast("All projects deleted."); + } catch (error) { + setToast(error instanceof Error ? error.message : "Some projects could not be deleted."); + } + }, [deleteProjectRecord, projects]); + return (
@@ -1774,6 +2031,21 @@ export function DropsStudio({ hero }: { hero: ReactNode }) {
+ +
)} {(project.conversation ?? []).map((message) => ( -
+
{message.role === "assistant" ? : "You"} diff --git a/components/project-v2-studio-surface.tsx b/components/project-v2-studio-surface.tsx index 7094204..b77c360 100644 --- a/components/project-v2-studio-surface.tsx +++ b/components/project-v2-studio-surface.tsx @@ -100,6 +100,11 @@ export interface ProjectV2StudioSurfaceProps { project: ProjectV2; provider: ProjectProvider; onProjectChange: (project: ProjectV2, storageRevision?: number) => void; + onAgentEvent?: (event: { + phase: "snapshot" | "sandbox" | "verification" | "preview"; + status: "active" | "done" | "blocked"; + message: string; + }) => void; onNotify?: (message: string) => void; } @@ -308,6 +313,7 @@ export function ProjectV2StudioSurface({ project, provider, onProjectChange, + onAgentEvent, onNotify, }: ProjectV2StudioSurfaceProps) { const [selectedPath, setSelectedPath] = useState(() => @@ -539,6 +545,7 @@ export function ProjectV2StudioSurface({ prompt: string, ) => { if (busy) return; + let activePhase: "snapshot" | "sandbox" | "verification" = "snapshot"; setBusy("task:build"); setAgentState("running"); setAgentSummary( @@ -547,6 +554,11 @@ export function ProjectV2StudioSurface({ : "Sandbox is installing, checking, building, and starting the preview.", ); setSandbox((current) => ({ ...current, status: "creating" })); + onAgentEvent?.({ + phase: "snapshot", + status: "active", + message: "Saving the private Project V2 file snapshot…", + }); try { const snapshot = await syncSnapshot(); if (!snapshot.persisted) { @@ -554,6 +566,17 @@ export function ProjectV2StudioSurface({ "Sandbox build requires an actor-owned private Project V2 snapshot. Browser-local files remain editable and safe.", ); } + onAgentEvent?.({ + phase: "snapshot", + status: "done", + message: "Private multi-file snapshot saved.", + }); + activePhase = "sandbox"; + onAgentEvent?.({ + phase: "sandbox", + status: "active", + message: "Starting the isolated Node 24 Sandbox and syncing real project files…", + }); const response = await fetch("/api/builder/agent", { method: "POST", credentials: "same-origin", @@ -569,6 +592,17 @@ export function ProjectV2StudioSurface({ if (!payload.result) { throw new Error(payload.error ?? "Builder agent returned no verifiable result."); } + onAgentEvent?.({ + phase: "sandbox", + status: "done", + message: "Project files are running inside the isolated Node 24 Sandbox.", + }); + activePhase = "verification"; + onAgentEvent?.({ + phase: "verification", + status: "active", + message: "Running typecheck, lint, tests, production build and browser verification…", + }); await absorbBuilderResult(payload.result, snapshot.project.files, payload.intelligence?.trace); if (!response.ok && payload.result.status !== "blocked") { throw new Error(payload.error ?? "Builder request failed."); @@ -580,7 +614,31 @@ export function ProjectV2StudioSurface({ : "AI build verified — preview and diff are ready." : payload.result.summary, ); + if (payload.result.releaseGate.ok) { + onAgentEvent?.({ + phase: "verification", + status: "done", + message: "Release checks passed against the real generated files.", + }); + onAgentEvent?.({ + phase: "preview", + status: "done", + message: "Live Sandbox preview is ready. You can keep chatting to edit multiple files.", + }); + } else { + window.sessionStorage.removeItem( + `${AUTO_BUILD_KEY}:${project.id}:${project.revision}`, + ); + onAgentEvent?.({ + phase: "verification", + status: "blocked", + message: payload.result.summary, + }); + } } catch (error) { + window.sessionStorage.removeItem( + `${AUTO_BUILD_KEY}:${project.id}:${project.revision}`, + ); const failure = message(error, "Builder execution failed safely."); if (mounted.current) { setAgentState("blocked"); @@ -589,11 +647,25 @@ export function ProjectV2StudioSurface({ setSandbox((current) => ({ ...current, status: "failed", message: failure })); setActiveView("checks"); } + onAgentEvent?.({ + phase: activePhase, + status: "blocked", + message: failure, + }); onNotify?.(failure); } finally { if (mounted.current) setBusy(null); } - }, [absorbBuilderResult, busy, onNotify, provider, syncSnapshot]); + }, [ + absorbBuilderResult, + busy, + onAgentEvent, + onNotify, + project.id, + project.revision, + provider, + syncSnapshot, + ]); useEffect(() => { if ( diff --git a/components/project-workspace-dialog.tsx b/components/project-workspace-dialog.tsx index 0e30f36..2acb0c2 100644 --- a/components/project-workspace-dialog.tsx +++ b/components/project-workspace-dialog.tsx @@ -280,7 +280,7 @@ export function ProjectWorkspaceDialog({ -
+
-
+