feat: add protected login workspace

This commit is contained in:
2026-05-19 15:18:13 +08:00
parent aa03bae91e
commit 091a19556c
14 changed files with 1064 additions and 6 deletions

View File

@@ -11,3 +11,10 @@ SEEDANCE_API_BASE=https://ark.cn-beijing.volces.com/api/v3
# 生产环境填写公网入口,用于把 /api/img/... 补成 Seedance 可访问的绝对 URL。
PUBLIC_APP_URL=
# Web login. Local Docker can use any private values; production values only live on VPS.
WEB_AUTH_USERNAME=
WEB_AUTH_PASSWORD=
WEB_AUTH_SESSION_SECRET=
WEB_AUTH_COOKIE_NAME=ai_toy_session
WEB_AUTH_COOKIE_SECURE=false

View File

@@ -803,6 +803,52 @@
"message": "fix: polish regeneration controls",
"hash": "254c2c3",
"files_changed": 2
},
{
"ts": "2026-05-19T14:45:29+08:00",
"type": "commit",
"message": "fix: polish regeneration controls",
"hash": "aa03bae",
"files_changed": 2
},
{
"ts": "2026-05-19T06:50:03Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 分支 master · 1 项未提交变更 · 最近提交fix: polish regeneration controls",
"files_changed": 1
},
{
"ts": "2026-05-19T14:56:30+08:00",
"type": "commit",
"message": "auto-save 2026-05-19 14:56 (+5, ~1)",
"hash": "010cb44",
"files_changed": 8
},
{
"ts": "2026-05-19T07:00:03Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 分支 master · 8 项未提交变更 · 最近提交auto-save 2026-05-19 14:56 (+5, ~1)",
"files_changed": 8
},
{
"ts": "2026-05-19T15:01:55+08:00",
"type": "commit",
"message": "auto-save 2026-05-19 15:01 (+1, ~8, -1)",
"hash": "e723029",
"files_changed": 9
},
{
"ts": "2026-05-19T07:10:03Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 分支 master · 2 项未提交变更 · 最近提交auto-save 2026-05-19 15:01 (+1, ~8, -1)",
"files_changed": 2
},
{
"ts": "2026-05-19T15:12:47+08:00",
"type": "commit",
"message": "auto-save 2026-05-19 15:12 (~2)",
"hash": "d3e29e7",
"files_changed": 2
}
]
}

View File

@@ -5,12 +5,38 @@
"kind": "tool",
"created": "2026-05-18",
"urls": [
{ "type": "app", "url": "https://ai-toy.kang-kang.com", "label": "VPS 生产" },
{ "type": "app", "url": "http://localhost:4560", "label": "本地 Docker" }
{
"type": "app",
"url": "https://ai-toy.kang-kang.com",
"label": "VPS 生产"
},
{
"type": "app",
"url": "https://ai-toy.kang-kang.com/login",
"label": "VPS 登录"
},
{
"type": "app",
"url": "http://localhost:4560",
"label": "本地 Docker"
}
],
"credentials": [
{ "name": "OPENAI_API_KEY", "env": "OPENAI_API_KEY", "note": "GPT 文本/结构化/图片生成;没填则图片 mock" },
{ "name": "SEEDANCE_API_KEY", "env": "SEEDANCE_API_KEY", "note": "Seedance 视频生成;没填则视频接口不可用" }
{
"name": "OPENAI_API_KEY",
"env": "OPENAI_API_KEY",
"note": "GPT 文本/结构化/图片生成;没填则图片 mock"
},
{
"name": "SEEDANCE_API_KEY",
"env": "SEEDANCE_API_KEY",
"note": "Seedance 视频生成;没填则视频接口不可用"
},
{
"name": "WEB_LOGIN",
"env": "WEB_AUTH_USERNAME/WEB_AUTH_PASSWORD/WEB_AUTH_SESSION_SECRET",
"note": "网页登录;生产值只放 VPS deploy/.env.production 和 /root/ai-toy-patent-workflow-login.txt"
}
],
"ports": [
{
@@ -28,5 +54,11 @@
"Docker Compose local/prod parity",
"Coolify Traefik"
],
"ownership": "personal"
"ownership": "personal",
"quick_login": {
"label": "AI Toy Patent / 登录",
"url": "https://ai-toy.kang-kang.com/login",
"username": "kangwan",
"password": "22668050fb50f6e95cb5e32c"
}
}

View File

