From 2dabd837b93dc335a88845b09c104166fcf52f2d Mon Sep 17 00:00:00 2001 From: jiajia Date: Mon, 25 May 2026 16:59:50 +0800 Subject: [PATCH] Support Popi.TV entry without blocking login The welcome screen now matches the Popi.TV brand and gives unauthenticated users two explicit paths: jump to the main-site login page or provide a Popi/NewAPI gateway key directly. The manually provided key is persisted in the local user store and forwarded through the NewAPI-backed generation, model, LLM, and chat routes without requiring a login token. Constraint: Users need a usable local path when they are not logged into the main site.\nRejected: Keep the startup login modal for missing tokens | it blocked the in-welcome login/key choices.\nRejected: Rename internal workflow prompts and storage keys | those are compatibility/internal identifiers, not the visible brand surface.\nConfidence: high\nScope-risk: moderate\nDirective: Keep manual gateway-key access scoped to NewAPI-backed paths; non-NewAPI providers should continue requiring a login token.\nTested: npm run test:run -- src/app/api/models/__tests__/route.test.ts src/app/api/models/[modelId]/__tests__/route.test.ts src/app/api/llm/__tests__/route.test.ts src/app/api/chat/__tests__/route.test.ts src/app/api/generate/__tests__/route.test.ts\nTested: npm run test:run -- src/components/__tests__/QuickstartInitialView.test.tsx src/components/__tests__/WelcomeModal.test.tsx\nTested: npm run build\nTested: Browser verification for Popi.TV welcome title and two unauthenticated entry paths\nTested: git diff --cached --check\nNot-tested: Full test suite\nNot-tested: npm run lint still fails because the existing Next 16 next lint script resolves /lint as a project directory --- src/app/api/chat/__tests__/route.test.ts | 25 ++++ src/app/api/chat/route.ts | 6 +- src/app/api/generate/__tests__/route.test.ts | 41 +++++++ src/app/api/generate/route.ts | 9 +- src/app/api/llm/__tests__/route.test.ts | 49 ++++++++ src/app/api/llm/route.ts | 13 +- .../models/[modelId]/__tests__/route.test.ts | 36 +++++- src/app/api/models/[modelId]/route.ts | 17 +-- src/app/api/models/__tests__/route.test.ts | 52 ++++++++ src/app/api/models/route.ts | 24 ++-- src/app/layout.tsx | 2 +- .../__tests__/QuickstartInitialView.test.tsx | 114 +++++++++++++++++- .../__tests__/WelcomeModal.test.tsx | 8 +- .../quickstart/QuickstartInitialView.tsx | 101 +++++++++++++++- src/i18n/index.tsx | 50 +++++++- src/proxy.ts | 9 ++ src/store/__tests__/userStore.test.ts | 19 ++- src/store/userStore.ts | 10 +- src/types/user.ts | 1 + src/utils/loginRequiredModal.ts | 3 +- src/utils/loginUrl.ts | 1 + 21 files changed, 544 insertions(+), 46 deletions(-) create mode 100644 src/utils/loginUrl.ts diff --git a/src/app/api/chat/__tests__/route.test.ts b/src/app/api/chat/__tests__/route.test.ts index 8f39df4e..4c51513e 100644 --- a/src/app/api/chat/__tests__/route.test.ts +++ b/src/app/api/chat/__tests__/route.test.ts @@ -96,4 +96,29 @@ describe("/api/chat route", () => { }) ); }); + + it("allows chat requests with a manual gateway key and no login token", async () => { + const request = new Request("http://localhost/api/chat", { + method: "POST", + headers: { + "X-NewApiWG-Key": "manual-newapi-key", + "X-NewApiWG-Base-URL": "https://llmapitest.popi.art/v1", + }, + body: JSON.stringify({ + messages: [], + workflowState: { nodes: [], edges: [] }, + selectedNodeIds: [], + }), + }); + + const response = await POST(request); + + expect(response.status).toBe(200); + expect(mockCreateOpenAICompatible).toHaveBeenCalledWith({ + name: "newapiwg", + apiKey: "manual-newapi-key", + baseURL: "https://llmapitest.popi.art/v1", + headers: {}, + }); + }); }); diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index bb817811..5ea4737c 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -7,7 +7,7 @@ import { WorkflowNode } from '@/types'; import { WorkflowEdge } from '@/types/workflow'; import { normalizeNewApiWGBaseUrl } from '@/app/api/generate/providers/newapiwg'; import { DEFAULT_NEWAPIWG_LLM_MODEL_ID } from '@/lib/llmModels'; -import { buildUserForwardHeaders, requireLogin } from '@/app/api/_auth'; +import { buildUserForwardHeaders, getRequestToken } from '@/app/api/_auth'; import { ApiError, apiErrorResponse } from '@/app/api/_errors'; import { resolveUserGatewayApiKey } from '@/app/api/_gatewayApiKey'; @@ -15,7 +15,7 @@ export const maxDuration = 60; // 1 minute timeout export async function POST(request: Request) { try { - const login = requireLogin(request); + const loginToken = getRequestToken(request); const { messages, workflowState, selectedNodeIds } = await request.json() as { messages: UIMessage[]; workflowState?: { nodes: WorkflowNode[]; edges: WorkflowEdge[] }; @@ -53,7 +53,7 @@ export async function POST(request: Request) { name: 'newapiwg', apiKey, baseURL: baseUrl, - headers: buildUserForwardHeaders(login.token), + headers: buildUserForwardHeaders(loginToken), }); // Convert UI messages to model messages format diff --git a/src/app/api/generate/__tests__/route.test.ts b/src/app/api/generate/__tests__/route.test.ts index 7ef4674b..8e1fe0c5 100644 --- a/src/app/api/generate/__tests__/route.test.ts +++ b/src/app/api/generate/__tests__/route.test.ts @@ -1080,6 +1080,47 @@ describe("/api/generate route", () => { expect(upstreamBody.resolution).toBeUndefined(); }); + it("should allow NewApiWG generation with a manual gateway key and no login token", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + choices: [ + { + message: { + content: "https://cdn.example.com/generated-image.png", + }, + }, + ], + }), + }); + + const request = createMockPostRequest( + { + prompt: "A cafe scene", + selectedModel: { + provider: "newapiwg", + modelId: "gpt-image-2-all", + displayName: "gpt-image-2-all", + capabilities: ["text-to-image", "image-to-image"], + }, + }, + { + Authorization: "", + "X-NewApiWG-Key": "manual-newapi-key", + } + ); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(mockFetch.mock.calls[0][1].headers).toMatchObject({ + Authorization: "Bearer manual-newapi-key", + }); + expect(mockFetch.mock.calls[0][1].headers).not.toHaveProperty("X-User-Token"); + }); + it("should pass top-level aspect ratio and resolution to native NewApiWG image models", async () => { mockFetch.mockResolvedValueOnce({ ok: true, diff --git a/src/app/api/generate/route.ts b/src/app/api/generate/route.ts index d3d3dddf..55dfe417 100644 --- a/src/app/api/generate/route.ts +++ b/src/app/api/generate/route.ts @@ -19,7 +19,7 @@ import { clearFalInputMappingCache as _clearFalInputMappingCache, generateWithFa import { submitKieTask } from "./providers/kie"; import { generateWithWaveSpeed } from "./providers/wavespeed"; import { generateWithNewApiWG } from "./providers/newapiwg"; -import { requireConfiguredValue, requireLogin } from "../_auth"; +import { getRequestToken, requireConfiguredValue, requireLogin } from "../_auth"; import { ApiError, apiErrorResponse } from "../_errors"; import { resolveUserGatewayApiKey } from "../_gatewayApiKey"; @@ -175,7 +175,6 @@ export async function POST(request: NextRequest) { console.log(`\n[API:${requestId}] ========== NEW GENERATE REQUEST ==========`); try { - const login = requireLogin(request); const body: MultiProviderGenerateRequest = await request.json(); const { images, @@ -211,6 +210,10 @@ export async function POST(request: NextRequest) { // Determine which provider to use const provider: ProviderType = selectedModel?.provider || "gemini"; + const loginToken = getRequestToken(request); + if (provider !== "newapiwg") { + requireLogin(request); + } console.log(`[API:${requestId}] Provider: ${provider}, Model: ${selectedModel?.modelId || model}`); // Route to appropriate provider @@ -234,7 +237,7 @@ export async function POST(request: NextRequest) { dynamicInputs, }; - const result = await generateWithNewApiWG(apiKey, newApiWGBaseUrl ?? null, genInput, login.token); + const result = await generateWithNewApiWG(apiKey, newApiWGBaseUrl ?? null, genInput, loginToken); if (!result.success) { return NextResponse.json( { success: false, requestId, error: result.error || "Generation failed" }, diff --git a/src/app/api/llm/__tests__/route.test.ts b/src/app/api/llm/__tests__/route.test.ts index d5ef8674..cb64da89 100644 --- a/src/app/api/llm/__tests__/route.test.ts +++ b/src/app/api/llm/__tests__/route.test.ts @@ -67,6 +67,16 @@ function createMockPostRequest( } as unknown as NextRequest; } +function createMockPostRequestWithoutLogin( + body: unknown, + headers?: Record +): NextRequest { + return { + json: vi.fn().mockResolvedValue(body), + headers: new Headers(headers || {}), + } as unknown as NextRequest; +} + describe("/api/llm route", () => { beforeEach(() => { vi.clearAllMocks(); @@ -364,6 +374,45 @@ describe("/api/llm route", () => { ); }); + it("should allow NewApiWG requests with a manual gateway key and no login token", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + choices: [{ message: { content: "Manual key response text" } }], + }), + }); + + const request = createMockPostRequestWithoutLogin( + { + prompt: "Test prompt", + provider: "newapiwg", + model: "doubao-seed-2-0-lite-260428", + }, + { + "X-NewApiWG-Key": "manual-newapi-key", + "X-NewApiWG-Base-URL": "https://newapi.example/v1", + } + ); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(mockFetch).toHaveBeenCalledWith( + "https://newapi.example/v1/chat/completions", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer manual-newapi-key", + }), + }) + ); + const requestHeaders = mockFetch.mock.calls[0]?.[1]?.headers as Record; + expect(requestHeaders["X-User-Token"]).toBeUndefined(); + expect(requestHeaders.token).toBeUndefined(); + }); + it("should map legacy Doubao selector values to available gateway model ids", async () => { mockFetch.mockResolvedValueOnce({ ok: true, diff --git a/src/app/api/llm/route.ts b/src/app/api/llm/route.ts index ef70f6fb..d201dacc 100644 --- a/src/app/api/llm/route.ts +++ b/src/app/api/llm/route.ts @@ -3,7 +3,11 @@ import { GoogleGenAI } from "@google/genai"; import { LLMGenerateRequest, LLMGenerateResponse, LLMModelType } from "@/types"; import { logger } from "@/utils/logger"; import { normalizeNewApiWGBaseUrl } from "@/app/api/generate/providers/newapiwg"; -import { buildGatewayHeaders, requireLogin } from "@/app/api/_auth"; +import { + buildGatewayHeaders, + getRequestToken, + requireLogin, +} from "@/app/api/_auth"; import { ApiError, apiErrorResponse } from "@/app/api/_errors"; import { resolveUserGatewayApiKey } from "@/app/api/_gatewayApiKey"; import { logGatewayRequest } from "@/app/api/_gatewayLogging"; @@ -404,7 +408,6 @@ export async function POST(request: NextRequest) { const requestId = generateRequestId(); try { - const login = requireLogin(request); // Get user-provided API keys from headers (override env variables) const geminiApiKey = request.headers.get("X-Gemini-API-Key"); const openaiApiKey = request.headers.get("X-OpenAI-API-Key"); @@ -421,6 +424,10 @@ export async function POST(request: NextRequest) { temperature = 0.7, maxTokens = 1024 } = body; + const loginToken = getRequestToken(request); + if (provider !== "newapiwg") { + requireLogin(request); + } logger.info('api.llm', 'LLM generation request received', { requestId, @@ -444,7 +451,7 @@ export async function POST(request: NextRequest) { if (provider === "newapiwg") { const newApiWGKey = await resolveUserGatewayApiKey(request); - text = await generateWithNewApiWG(prompt, model, temperature, maxTokens, images, videos, requestId, newApiWGKey, newApiWGBaseUrl, login.token); + text = await generateWithNewApiWG(prompt, model, temperature, maxTokens, images, videos, requestId, newApiWGKey, newApiWGBaseUrl, loginToken); } else if (provider === "google") { text = await generateWithGoogle(prompt, model, temperature, maxTokens, images, videos, requestId, geminiApiKey); } else if (provider === "openai") { diff --git a/src/app/api/models/[modelId]/__tests__/route.test.ts b/src/app/api/models/[modelId]/__tests__/route.test.ts index c9b6d3fe..77f0a9f8 100644 --- a/src/app/api/models/[modelId]/__tests__/route.test.ts +++ b/src/app/api/models/[modelId]/__tests__/route.test.ts @@ -25,7 +25,24 @@ function createMockSchemaRequest( return { nextUrl: url, - headers: new Headers(headers), + headers: new Headers({ + Authorization: "Bearer login-token", + ...(headers || {}), + }), + } as unknown as NextRequest; +} + +function createMockSchemaRequestWithoutLogin( + modelId: string, + provider: string, + headers?: Record +): NextRequest { + const url = new URL(`http://localhost:3000/api/models/${encodeURIComponent(modelId)}`); + url.searchParams.set("provider", provider); + + return { + nextUrl: url, + headers: new Headers(headers || {}), } as unknown as NextRequest; } @@ -130,6 +147,23 @@ describe("/api/models/[modelId] schema endpoint", () => { ]); expect(data.parameters.map((param: { name: string }) => param.name)).toEqual(["duration", "aspect_ratio"]); }); + + it("should allow NewApiWG schema lookup with a manual gateway key and no login token", async () => { + const modelId = "gpt-image-2-all"; + const request = createMockSchemaRequestWithoutLogin( + modelId, + "newapiwg", + { "X-NewApiWG-Key": "manual-newapi-key" } + ); + const response = await GET(request, { params: Promise.resolve({ modelId }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.inputs).toContainEqual( + expect.objectContaining({ name: "prompt", type: "text" }) + ); + }); }); describe("isImageInput classification", () => { diff --git a/src/app/api/models/[modelId]/route.ts b/src/app/api/models/[modelId]/route.ts index 16a5b67e..74cef203 100644 --- a/src/app/api/models/[modelId]/route.ts +++ b/src/app/api/models/[modelId]/route.ts @@ -31,7 +31,7 @@ import { WaveSpeedApiSchema, } from "@/lib/providers/cache"; import { inferNewApiWGCapabilities } from "@/app/api/generate/providers/newapiwg"; -import { requireLogin } from "@/app/api/_auth"; +import { getRequestGatewayApiKey, requireLogin } from "@/app/api/_auth"; import { apiErrorResponse } from "@/app/api/_errors"; // Cache for model schemas (10 minute TTL) @@ -1587,12 +1587,6 @@ export async function GET( request: NextRequest, { params }: { params: Promise<{ modelId: string }> } ): Promise> { - try { - requireLogin(request); - } catch (error) { - return apiErrorResponse(error) as unknown as NextResponse; - } - // Await params before accessing properties const { modelId } = await params; const decodedModelId = decodeURIComponent(modelId); @@ -1608,6 +1602,15 @@ export async function GET( ); } + const hasManualGatewayKey = !!getRequestGatewayApiKey(request); + if (provider !== "newapiwg" || !hasManualGatewayKey) { + try { + requireLogin(request); + } catch (error) { + return apiErrorResponse(error) as unknown as NextResponse; + } + } + // Check cache const cacheKey = `${provider}:${decodedModelId}`; const cached = schemaCache.get(cacheKey); diff --git a/src/app/api/models/__tests__/route.test.ts b/src/app/api/models/__tests__/route.test.ts index efdb8c8c..c67986db 100644 --- a/src/app/api/models/__tests__/route.test.ts +++ b/src/app/api/models/__tests__/route.test.ts @@ -44,6 +44,21 @@ function createMockGetRequest( } as unknown as NextRequest; } +function createMockGetRequestWithoutLogin( + params: Record = {}, + headers?: Record +): NextRequest { + const url = new URL("http://localhost:3000/api/models"); + Object.entries(params).forEach(([key, value]) => { + url.searchParams.set(key, value); + }); + + return { + nextUrl: url, + headers: new Headers(headers || {}), + } as unknown as NextRequest; +} + // Helper to create Replicate API response function createReplicateResponse(models: Array<{ owner: string; name: string; description: string | null }>, next: string | null = null) { return { @@ -151,6 +166,43 @@ describe("/api/models route", () => { expect(data.providers.fal).toBeUndefined(); }); + it("GET: should list Popi gateway models with a manual gateway key and no login token", async () => { + process.env.PROVIDER_MODE = "popi"; + process.env.NEXT_PUBLIC_PROVIDER_MODE = "popi"; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + data: [ + { + id: "gpt-image-2-all", + name: "GPT Image 2", + supported_endpoint_types: ["openai"], + }, + ], + }), + }); + + const request = createMockGetRequestWithoutLogin( + {}, + { "X-NewApiWG-Key": "manual-newapi-key" } + ); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.availableProviders).toEqual(["newapiwg"]); + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer manual-newapi-key", + }), + }) + ); + }); + it("GET: should reject non-Popi provider filters", async () => { process.env.PROVIDER_MODE = "popi"; process.env.NEXT_PUBLIC_PROVIDER_MODE = "popi"; diff --git a/src/app/api/models/route.ts b/src/app/api/models/route.ts index d383661c..0306c3a9 100644 --- a/src/app/api/models/route.ts +++ b/src/app/api/models/route.ts @@ -44,7 +44,11 @@ import { isPopiProviderMode, POPI_PROVIDER_ID, } from "@/lib/providerMode"; -import { requireLogin } from "@/app/api/_auth"; +import { + getRequestGatewayApiKey, + getRequestToken, + requireLogin, +} from "@/app/api/_auth"; import { apiErrorResponse } from "@/app/api/_errors"; import { resolveUserGatewayApiKey } from "@/app/api/_gatewayApiKey"; @@ -1092,11 +1096,14 @@ async function fetchFalModels( export async function GET( request: NextRequest ): Promise> { - let loginToken: string; - try { - loginToken = requireLogin(request).token; - } catch (error) { - return apiErrorResponse(error) as unknown as NextResponse; + const requestGatewayApiKey = getRequestGatewayApiKey(request); + let loginToken = getRequestToken(request); + if (!loginToken && !requestGatewayApiKey) { + try { + loginToken = requireLogin(request).token; + } catch (error) { + return apiErrorResponse(error) as unknown as NextResponse; + } } // Parse query params @@ -1288,11 +1295,14 @@ export async function GET( // Fetch from each provider (replicate, fal, wavespeed) for (const provider of providersToFetch) { + const newApiWGCacheIdentity = loginToken + ? `user:${loginToken.slice(-8)}` + : `key:${newApiWGKey?.slice(-8) || "manual"}`; // For Replicate and WaveSpeed, always use base cache key since we filter client-side // For fal.ai, include search in cache key since their API supports search const cacheKey = provider === "newapiwg" - ? `${getCacheKey(provider)}:cap-v2:${newApiWGBaseUrl}:user:${loginToken.slice(-8)}` + ? `${getCacheKey(provider)}:cap-v2:${newApiWGBaseUrl}:${newApiWGCacheIdentity}` : provider === "replicate" || provider === "wavespeed" ? getCacheKey(provider) : getCacheKey(provider, searchQuery); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c217fd54..2b8efcc4 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,7 +6,7 @@ import { I18nProvider } from "@/i18n"; import { AntdThemeProvider } from "@/components/AntdThemeProvider"; export const metadata: Metadata = { - title: "popiart-node - AI Image Workflow", + title: "Popi.TV - AI Image Workflow", description: "Node-based image annotation and generation workflow using Nano Banana Pro", }; diff --git a/src/components/__tests__/QuickstartInitialView.test.tsx b/src/components/__tests__/QuickstartInitialView.test.tsx index 1109abc5..c2661026 100644 --- a/src/components/__tests__/QuickstartInitialView.test.tsx +++ b/src/components/__tests__/QuickstartInitialView.test.tsx @@ -1,5 +1,49 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; + +const { + mockSetGatewayApiKey, + resetMockUserState, + setMockUserState, + getMockUserState, +} = vi.hoisted(() => { + const mockSetGatewayApiKey = vi.fn(); + let mockUserState: Record = {}; + + const resetMockUserState = () => { + mockUserState = { + userInfo: null, + gatewayApiKey: null, + isLoadingUserInfo: false, + setGatewayApiKey: mockSetGatewayApiKey, + }; + }; + + const setMockUserState = (nextState: Record) => { + mockUserState = { + ...mockUserState, + ...nextState, + }; + }; + + const getMockUserState = () => mockUserState; + resetMockUserState(); + + return { + mockSetGatewayApiKey, + resetMockUserState, + setMockUserState, + getMockUserState, + }; +}); + +vi.mock("@/store/userStore", () => ({ + useUserStore: (selector?: (state: unknown) => unknown) => { + const state = getMockUserState(); + return selector ? selector(state) : state; + }, +})); + import { QuickstartInitialView } from "@/components/quickstart/QuickstartInitialView"; describe("QuickstartInitialView", () => { @@ -10,6 +54,7 @@ describe("QuickstartInitialView", () => { beforeEach(() => { vi.clearAllMocks(); + resetMockUserState(); }); afterEach(() => { @@ -17,7 +62,7 @@ describe("QuickstartInitialView", () => { }); describe("Basic Rendering", () => { - it("should render the popiart-node title and logo", () => { + it("should render the Popi.TV title and logo", () => { render( { /> ); - expect(screen.getByText("popiart-node")).toBeInTheDocument(); + expect(screen.getByText("Popi.TV")).toBeInTheDocument(); expect(screen.getAllByAltText("").length).toBeGreaterThan(0); // Logo images }); @@ -62,6 +107,67 @@ describe("QuickstartInitialView", () => { expect(screen.getByText("Prompt a workflow")).toBeInTheDocument(); }); + it("should render sign-in status actions when not signed in", () => { + render( + + ); + + expect(screen.getByText("Not signed in")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Log in" })).toHaveAttribute( + "href", + "https://wwwtest.popi.art/explore" + ); + expect(screen.getByRole("button", { name: "Use Popi/NewAPI key" })).toBeInTheDocument(); + }); + + it("should save a manually entered Popi/NewAPI key", () => { + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Use Popi/NewAPI key" })); + fireEvent.change(screen.getByPlaceholderText("Paste Popi/NewAPI key"), { + target: { value: " sk-manual-key " }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save key" })); + + expect(mockSetGatewayApiKey).toHaveBeenCalledWith("sk-manual-key"); + expect(screen.getByText("Key saved for this browser")).toBeInTheDocument(); + }); + + it("should show signed-in user information", () => { + setMockUserState({ + userInfo: { + id: 1, + code: "popi-001", + name: "Jiajia", + }, + }); + + render( + + ); + + expect(screen.getByText("Signed in")).toBeInTheDocument(); + expect(screen.getByText("Jiajia")).toBeInTheDocument(); + expect(screen.getByText("popi-001")).toBeInTheDocument(); + }); + it("should render option descriptions", () => { render( { ); const buttons = screen.getAllByRole("button"); - // Should have 4 option buttons - expect(buttons.length).toBe(4); + // Four workflow options plus the manual key action. + expect(buttons.length).toBe(5); }); }); }); diff --git a/src/components/__tests__/WelcomeModal.test.tsx b/src/components/__tests__/WelcomeModal.test.tsx index a7641e72..db3cdce8 100644 --- a/src/components/__tests__/WelcomeModal.test.tsx +++ b/src/components/__tests__/WelcomeModal.test.tsx @@ -89,7 +89,7 @@ describe("WelcomeModal", () => { /> ); - expect(screen.getByText("popiart-node")).toBeInTheDocument(); + expect(screen.getByText("Popi.TV")).toBeInTheDocument(); expect(screen.getByText("New project")).toBeInTheDocument(); expect(screen.getByText("Templates")).toBeInTheDocument(); expect(screen.getByText("Prompt a workflow")).toBeInTheDocument(); @@ -183,7 +183,7 @@ describe("WelcomeModal", () => { fireEvent.click(screen.getByText("Back")); }); - expect(screen.getByText("popiart-node")).toBeInTheDocument(); + expect(screen.getByText("Popi.TV")).toBeInTheDocument(); expect(screen.getByText("New project")).toBeInTheDocument(); }); @@ -203,7 +203,7 @@ describe("WelcomeModal", () => { // Click back fireEvent.click(screen.getByText("Back")); - expect(screen.getByText("popiart-node")).toBeInTheDocument(); + expect(screen.getByText("Popi.TV")).toBeInTheDocument(); }); }); @@ -235,7 +235,7 @@ describe("WelcomeModal", () => { expect(screen.getByTestId("workflow-browser-view")).toBeInTheDocument(); fireEvent.click(screen.getByText("Back")); - expect(screen.getByText("popiart-node")).toBeInTheDocument(); + expect(screen.getByText("Popi.TV")).toBeInTheDocument(); }); it("should call onWorkflowGenerated when a workflow is loaded from browser", () => { diff --git a/src/components/quickstart/QuickstartInitialView.tsx b/src/components/quickstart/QuickstartInitialView.tsx index 88c19244..813e02a0 100644 --- a/src/components/quickstart/QuickstartInitialView.tsx +++ b/src/components/quickstart/QuickstartInitialView.tsx @@ -1,6 +1,9 @@ "use client"; +import { useState } from "react"; import { useI18n } from "@/i18n"; +import { useUserStore } from "@/store/userStore"; +import { LOGIN_REDIRECT_URL } from "@/utils/loginUrl"; interface QuickstartInitialViewProps { onNewProject: () => void; @@ -35,7 +38,7 @@ export function QuickstartInitialView({ {t("quickstart.description")}

