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