@@ -12,13 +12,17 @@
- 服务名 / 容器名:`ai-toy-patent-workflow`
- 服务器路径:`/opt/ai-toy-patent-workflow`
- 主站 / 前端https://ai-toy.kang-kang.com
- 登录页https://ai-toy.kang-kang.com/login
- 本地 Dockerhttp://localhost:4560
- API / 后端:内置 Next.js API Route生产同域名
- 文档 / 解析:无
- 管理后台:无
## 快捷登录
- 无登录系统(本地工具)
- 已启用应用内登录页
- 登录地址:`https://ai-toy.kang-kang.com/login`
- 用户名 / 密码:见 `.project.json.quick_login`;生产备份在 VPS `/root/ai-toy-patent-workflow-login.txt`
- 会话HttpOnly HMAC Cookie生产变量只放 VPS `deploy/.env.production`
## 元数据回写清单
- 改公网域名或迁移部署时,更新 `.project.json.urls` + 本节
@@ -39,9 +43,13 @@
- `SEEDANCE_MODEL` — 默认 `doubao-seedance-2-0-260128`
- `SEEDANCE_API_BASE` — 默认 `https://ark.cn-beijing.volces.com/api/v3`
- `PUBLIC_APP_URL` — 生产填公网入口,用于把 `/api/img/...` 补成 Seedance 可访问的绝对 URL
- `WEB_AUTH_USERNAME` / `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET` — 网页登录;真实值只放 `.env.local` 和 VPS `deploy/.env.production`
- `WEB_AUTH_COOKIE_NAME` — 默认 `ai_toy_session`
- `WEB_AUTH_COOKIE_SECURE` — 本地 `false`,生产 `true`
- 配置位置:`.env.local`gitignored参考 `.env.local.example`
- 生产配置模板:`deploy/.env.production.example`;真实生产值只放 VPS 的 `/opt/ai-toy-patent-workflow/deploy/.env.production`
- 图片生成未配置 GPT Key 时回退 mockSVG 占位图),视频生成不 mock必须配置 Seedance Key
-`/login``/api/auth/*``/api/img/*` 外,页面与 API 都需要登录;`/api/img/*` 保持公开是为了 Seedance 能从公网读取参考图
## 规则
- 全项目规则真源:`/Users/kangwan/Projects/code/20260317-rules-dashboard/RULES.md`

View File

@@ -8,3 +8,10 @@ GPT_API_BASE=https://api.openai.com/v1
SEEDANCE_API_KEY=
SEEDANCE_MODEL=doubao-seedance-2-0-260128
SEEDANCE_API_BASE=https://ark.cn-beijing.volces.com/api/v3
# Web login. Keep real values only on the VPS at deploy/.env.production.
WEB_AUTH_USERNAME=
WEB_AUTH_PASSWORD=
WEB_AUTH_SESSION_SECRET=
WEB_AUTH_COOKIE_NAME=ai_toy_session
WEB_AUTH_COOKIE_SECURE=true

View File

@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import { AUTH_COOKIE_NAME, verifyAuthToken } from '@/lib/auth';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function GET(req: Request) {
const cookie = req.headers.get('cookie') || '';
const token = cookie
.split(';')
.map(part => part.trim())
.find(part => part.startsWith(`${AUTH_COOKIE_NAME}=`))
?.slice(AUTH_COOKIE_NAME.length + 1);
const username = verifyAuthToken(token ? decodeURIComponent(token) : '');
if (!username) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
return NextResponse.json({ ok: true, username });
}

View File

@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import { AUTH_COOKIE_NAME, authCookieSecure, configuredUsername, isAuthConfigured, makeAuthToken, validateCredentials } from '@/lib/auth';
import { recordEvent } from '@/lib/auditDb';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function POST(req: Request) {
if (!isAuthConfigured()) {
recordEvent({ action: 'auth.login_unconfigured', targetType: 'auth', status: 'error', message: 'WEB_AUTH not configured' });
return NextResponse.json({ error: 'WEB_AUTH not configured' }, { status: 503 });
}
const body = await req.json().catch(() => ({})) as { username?: unknown; password?: unknown; remember?: unknown };
const username = String(body.username || '');
const password = String(body.password || '');
const remember = body.remember !== false;
if (!validateCredentials(username, password)) {
recordEvent({ action: 'auth.login_failed', targetType: 'auth', targetId: username.trim() || null, status: 'error' });
return NextResponse.json({ error: '用户名或密码不正确' }, { status: 401 });
}
const ttlSeconds = remember ? 60 * 60 * 24 * 30 : 60 * 60 * 12;
const response = NextResponse.json({ ok: true, username: configuredUsername() });
response.cookies.set({
name: AUTH_COOKIE_NAME,
value: makeAuthToken(configuredUsername(), ttlSeconds),
maxAge: ttlSeconds,
httpOnly: true,
secure: authCookieSecure(),
sameSite: 'lax',
path: '/',
});
recordEvent({ action: 'auth.login_ok', targetType: 'auth', targetId: configuredUsername(), status: 'ok', metadata: { remember } });
return response;
}

View File

@@ -0,0 +1,21 @@
import { NextResponse } from 'next/server';
import { AUTH_COOKIE_NAME, authCookieSecure } from '@/lib/auth';
import { recordEvent } from '@/lib/auditDb';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function POST() {
const response = NextResponse.json({ ok: true });
response.cookies.set({
name: AUTH_COOKIE_NAME,
value: '',
maxAge: 0,
httpOnly: true,
secure: authCookieSecure(),
sameSite: 'lax',
path: '/',
});
recordEvent({ action: 'auth.logout', targetType: 'auth', status: 'ok' });
return response;
}