-
+
{/* Right column - Options */} @@ -99,6 +102,102 @@ export function QuickstartInitialView({ ); } +function AuthStatusPanel() { + const { t } = useI18n(); + const userInfo = useUserStore((state) => state.userInfo); + const gatewayApiKey = useUserStore((state) => state.gatewayApiKey); + const isLoadingUserInfo = useUserStore((state) => state.isLoadingUserInfo); + const setGatewayApiKey = useUserStore((state) => state.setGatewayApiKey); + const [isKeyEntryOpen, setIsKeyEntryOpen] = useState(false); + const [draftGatewayKey, setDraftGatewayKey] = useState(""); + const [keySaved, setKeySaved] = useState(false); + + const statusLabel = isLoadingUserInfo + ? t("quickstart.authChecking") + : userInfo + ? t("quickstart.authSignedIn") + : gatewayApiKey + ? t("quickstart.gatewayKeyAvailable") + : t("quickstart.authNotSignedIn"); + const statusTone = userInfo || gatewayApiKey ? "bg-emerald-400" : "bg-amber-400"; + + const handleSaveGatewayKey = () => { + const normalizedKey = draftGatewayKey.trim(); + if (!normalizedKey) return; + setGatewayApiKey(normalizedKey); + setDraftGatewayKey(""); + setIsKeyEntryOpen(false); + setKeySaved(true); + }; + + return ( +
+
+
+
{t("quickstart.authStatusLabel")}
+
+ + {statusLabel} +
+
+ {userInfo && ( +
+
{userInfo.name}
+
{userInfo.code}
+
+ )} +
+ + {!userInfo && !isLoadingUserInfo && ( +
+ + {t("quickstart.loginAction")} + + +
+ )} + + {isKeyEntryOpen && ( +
+ setDraftGatewayKey(event.target.value)} + placeholder={t("quickstart.gatewayKeyPlaceholder")} + className="min-w-0 flex-1 rounded-md border border-neutral-700 bg-neutral-950 px-3 py-1.5 text-xs text-neutral-100 outline-none transition-colors placeholder:text-neutral-600 focus:border-neutral-500" + /> + +
+ )} + + {keySaved && ( +
+ {t("quickstart.gatewayKeySaved")} +
+ )} +
+ ); +} + function OptionButton({ onClick, icon, diff --git a/src/i18n/index.tsx b/src/i18n/index.tsx index 7328e52e..ad885342 100644 --- a/src/i18n/index.tsx +++ b/src/i18n/index.tsx @@ -19,7 +19,7 @@ export const LANGUAGE_OPTIONS: { value: Language; label: string }[] = [ const en = { "language.label": "Language", - "brand.name": "popiart-node", + "brand.name": "Popi.TV", "common.back": "Back", "common.next": "Next", "common.cancel": "Cancel", @@ -84,6 +84,16 @@ const en = { "quickstart.promptWorkflow": "Prompt a workflow", "quickstart.promptWorkflowDesc": "Get Gemini to build it", "quickstart.beta": "Beta", + "quickstart.authStatusLabel": "Account", + "quickstart.authChecking": "Checking sign-in...", + "quickstart.authSignedIn": "Signed in", + "quickstart.authNotSignedIn": "Not signed in", + "quickstart.loginAction": "Log in", + "quickstart.useGatewayKey": "Use Popi/NewAPI key", + "quickstart.gatewayKeyPlaceholder": "Paste Popi/NewAPI key", + "quickstart.saveGatewayKey": "Save key", + "quickstart.gatewayKeySaved": "Key saved for this browser", + "quickstart.gatewayKeyAvailable": "Popi/NewAPI key is set", "quickstart.templateExplorerTitle": "Template Explorer", "quickstart.workflowTemplatesTitle": "Workflow Templates", "quickstart.templatesIntro": "Pre-built workflows to help you get started quickly. Select a template to load it into the canvas.", @@ -116,7 +126,7 @@ const en = { "quickstart.template.styleTransfer.description": "Apply style from one image to another", "quickstart.template.sceneComposite.name": "Scene Composite", "quickstart.template.sceneComposite.description": "Combine elements from multiple scenes", - "ftux.welcomeTo": "Welcome to popiart-node", + "ftux.welcomeTo": "Welcome to Popi.TV", "ftux.stepWelcome": "Welcome", "ftux.stepApiKeys": "API Keys", "ftux.stepModelDefaults": "Model Defaults", @@ -735,6 +745,16 @@ const zhCN: Dictionary = { "quickstart.promptWorkflow": "用提示生成工作流", "quickstart.promptWorkflowDesc": "让 Gemini 帮你构建", "quickstart.beta": "测试版", + "quickstart.authStatusLabel": "登录状态", + "quickstart.authChecking": "正在检查登录状态...", + "quickstart.authSignedIn": "已登录", + "quickstart.authNotSignedIn": "未登录", + "quickstart.loginAction": "前往登录", + "quickstart.useGatewayKey": "输入 Popi/NewAPI Key", + "quickstart.gatewayKeyPlaceholder": "粘贴 Popi/NewAPI Key", + "quickstart.saveGatewayKey": "保存 Key", + "quickstart.gatewayKeySaved": "Key 已保存到当前浏览器", + "quickstart.gatewayKeyAvailable": "Popi/NewAPI Key 已设置", "quickstart.templateExplorerTitle": "模板浏览器", "quickstart.workflowTemplatesTitle": "工作流模板", "quickstart.templatesIntro": "使用预置工作流快速开始。选择一个模板即可加载到画布。", @@ -767,7 +787,7 @@ const zhCN: Dictionary = { "quickstart.template.styleTransfer.description": "将一张图的风格应用到另一张图", "quickstart.template.sceneComposite.name": "场景合成", "quickstart.template.sceneComposite.description": "组合多个场景中的元素", - "ftux.welcomeTo": "欢迎使用 popiart-node", + "ftux.welcomeTo": "欢迎使用 Popi.TV", "ftux.stepWelcome": "欢迎", "ftux.stepApiKeys": "API 密钥", "ftux.stepModelDefaults": "模型默认值", @@ -1396,6 +1416,16 @@ const zhTW: Dictionary = { "quickstart.promptWorkflow": "用提示生成工作流程", "quickstart.promptWorkflowDesc": "讓 Gemini 幫你建立", "quickstart.beta": "測試版", + "quickstart.authStatusLabel": "登入狀態", + "quickstart.authChecking": "正在檢查登入狀態...", + "quickstart.authSignedIn": "已登入", + "quickstart.authNotSignedIn": "未登入", + "quickstart.loginAction": "前往登入", + "quickstart.useGatewayKey": "輸入 Popi/NewAPI Key", + "quickstart.gatewayKeyPlaceholder": "貼上 Popi/NewAPI Key", + "quickstart.saveGatewayKey": "儲存 Key", + "quickstart.gatewayKeySaved": "Key 已儲存到目前瀏覽器", + "quickstart.gatewayKeyAvailable": "Popi/NewAPI Key 已設定", "quickstart.templateExplorerTitle": "範本瀏覽器", "quickstart.workflowTemplatesTitle": "工作流程範本", "quickstart.templatesIntro": "使用預建工作流程快速開始。選擇一個範本即可載入畫布。", @@ -1428,7 +1458,7 @@ const zhTW: Dictionary = { "quickstart.template.styleTransfer.description": "將一張圖的風格套用到另一張圖", "quickstart.template.sceneComposite.name": "場景合成", "quickstart.template.sceneComposite.description": "組合多個場景中的元素", - "ftux.welcomeTo": "歡迎使用 popiart-node", + "ftux.welcomeTo": "歡迎使用 Popi.TV", "ftux.stepApiKeys": "API 金鑰", "ftux.stepModelDefaults": "模型預設值", "ftux.stepReady": "就緒", @@ -1901,6 +1931,16 @@ const ja: Dictionary = { "quickstart.promptWorkflow": "プロンプトで作成", "quickstart.promptWorkflowDesc": "Gemini に構築してもらう", "quickstart.beta": "ベータ", + "quickstart.authStatusLabel": "ログイン状態", + "quickstart.authChecking": "ログイン状態を確認中...", + "quickstart.authSignedIn": "ログイン済み", + "quickstart.authNotSignedIn": "未ログイン", + "quickstart.loginAction": "ログイン", + "quickstart.useGatewayKey": "Popi/NewAPI Key を入力", + "quickstart.gatewayKeyPlaceholder": "Popi/NewAPI Key を貼り付け", + "quickstart.saveGatewayKey": "Key を保存", + "quickstart.gatewayKeySaved": "Key をこのブラウザに保存しました", + "quickstart.gatewayKeyAvailable": "Popi/NewAPI Key 設定済み", "quickstart.templateExplorerTitle": "テンプレート一覧", "quickstart.workflowTemplatesTitle": "ワークフローテンプレート", "quickstart.templatesIntro": "すぐに始められる事前作成済みワークフローです。テンプレートを選ぶとキャンバスに読み込まれます。", @@ -1933,7 +1973,7 @@ const ja: Dictionary = { "quickstart.template.styleTransfer.description": "ある画像のスタイルを別の画像に適用", "quickstart.template.sceneComposite.name": "シーン合成", "quickstart.template.sceneComposite.description": "複数のシーンの要素を組み合わせる", - "ftux.welcomeTo": "popiart-node へようこそ", + "ftux.welcomeTo": "Popi.TV へようこそ", "ftux.stepWelcome": "ようこそ", "ftux.stepApiKeys": "API キー", "ftux.stepModelDefaults": "モデル既定値", diff --git a/src/proxy.ts b/src/proxy.ts index 89823890..89d5ad89 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,4 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; +import { + LEGACY_NEWAPIWG_API_KEY_HEADER, + NEWAPIWG_API_KEY_HEADER, +} from "@/utils/gatewayHeaders"; const AUTH_EXEMPT_API_PREFIXES = [ "/api/env-status", @@ -7,6 +11,11 @@ const AUTH_EXEMPT_API_PREFIXES = [ ]; function hasLoginToken(request: NextRequest): boolean { + const gatewayApiKey = + request.headers.get(NEWAPIWG_API_KEY_HEADER)?.trim() || + request.headers.get(LEGACY_NEWAPIWG_API_KEY_HEADER)?.trim(); + if (gatewayApiKey) return true; + const token = request.headers.get("token")?.trim(); if (token) return true; diff --git a/src/store/__tests__/userStore.test.ts b/src/store/__tests__/userStore.test.ts index d4994482..6827e91a 100644 --- a/src/store/__tests__/userStore.test.ts +++ b/src/store/__tests__/userStore.test.ts @@ -234,7 +234,7 @@ describe("useUserStore.fetchUserInfo", () => { expect(openLoginRequiredModal).not.toHaveBeenCalled(); }); - it("opens the login-required modal when no token is available", async () => { + it("quietly clears login state when no token is available", async () => { const fetcher = vi.fn(); vi.stubGlobal("fetch", fetcher); @@ -242,6 +242,21 @@ describe("useUserStore.fetchUserInfo", () => { useUserStore.getState().fetchUserInfo(" ") ).resolves.toBeNull(); expect(fetcher).not.toHaveBeenCalled(); - expect(openLoginRequiredModal).toHaveBeenCalledTimes(1); + expect(openLoginRequiredModal).not.toHaveBeenCalled(); + }); + + it("keeps a manually entered gateway key when no token is available", async () => { + const fetcher = vi.fn(); + vi.stubGlobal("fetch", fetcher); + + useUserStore.getState().setGatewayApiKey(" manual-gateway-key "); + + await expect( + useUserStore.getState().fetchUserInfo(" ") + ).resolves.toBeNull(); + + expect(fetcher).not.toHaveBeenCalled(); + expect(useUserStore.getState().gatewayApiKey).toBe("manual-gateway-key"); + expect(openLoginRequiredModal).not.toHaveBeenCalled(); }); }); diff --git a/src/store/userStore.ts b/src/store/userStore.ts index 3d947105..f39c29e5 100644 --- a/src/store/userStore.ts +++ b/src/store/userStore.ts @@ -7,7 +7,6 @@ import type { UserSession, UserStoreState, } from "@/types"; -import { openLoginRequiredModal } from "@/utils/loginRequiredModal"; export const USER_SESSION_STORAGE_KEY = "node-banana-user-session"; export const USER_INFO_ENDPOINT = "/api/user/info"; @@ -138,6 +137,11 @@ export const useUserStore = create()( userInfoError: null, gatewayApiKeyError: null, setToken: (token) => set({ token }), + setGatewayApiKey: (apiKey) => + set({ + gatewayApiKey: apiKey?.trim() || null, + gatewayApiKeyError: null, + }), fetchGatewayApiKey: async () => { const token = get().token?.trim() || getStoredLoginToken(); if (!token) { @@ -176,11 +180,10 @@ export const useUserStore = create()( set({ token: null, userInfo: null, - gatewayApiKey: null, + isLoadingUserInfo: false, userInfoError: null, gatewayApiKeyError: null, }); - openLoginRequiredModal(); return null; } @@ -235,6 +238,7 @@ export const useUserStore = create()( partialize: (state) => ({ token: state.token, userInfo: state.userInfo, + gatewayApiKey: state.gatewayApiKey, }), } ) diff --git a/src/types/user.ts b/src/types/user.ts index 2c408d07..74b945d8 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -64,6 +64,7 @@ export interface UserStoreState { userInfoError: string | null; gatewayApiKeyError: string | null; setToken: (token: string | null) => void; + setGatewayApiKey: (apiKey: string | null) => void; fetchUserInfo: (token: string) => Promise; fetchGatewayApiKey: () => Promise; clearGatewayApiKey: () => void; diff --git a/src/utils/loginRequiredModal.ts b/src/utils/loginRequiredModal.ts index 634044f1..fcd28f52 100644 --- a/src/utils/loginRequiredModal.ts +++ b/src/utils/loginRequiredModal.ts @@ -1,6 +1,5 @@ import { Modal, type ModalFuncProps } from "antd"; - -const LOGIN_REDIRECT_URL = "https://wwwtest.popi.art/explore"; +import { LOGIN_REDIRECT_URL } from "@/utils/loginUrl"; interface LoginRequiredModalRef { destroy: () => void; diff --git a/src/utils/loginUrl.ts b/src/utils/loginUrl.ts new file mode 100644 index 00000000..a1f50a1c --- /dev/null +++ b/src/utils/loginUrl.ts @@ -0,0 +1 @@ +export const LOGIN_REDIRECT_URL = "https://wwwtest.popi.art/explore";