View File

@@ -139,6 +139,449 @@ input, textarea {
@apply inline-block text-[10px] font-semibold uppercase tracking-[0.18em] text-violet-300/80;
}
/* ===== Login ===== */
.login-page {
position: relative;
min-height: 100vh;
overflow: hidden;
background:
linear-gradient(135deg, rgba(10, 10, 15, 0.96), rgba(18, 18, 28, 0.98)),
linear-gradient(90deg, rgba(139, 92, 246, 0.12), rgba(59, 130, 246, 0.08));
color: #fff;
}
.login-backdrop {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(255,255,255,0.045) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.045) 1px, transparent 1px),
linear-gradient(135deg, rgba(139,92,246,0.18), transparent 48%, rgba(59,130,246,0.14));
background-size: 42px 42px, 42px 42px, auto;
mask-image: linear-gradient(180deg, rgba(0,0,0,0.92), rgba(0,0,0,0.5));
}
.login-shell {
position: relative;
z-index: 1;
min-height: 100vh;
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(360px, 430px);
align-items: center;
gap: 56px;
width: min(1120px, calc(100vw - 48px));
margin: 0 auto;
padding: 42px 0;
}
.login-showcase {
min-width: 0;
}
.login-brand-mark {
display: inline-flex;
align-items: center;
height: 30px;
padding: 0 11px;
border: 1px solid rgba(255,255,255,0.14);
border-radius: 8px;
background: rgba(255,255,255,0.06);
color: rgba(255,255,255,0.76);
font-size: 12px;
font-weight: 700;
}
.login-showcase h1 {
margin: 22px 0 10px;
font-size: clamp(38px, 6vw, 72px);
line-height: 1;
font-weight: 760;
}
.login-showcase p {
max-width: 520px;
margin: 0;
color: rgba(255,255,255,0.52);
font-size: 15px;
line-height: 1.7;
}
.login-character-strip {
margin-top: 34px;
width: min(620px, 100%);
height: 300px;
border: 1px solid rgba(255,255,255,0.08);
border-radius: 8px;
background:
linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0.035)),
rgba(5, 5, 10, 0.45);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08), 0 24px 70px rgba(0,0,0,0.32);
overflow: hidden;
}
.login-panel {
display: grid;
gap: 14px;
padding: 24px;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 8px;
background: rgba(9, 9, 14, 0.76);
backdrop-filter: blur(18px);
box-shadow: 0 24px 90px rgba(0,0,0,0.44), inset 0 1px 0 rgba(255,255,255,0.08);
}
.login-panel__eyebrow {
color: rgba(167,139,250,0.86);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.14em;
}
.login-panel h2 {
margin: 6px 0 4px;
font-size: 22px;
font-weight: 720;
}
.login-field {
display: flex;
align-items: center;
gap: 11px;
height: 46px;
padding: 0 12px;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
background: rgba(255,255,255,0.055);
color: rgba(255,255,255,0.48);
transition: border-color .18s ease, background .18s ease, box-shadow .18s ease;
}
.login-field:focus-within {
border-color: rgba(167,139,250,0.62);
background: rgba(255,255,255,0.08);
box-shadow: 0 0 0 4px rgba(139,92,246,0.16);
}
.login-field input {
min-width: 0;
flex: 1;
height: 100%;
border: 0;
outline: 0;
background: transparent;
color: #fff;
font-size: 15px;
}
.login-field input::placeholder {
color: rgba(255,255,255,0.3);
}
.login-icon-button {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border: 0;
border-radius: 8px;
background: transparent;
color: rgba(255,255,255,0.55);
cursor: pointer;
}
.login-icon-button:hover {
background: rgba(255,255,255,0.08);
color: #fff;
}
.login-options {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 24px;
color: rgba(255,255,255,0.58);
font-size: 13px;
}
.login-options label,
.login-success {
display: inline-flex;
align-items: center;
gap: 7px;
}
.login-options input {
width: 15px;
height: 15px;
accent-color: #8b5cf6;
}
.login-success {
color: #86efac;
}
.login-message {
min-height: 20px;
color: #fca5a5;
font-size: 13px;
}
.login-submit {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 46px;
border: 0;
border-radius: 8px;
background: #fff;
color: #08080c;
font-size: 15px;
font-weight: 720;
cursor: pointer;
transition: transform .18s ease, background .18s ease, opacity .18s ease;
}
.login-submit:hover {
background: #f1edff;
}
.login-submit:active {
transform: translateY(1px);
}
.login-submit:disabled {
cursor: wait;
opacity: .72;
}
.login-character-stage {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.login-stage-grid {
position: absolute;
inset: 22px;
border: 1px dashed rgba(255,255,255,0.12);
border-radius: 8px;
}
.login-characters-container {
position: absolute;
z-index: 1;
left: 50%;
bottom: -8px;
width: 550px;
height: 400px;
transform: translateX(-50%) scale(0.66);
transform-origin: bottom center;
}
.login-character-base {
position: absolute;
left: 28px;
right: 18px;
bottom: -12px;
height: 28px;
border-radius: 999px;
background: radial-gradient(ellipse at center, rgba(0,0,0,0.52), rgba(0,0,0,0) 72%);
filter: blur(1px);
}
.login-figure {
position: absolute;
bottom: 0;
transform-origin: bottom center;
overflow: hidden;
border: 1px solid rgba(255,255,255,0.2);
transition: transform .7s cubic-bezier(.4,0,.2,1), height .55s cubic-bezier(.4,0,.2,1);
filter: drop-shadow(0 34px 32px rgba(0,0,0,0.42));
}
.login-figure::before {
content: "";
position: absolute;
inset: 0;
z-index: 1;
background: radial-gradient(circle at 30% 18%, rgba(255,255,255,0.34), transparent 24%), linear-gradient(90deg, rgba(255,255,255,0.18), transparent 26%, transparent 62%, rgba(0,0,0,0.3));
mix-blend-mode: screen;
}
.login-figure::after {
content: "";
position: absolute;
inset: 0;
z-index: 1;
background: linear-gradient(105deg, rgba(255,255,255,0.2), transparent 30%, rgba(0,0,0,0.26));
box-shadow: inset 12px 0 22px rgba(255,255,255,0.09), inset -18px 0 28px rgba(0,0,0,0.22), inset 0 -28px 36px rgba(0,0,0,0.18);
}
.login-figure__rim {
position: absolute;
inset: 8px;
z-index: 1;
border: 1px solid rgba(255,255,255,0.16);
border-radius: inherit;
}
.login-figure__shine {
position: absolute;
z-index: 1;
left: 18%;
top: 18%;
width: 42%;
height: 11%;
border-radius: 999px;
background: rgba(255,255,255,0.22);
transform: rotate(-14deg);
}
.login-figure__panel {
position: absolute;
z-index: 1;
border: 1px solid rgba(255,255,255,0.13);
background: rgba(255,255,255,0.1);
}
.login-figure__signal {
position: absolute;
z-index: 2;
display: flex;
gap: 6px;
}
.login-figure__signal span {
width: 7px;
height: 7px;
border-radius: 999px;
background: rgba(255,255,255,0.56);
}
.login-figure--purple {
left: 72px;
width: 176px;
height: 392px;
z-index: 1;
background: linear-gradient(165deg, #9b7bff 0%, #6c3ff5 46%, #3e1eb8 100%);
border-radius: 22px 22px 8px 8px;
}
.login-figure--black {
left: 240px;
width: 120px;
height: 310px;
z-index: 2;
background: linear-gradient(165deg, #34343a 0%, #202126 48%, #111217 100%);
border-radius: 18px 18px 6px 6px;
}
.login-figure--orange {
left: 0;
width: 240px;
height: 150px;
z-index: 3;
background: linear-gradient(160deg, #ffb27f 0%, #ff8f61 56%, #dc6f48 100%);
border-radius: 120px 120px 0 0;
}
.login-figure--yellow {
left: 310px;
width: 140px;
height: 230px;
z-index: 4;
background: linear-gradient(160deg, #fff17a 0%, #e8d754 58%, #bfa73a 100%);
border-radius: 70px 70px 0 0;
}
.login-figure--purple .login-figure__panel { left: 22px; bottom: 88px; width: 36px; height: 152px; border-radius: 999px; }
.login-figure--purple .login-figure__signal { left: 26px; bottom: 46px; }
.login-figure--black .login-figure__panel { right: 16px; bottom: 40px; width: 12px; height: 190px; border-radius: 999px; }
.login-figure--black .login-figure__signal { right: 18px; top: 88px; flex-direction: column; }
.login-figure--orange .login-figure__panel { left: 42px; bottom: 24px; width: 88px; height: 18px; border-radius: 999px; }
.login-figure--orange .login-figure__signal { right: 54px; bottom: 28px; }
.login-figure--yellow .login-figure__panel { left: 24px; bottom: 44px; width: 92px; height: 24px; border-radius: 999px; background: rgba(45,45,45,0.08); }
.login-figure--yellow .login-figure__signal { left: 34px; top: 124px; }
.login-figure--yellow .login-figure__signal span,
.login-figure--orange .login-figure__signal span { background: rgba(45,45,45,0.36); }
.login-eyes {
position: absolute;
z-index: 2;
display: flex;
transition: left .35s cubic-bezier(.4,0,.2,1), top .35s cubic-bezier(.4,0,.2,1);
}
.login-eyes--purple { left: 75px; top: 25px; gap: 32px; }
.login-eyes--black { left: 26px; top: 32px; gap: 24px; }
.login-eyes--orange { left: 112px; top: 60px; gap: 32px; }
.login-eyes--yellow { left: 52px; top: 40px; gap: 24px; }
.login-eye {
position: relative;
width: 22px;
height: 22px;
border-radius: 999px;
background: white;
overflow: hidden;
}
.login-eye--small { width: 20px; height: 20px; }
.login-eye::after,
.login-pupil::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 8px;
height: 8px;
border-radius: 50%;
background: #2d2d2d;
transform: translate(var(--eye-x), var(--eye-y));
transition: transform .08s linear, opacity .2s ease;
}
.login-eye::after { margin: -4px 0 0 -4px; }
.login-pupil {
position: relative;
width: 12px;
height: 12px;
border-radius: 50%;
}
.login-pupil::after {
width: 12px;
height: 12px;
margin: -6px 0 0 -6px;
}
.login-mouth {
position: absolute;
z-index: 2;
background: #2d2d2d;
transition: width .35s cubic-bezier(.4,0,.2,1), height .35s cubic-bezier(.4,0,.2,1), border-radius .35s cubic-bezier(.4,0,.2,1), transform .35s cubic-bezier(.4,0,.2,1);
}
.login-mouth--purple { left: 97px; top: 57px; width: 24px; height: 8px; border-radius: 0 0 12px 12px; }
.login-mouth--orange { left: 126px; top: 92px; width: 26px; height: 13px; border-radius: 0 0 13px 13px; }
.login-yellow-mouth {
position: absolute;
z-index: 2;
left: 40px;
top: 88px;
}
.login-yellow-mouth path {
stroke: #2d2d2d;
stroke-width: 3;
fill: none;
stroke-linecap: round;
}
.login-character-stage[data-mood="typing"] .login-figure--purple { height: 430px; transform: skewX(-10deg) translateX(36px); }
.login-character-stage[data-mood="typing"] .login-figure--black { transform: skewX(7deg) translateX(6px); }
.login-character-stage[data-mood="typing"] .login-figure--orange { transform: skewX(-5deg); }
.login-character-stage[data-mood="typing"] .login-figure--yellow { transform: skewX(4deg); }
.login-character-stage[data-mood="typing"] .login-mouth--purple { width: 7px; height: 32px; border-radius: 0; transform: translate(14px, -28px) skewX(10deg); }
.login-character-stage[data-mood="typing"] .login-mouth--orange { width: 14px; height: 14px; border-radius: 50%; transform: translateX(6px); }
.login-character-stage[data-mood="peek"] .login-figure--purple,
.login-character-stage[data-mood="peek"] .login-figure--black { transform: skewX(0deg) translateY(-10px); }
.login-character-stage[data-mood="peek"] .login-eyes--purple { left: 40px; top: 14px; }
.login-character-stage[data-mood="peek"] .login-eyes--black { left: 2px; top: 20px; }
.login-character-stage[data-mood="peek"] .login-eyes--orange { left: 68px; top: 48px; }
.login-character-stage[data-mood="peek"] .login-eyes--yellow { left: 10px; top: 28px; }
.login-character-stage[data-mood="peek"] .login-eye::after,
.login-character-stage[data-mood="peek"] .login-pupil::after { transform: translate(-8px, -6px); }
.login-character-stage[data-mood="error"] .login-characters-container { animation: login-shake .28s ease-in-out 2; }
.login-character-stage[data-mood="error"] .login-eye { height: 10px; margin-top: 4px; }
.login-character-stage[data-mood="error"] .login-mouth--purple,
.login-character-stage[data-mood="error"] .login-mouth--orange { border-radius: 12px 12px 0 0; transform: translateY(10px); }
.login-character-stage[data-mood="success"] .login-characters-container { animation: login-stage-success .75s cubic-bezier(.34,1.56,.64,1) both; }
.login-character-stage[data-mood="success"] .login-mouth--purple { width: 30px; height: 16px; border-radius: 0 0 15px 15px; }
.login-character-stage[data-mood="success"] .login-mouth--orange { width: 32px; height: 18px; border-radius: 0 0 16px 16px; }
@keyframes login-shake {
0%, 100% { translate: 0 0; }
33% { translate: -5px 0; }
66% { translate: 5px 0; }
}
@keyframes login-stage-success {
0% { translate: 0 0; }
45% { translate: 0 -22px; }
100% { translate: 0 -8px; }
}
@media (max-width: 860px) {
.login-shell {
width: min(100vw - 28px, 460px);
grid-template-columns: 1fr;
gap: 18px;
align-content: center;
}
.login-showcase h1 {
font-size: 40px;
}
.login-showcase p {
font-size: 13px;
}
.login-character-strip {
height: 210px;
}
.login-characters-container {
transform: translateX(-50%) scale(0.46);
}
}
/* ===== Divider ===== */
.divider-line {
@apply h-px w-full;

170
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,170 @@
'use client';
import type { FormEvent } from 'react';
import { Suspense, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { AnimatedLoginCharacters, type LoginCharacterMood } from '@/components/login/AnimatedLoginCharacters';
type LoginStatus = 'idle' | 'loading' | 'success';
function Icon({ type }: { type: 'user' | 'lock' | 'eye' | 'eyeOff' | 'arrow' | 'check' }) {
const common = { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2.2, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const };
if (type === 'user') return <svg {...common}><path d="M20 21a8 8 0 0 0-16 0" /><circle cx="12" cy="7" r="4" /></svg>;
if (type === 'lock') return <svg {...common}><rect x="5" y="11" width="14" height="10" rx="2" /><path d="M8 11V7a4 4 0 0 1 8 0v4" /></svg>;
if (type === 'eye') return <svg {...common}><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" /><circle cx="12" cy="12" r="3" /></svg>;
if (type === 'eyeOff') return <svg {...common}><path d="m3 3 18 18" /><path d="M10.6 10.6A3 3 0 0 0 13.4 13.4" /><path d="M9.9 5.2A10.7 10.7 0 0 1 12 5c6.5 0 10 7 10 7a18 18 0 0 1-3.2 4.3" /><path d="M6.4 6.5C3.5 8.3 2 12 2 12s3.5 7 10 7c1.3 0 2.5-.3 3.6-.7" /></svg>;
if (type === 'check') return <svg {...common}><path d="M20 6 9 17l-5-5" /></svg>;
return <svg {...common}><path d="M5 12h14" /><path d="m13 6 6 6-6 6" /></svg>;
}
function LoginInner() {
const searchParams = useSearchParams();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [remember, setRemember] = useState(true);
const [showPassword, setShowPassword] = useState(false);
const [activeField, setActiveField] = useState<'username' | 'password' | null>(null);
const [hasError, setHasError] = useState(false);
const [message, setMessage] = useState('');
const [status, setStatus] = useState<LoginStatus>('idle');
const [eyeOffset, setEyeOffset] = useState({ x: 0, y: 0 });
useEffect(() => {
const onPointerMove = (event: PointerEvent) => {
const centerX = window.innerWidth / 2;
const centerY = window.innerHeight / 2;
const nextX = Math.max(-1, Math.min(1, (event.clientX - centerX) / centerX));
const nextY = Math.max(-1, Math.min(1, (event.clientY - centerY) / centerY));
setEyeOffset({ x: nextX * 8, y: nextY * 5.5 });
};
window.addEventListener('pointermove', onPointerMove);
return () => window.removeEventListener('pointermove', onPointerMove);
}, []);
const disabled = status === 'loading' || status === 'success';
const mood: LoginCharacterMood = useMemo(() => {
if (status === 'success') return 'success';
if (hasError) return 'error';
if (showPassword && activeField === 'password') return 'peek';
if (activeField || username || password) return 'typing';
return 'idle';
}, [activeField, hasError, password, showPassword, status, username]);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setHasError(false);
setMessage('');
if (!username.trim() || !password) {
setHasError(true);
setMessage('账号和密码都要填。');
return;
}
setStatus('loading');
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, remember }),
});
if (!res.ok) throw new Error();
setStatus('success');
const next = searchParams.get('next') || '/';
window.setTimeout(() => { window.location.href = next.startsWith('/') ? next : '/'; }, 350);
} catch {
setStatus('idle');
setHasError(true);
setMessage('账号或密码不对。');
}
}
return (
<main className="login-page">
<div className="login-backdrop" />
<section className="login-shell">
<div className="login-showcase">
<div className="login-brand-mark">AI Toy</div>
<h1>AI Toy Patent</h1>
<p> IP </p>
<div className="login-character-strip">
<AnimatedLoginCharacters mood={mood} eyeOffset={eyeOffset} />
</div>
</div>
<form className="login-panel" onSubmit={onSubmit}>
<div>
<div className="login-panel__eyebrow">Private Workspace</div>
<h2></h2>
</div>
<label className="login-field">
<span><Icon type="user" /></span>
<input
value={username}
disabled={disabled}
autoComplete="username"
placeholder="账号"
onFocus={() => setActiveField('username')}
onBlur={() => setActiveField(null)}
onChange={event => {
setUsername(event.target.value);
if (hasError) setHasError(false);
}}
/>
</label>
<label className="login-field">
<span><Icon type="lock" /></span>
<input
value={password}
disabled={disabled}
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
placeholder="密码"
onFocus={() => setActiveField('password')}
onBlur={() => setActiveField(null)}
onChange={event => {
setPassword(event.target.value);
if (hasError) setHasError(false);
}}
/>
<button
type="button"
className="login-icon-button"
disabled={disabled}
onMouseDown={event => event.preventDefault()}
onClick={() => setShowPassword(value => !value)}
aria-label={showPassword ? '隐藏密码' : '显示密码'}
>
<Icon type={showPassword ? 'eyeOff' : 'eye'} />
</button>
</label>
<div className="login-options">
<label>
<input type="checkbox" checked={remember} disabled={disabled} onChange={event => setRemember(event.target.checked)} />
<span></span>
</label>
{status === 'success' ? <span className="login-success"><Icon type="check" /> </span> : null}
</div>
{message ? <div className="login-message">{message}</div> : null}
<button className="login-submit" type="submit" disabled={disabled}>
<Icon type="arrow" />
{status === 'loading' ? '验证中' : '进入'}
</button>
</form>
</section>
</main>
);
}
export default function LoginPage() {
return (
<Suspense fallback={null}>
<LoginInner />
</Suspense>
);
}

View File

@@ -271,6 +271,11 @@ export default function Home() {
}
}
async function handleLogout() {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
window.location.href = '/login';
}
return (
<div className="flex h-screen text-white">
<Sidebar
@@ -319,6 +324,12 @@ export default function Home() {
</a>
</>
)}
<button
onClick={handleLogout}
className="chip chip-neutral hover:border-violet-300/40 hover:text-white transition-colors"
>
退
</button>
<span className={provider === 'gpt' ? 'chip chip-live' : provider === '?' ? 'chip chip-neutral' : 'chip chip-mock'}>
<span className={`w-1.5 h-1.5 rounded-full ${provider === 'gpt' ? 'bg-emerald-400' : provider === '?' ? 'bg-white/40' : 'bg-amber-400'}`} />
{provider === 'gpt' ? 'GPT · gpt-image-2' : provider === 'mock' ? 'Mock · 占位图' : provider === '?' ? '待连接' : provider}

View File

@@ -0,0 +1,84 @@
'use client';
import type { CSSProperties } from 'react';
export type LoginCharacterMood = 'idle' | 'typing' | 'peek' | 'error' | 'success';
type EyeKind = 'eye' | 'small-eye' | 'pupil';
type FigureSpec = {
id: 'purple' | 'black' | 'orange' | 'yellow';
eyeKind: EyeKind;
mouth?: 'purple' | 'orange' | 'yellow';
marks?: number;
};
const FIGURES: FigureSpec[] = [
{ id: 'purple', eyeKind: 'eye', mouth: 'purple', marks: 3 },
{ id: 'black', eyeKind: 'small-eye', marks: 2 },
{ id: 'orange', eyeKind: 'pupil', mouth: 'orange', marks: 2 },
{ id: 'yellow', eyeKind: 'pupil', mouth: 'yellow', marks: 3 },
];
function Eyes({ figure, kind }: { figure: FigureSpec['id']; kind: EyeKind }) {
const className = kind === 'pupil' ? 'login-pupil' : kind === 'small-eye' ? 'login-eye login-eye--small' : 'login-eye';
return (
<span className={`login-eyes login-eyes--${figure}`}>
<span className={className} />
<span className={className} />
</span>
);
}
function Mouth({ type }: { type: NonNullable<FigureSpec['mouth']> }) {
if (type === 'yellow') {
return (
<span className="login-yellow-mouth">
<svg width="80" height="20" viewBox="0 0 80 20" aria-hidden="true">
<path d="M0 10 Q10 10, 20 10 Q30 10, 40 10 Q50 10, 60 10 Q70 10, 80 10" />
</svg>
</span>
);
}
return <span className={`login-mouth login-mouth--${type}`} />;
}
function Figure({ spec }: { spec: FigureSpec }) {
return (
<div className={`login-figure login-figure--${spec.id}`}>
<span className="login-figure__rim" />
<span className="login-figure__shine" />
<span className="login-figure__panel" />
<span className="login-figure__signal">
{Array.from({ length: spec.marks ?? 0 }).map((_, index) => <span key={index} />)}
</span>
<Eyes figure={spec.id} kind={spec.eyeKind} />
{spec.mouth ? <Mouth type={spec.mouth} /> : null}
</div>
);
}
export function AnimatedLoginCharacters({
mood,
eyeOffset,
}: {
mood: LoginCharacterMood;
eyeOffset: { x: number; y: number };
}) {
return (
<div
className="login-character-stage"
data-mood={mood}
aria-hidden="true"
style={{ '--eye-x': `${eyeOffset.x}px`, '--eye-y': `${eyeOffset.y}px` } as CSSProperties}
>
<div className="login-stage-grid" />
<div className="login-characters-container">
<div className="login-character-base" />
{FIGURES.map(spec => <Figure key={spec.id} spec={spec} />)}
</div>
</div>
);
}

80
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,80 @@
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
export const AUTH_COOKIE_NAME = process.env.WEB_AUTH_COOKIE_NAME?.trim() || 'ai_toy_session';
function authUsername() {
return process.env.WEB_AUTH_USERNAME?.trim() || '';
}
function authPassword() {
return process.env.WEB_AUTH_PASSWORD || '';
}
function authSecret() {
return process.env.WEB_AUTH_SESSION_SECRET || '';
}
export function isAuthConfigured() {
return Boolean(authUsername() && authPassword() && authSecret());
}
export function authCookieSecure() {
const value = process.env.WEB_AUTH_COOKIE_SECURE?.trim().toLowerCase();
if (value) return !['0', 'false', 'no'].includes(value);
const publicUrl = process.env.PUBLIC_APP_URL || process.env.NEXT_PUBLIC_APP_URL || '';
return publicUrl.startsWith('https://');
}
function base64UrlEncode(input: string) {
return Buffer.from(input, 'utf-8').toString('base64url');
}
function base64UrlDecode(input: string) {
return Buffer.from(input, 'base64url').toString('utf-8');
}
function sign(body: string) {
return createHmac('sha256', authSecret()).update(body).digest('hex');
}
function safeCompare(a: string, b: string) {
const left = Buffer.from(a);
const right = Buffer.from(b);
return left.length === right.length && timingSafeEqual(left, right);
}
export function makeAuthToken(username: string, ttlSeconds: number) {
if (!isAuthConfigured()) throw new Error('WEB_AUTH not configured');
const body = base64UrlEncode(JSON.stringify({
u: username,
exp: Math.floor(Date.now() / 1000) + ttlSeconds,
n: randomUUID(),
}));
return `${body}.${sign(body)}`;
}
export function verifyAuthToken(token?: string | null): string | null {
if (!isAuthConfigured() || !token || !token.includes('.')) return null;
const [body, suppliedSig] = token.split('.', 2);
if (!body || !suppliedSig || !safeCompare(sign(body), suppliedSig)) return null;
try {
const payload = JSON.parse(base64UrlDecode(body)) as { u?: unknown; exp?: unknown };
const username = String(payload.u || '');
const expiresAt = Number(payload.exp || 0);
if (username !== authUsername() || !Number.isFinite(expiresAt)) return null;
if (expiresAt < Math.floor(Date.now() / 1000)) return null;
return username;
} catch {
return null;
}
}
export function validateCredentials(username: string, password: string) {
if (!isAuthConfigured()) return false;
return safeCompare(username.trim(), authUsername()) && safeCompare(password, authPassword());
}
export function configuredUsername() {
return authUsername();
}

95
src/middleware.ts Normal file
View File

@@ -0,0 +1,95 @@
import { NextResponse, type NextRequest } from 'next/server';
const COOKIE_NAME = process.env.WEB_AUTH_COOKIE_NAME?.trim() || 'ai_toy_session';
function isPublicPath(pathname: string) {
return (
pathname === '/login' ||
pathname.startsWith('/login/') ||
pathname.startsWith('/_next/') ||
pathname.startsWith('/api/auth/') ||
pathname.startsWith('/api/img/') ||
pathname === '/favicon.ico' ||
pathname === '/robots.txt' ||
pathname === '/sitemap.xml'
);
}
function isBrowserHtml(pathname: string) {
return !pathname.startsWith('/api/') || pathname.startsWith('/api/gallery/') || pathname.startsWith('/api/audit/');
}
function authConfigured() {
return Boolean(process.env.WEB_AUTH_USERNAME && process.env.WEB_AUTH_SESSION_SECRET);
}
function bytesToHex(buffer: ArrayBuffer) {
return [...new Uint8Array(buffer)].map(byte => byte.toString(16).padStart(2, '0')).join('');
}
function safeEqual(a: string, b: string) {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i += 1) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
async function sign(body: string) {
const secret = process.env.WEB_AUTH_SESSION_SECRET || '';
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
return bytesToHex(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(body)));
}
function decodePayload(body: string) {
const base64 = body.replace(/-/g, '+').replace(/_/g, '/');
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
return JSON.parse(atob(padded)) as { u?: unknown; exp?: unknown };
}
async function verifyToken(token?: string) {
if (!authConfigured() || !token || !token.includes('.')) return false;
const [body, suppliedSig] = token.split('.', 2);
if (!body || !suppliedSig || !safeEqual(await sign(body), suppliedSig)) return false;
try {
const payload = decodePayload(body);
const username = String(payload.u || '');
const expiresAt = Number(payload.exp || 0);
return username === process.env.WEB_AUTH_USERNAME && expiresAt >= Math.floor(Date.now() / 1000);
} catch {
return false;
}
}
export async function middleware(req: NextRequest) {
const { pathname, search } = req.nextUrl;
const authed = await verifyToken(req.cookies.get(COOKIE_NAME)?.value);
if (pathname === '/login' || pathname.startsWith('/login/')) {
if (authed) {
const next = req.nextUrl.searchParams.get('next') || '/';
return NextResponse.redirect(new URL(next.startsWith('/') ? next : '/', req.url));
}
return NextResponse.next();
}
if (isPublicPath(pathname)) return NextResponse.next();
if (authed) return NextResponse.next();
if (isBrowserHtml(pathname)) {
const loginUrl = new URL('/login', req.url);
loginUrl.searchParams.set('next', `${pathname}${search}`);
return NextResponse.redirect(loginUrl);
}
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
export const config = {
matcher: ['/((?!.*\\.).*)', '/api/:path*'],
};