fix: restore admin password reset and video history reuse
This commit is contained in:
@@ -124,7 +124,7 @@ All routes in this section require admin unless noted.
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/admin/dashboard` | `src/app/api/admin/dashboard/route.ts` | Console dashboard aggregate stats and recent activity. |
|
||||
| GET | `/api/admin/stats` | `src/app/api/admin/stats/route.ts` | Additional admin stats. |
|
||||
| GET/PUT/DELETE | `/api/admin/users` | `src/app/api/admin/users/route.ts` | List/update/delete users. GET returns `watermark_disabled`; PUT accepts `watermarkDisabled`/`watermark_disabled` so admins can grant or revoke no-watermark downloads for an individual user without changing that user's membership tier. |
|
||||
| GET/PUT/DELETE | `/api/admin/users` | `src/app/api/admin/users/route.ts` | List/update/delete users. GET returns `watermark_disabled`; PUT accepts profile fields, `watermarkDisabled`/`watermark_disabled`, and `newPassword`. `newPassword` is admin-only and upserts `auth.users.password_hash` with PostgreSQL `crypt(..., gen_salt('bf'))` so the user can immediately log in with the reset password. |
|
||||
| POST | `/api/admin/clear-users` | `src/app/api/admin/clear-users/route.ts` | Dangerous user cleanup controlled by env switch. |
|
||||
| GET/POST/PUT | `/api/admin/orders` | `src/app/api/admin/orders/route.ts` | List/create/update orders. |
|
||||
| GET/POST/PUT/DELETE | `/api/admin/redeem-codes` | `src/app/api/admin/redeem-codes/route.ts` | Admin redeem-code management. GET lists codes by status/search, POST generates 1-500 unique single-use credit or membership codes, PUT enables/disables unused codes, and DELETE removes unused codes. Membership-code payloads include `membershipTier`, `membershipDurationValue`, and `membershipDurationUnit` (`day`, `month`, `year`). The redeem-code management UI also saves the shared external mall URL through `/api/site-config` as `redeemCodeMallUrl`. |
|
||||
|
||||
@@ -74,7 +74,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
|
||||
| Gallery shows many historical/imported works or thumbnail rules differ between all/category/search | `src/app/gallery/page.tsx`, `src/app/api/gallery/route.ts`, `src/lib/creation-history-store.ts` | The gallery page should render only `/api/gallery` rows, not merge browser localStorage published/history records and not call `syncPublishedToSupabase()` on load. `/api/gallery` should apply `category`/`q` filters server-side against public works with stable `/api/local-storage/...` result URLs so all, text-to-image, image-to-image, text-to-video, image-to-video, and search share the same thumbnail/original split. |
|
||||
| Generated image preview zooms but cannot be dragged | `src/components/lightbox.tsx`, `src/components/fullscreen-preview.tsx` | Result-card previews use `ImageLightbox`; after zooming above 100%, panning should be bound to the whole preview stage as well as the image so mouse drag remains available even when the transformed image extends beyond its original element box. Keep wheel zoom, double-click zoom/reset, right-click actions, and ESC close intact. |
|
||||
| Create page loads slowly and console shows CORS errors for historical `coze-codingproject.tos.coze.site` images | `src/components/create/cached-preview-image.tsx`, `src/components/create/text-to-image.tsx`, `src/app/api/download/route.ts` | Hidden mobile history must not mount desktop-side image effects, and cross-origin historical result images should render through the same-origin `/api/download?disposition=inline` proxy before canvas preview generation. Otherwise every hidden history image can issue a blocked browser request and slow the page. |
|
||||
| Creation detail, gallery one-click reuse, or inspiration reuse buttons do not fill create forms or switch tabs | `src/components/creation-detail-dialog.tsx`, `src/app/gallery/page.tsx`, `src/components/create/inspiration-gallery-dialog.tsx`, `src/lib/creation-reuse.ts`, `src/app/create/page.tsx`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx` | Reuse actions should write the shared draft key/event for `text2img`, `img2img`, `text2video`, or `img2video`, route to the matching `/create?type=...` when leaving gallery, and already-mounted create panels should react to the event. Image-to-image and image-to-video reuse should include stored reference images from the work; when intentionally using the generated output as the new reference, fall back to the original output `url`, never `thumbnailUrl`. |
|
||||
| Creation detail, gallery one-click reuse, or inspiration reuse buttons do not fill create forms or switch tabs | `src/components/creation-detail-dialog.tsx`, `src/app/gallery/page.tsx`, `src/components/create/inspiration-gallery-dialog.tsx`, `src/lib/creation-reuse.ts`, `src/app/create/page.tsx`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx` | Reuse actions should write the shared draft key/event for `text2img`, `img2img`, `text2video`, or `img2video`, route to the matching `/create?type=...` when leaving gallery, and already-mounted create panels should react to the event. Creation detail's `复用配置` must also support video history: text-to-video records write `text2video`, image-to-video records write `img2video`. Image-to-image and image-to-video reuse should include stored reference images from the work; when intentionally using the generated output as the new reference, fall back to the original output `url`, never `thumbnailUrl`. |
|
||||
| Image generation count dropdown too wide, options missing, or manual count input unavailable | `src/components/create/image-count-combobox.tsx`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx` | Use the shared compact combobox instead of browser `datalist`; verify manual numeric entry and dropdown options in both text-to-image and image-to-image panels. |
|
||||
| Generated image result hover actions are unreadable in light theme | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx` | The result-card hover overlay owns the Preview/Share/Download buttons. These buttons should use fixed dark translucent backgrounds and white text/icons so light and dark themes have the same readable hover action style. |
|
||||
| Generated image is pushed down by a long prompt in the desktop result column | `src/components/create/text-to-image.tsx` | The result prompt above new images should be a compact two-line summary with the full prompt only in the title tooltip/history detail. Do not render the entire prompt as an unbounded paragraph in the live result column. |
|
||||
@@ -135,7 +135,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
|
||||
| Symptom | Check Files | What To Verify |
|
||||
| --- | --- | --- |
|
||||
| Dashboard stats wrong | `src/modules/console/pages/console-dashboard-page.tsx`, `src/app/api/admin/dashboard/route.ts`, `src/app/api/admin/stats/route.ts` | SQL source tables, safe query fallbacks, admin auth. |
|
||||
| User management bug | `src/components/admin/user-management-tab.tsx`, `src/app/api/admin/users/route.ts` | Role/tier mapping, active flag, admin auth. |
|
||||
| User management bug | `src/components/admin/user-management-tab.tsx`, `src/app/api/admin/users/route.ts`, `src/lib/admin-users-service.ts` | Role/tier mapping, active flag, admin auth, password reset, and row actions. Admin reset-password must be reachable directly from the user row, not only behind the edit modal, and should clear/close overlapping edit state before showing the reset form. `/api/admin/users` PUT with `newPassword` must upsert `auth.users.password_hash` with `crypt(..., gen_salt('bf'))` so missing auth rows cannot silently return success without a usable password. |
|
||||
| API/model management list opens for anonymous users or admin tab loads empty provider/recommendation rows | `src/components/admin/api-management-tab.tsx`, `src/app/api/admin/providers/route.ts`, `src/app/api/admin/model-recommendations/route.ts`, `src/lib/admin-auth.ts` | Provider and recommendation GET routes require admin auth just like mutations. The admin tab's initial `fetch('/api/admin/providers')` and `fetch('/api/admin/model-recommendations')` must include bearer auth headers; anonymous requests should return 401. |
|
||||
| Order/payment bug | `src/components/admin/order-management-tab.tsx`, `src/components/admin/payment-tab.tsx`, `src/app/api/admin/orders/route.ts`, `src/app/api/admin/payment-methods/route.ts`, `src/lib/server-payment-config.ts` | Payment config encryption, order status, request shape. |
|
||||
| Data import/export bug | `src/components/admin/data-management-tab.tsx`, `src/app/api/admin/data-export/route.ts`, `src/app/api/admin/data-import/route.ts`, `scripts/migration-integrity-check.mjs`, `scripts/migration-integrity-check-helpers.mjs` | JSON format, table coverage, admin auth, `_media` coverage, import transaction/savepoints, password hash/encrypted secret preservation, and work dedupe by URL/source URL/media SHA scoped to the same `user_id` so one user's private work cannot collapse into another user's row. Run `pnpm run migration:check` for the read-only migration gate. The checker should default to the web port 8000 and use bounded timeout/concurrency helpers for `/api/local-storage/*` probes so one slow or missing media URL is reported rather than crashing the whole script. |
|
||||
|
||||
@@ -120,7 +120,7 @@ Use this document to jump directly to code before broad searching.
|
||||
| --- | --- | --- |
|
||||
| Console login | `src/app/console/page.tsx`, `src/modules/console/pages/console-login-page.tsx` | `src/app/api/auth/login/route.ts` with `adminOnly` |
|
||||
| Console dashboard | `src/app/console/dashboard/page.tsx`, `src/modules/console/pages/console-dashboard-page.tsx` | `src/app/api/admin/dashboard/route.ts`, `src/app/api/admin/stats/route.ts`. The dashboard page owns the mobile admin shell classes (`console-mobile-page`, `console-mobile-main`, `console-mobile-content`) used by `src/app/globals.css` to keep cards constrained and admin tables horizontally scrollable on phones. |
|
||||
| Users | `src/components/admin/user-management-tab.tsx` | `src/app/api/admin/users/route.ts`, `src/lib/admin-users-service.ts`, `src/app/api/admin/clear-users/route.ts`, `src/app/api/admin/invitations/route.ts`. The user-management UI has separate subpages for `用户列表` and `邀请注册记录`; invitation records have independent search, pagination, total count, inviter/invitee details, invite code, reward amounts, and creation time. Admins can edit an individual user's `下载无水印` switch, which writes `profiles.watermark_disabled` through `/api/admin/users` without changing the membership tier. |
|
||||
| Users | `src/components/admin/user-management-tab.tsx` | `src/app/api/admin/users/route.ts`, `src/lib/admin-users-service.ts`, `src/app/api/admin/clear-users/route.ts`, `src/app/api/admin/invitations/route.ts`. The user-management UI has separate subpages for `用户列表` and `邀请注册记录`; invitation records have independent search, pagination, total count, inviter/invitee details, invite code, reward amounts, and creation time. Row actions include recharge, reset password, edit, and delete. Admin reset-password opens a separate reset form and `/api/admin/users` PUT upserts `auth.users.password_hash` for the target user. Admins can edit an individual user's `下载无水印` switch, which writes `profiles.watermark_disabled` through `/api/admin/users` without changing the membership tier. |
|
||||
| API/model management | `src/components/admin/api-management-tab.tsx` | `src/app/api/admin/providers/route.ts`, `src/app/api/admin/system-apis/route.ts`, `src/app/api/admin/system-apis/smart-import/route.ts`, `src/app/api/admin/model-recommendations/route.ts` |
|
||||
| Pricing | `src/components/admin/pricing-tab.tsx` | Admin store/site config related routes |
|
||||
| Redeem codes | `src/components/admin/redeem-code-management-tab.tsx` | `src/app/api/admin/redeem-codes/route.ts`, `src/lib/redeem-code-service.ts`, `src/app/api/site-config/route.ts`. Admins can generate one or many unique single-use redeem codes, choose credit-code or membership-code type, set credit amount or membership tier plus duration in days/months/years, copy generated codes, and manage unused code status. The same tab has a `商城链接配置` dialog that saves `site_config.redeem_code_mall_url`; frontend credit get-code buttons and membership upgrade buttons open that URL. |
|
||||
|
||||
56
scripts/test-admin-password-and-video-reuse.mjs
Normal file
56
scripts/test-admin-password-and-video-reuse.mjs
Normal file
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
async function runTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
await runTest('admin user management exposes reset password without hiding it behind edit modal', () => {
|
||||
const source = read('src/components/admin/user-management-tab.tsx');
|
||||
|
||||
assert.match(source, /const startResetPassword = \(user: ManagedUser\) => \{/);
|
||||
assert.match(source, /setResetPwUser\(user\)/);
|
||||
assert.match(source, /setNewPassword\(''\)/);
|
||||
assert.match(source, /setEditingUser\(null\)/);
|
||||
assert.match(source, /onClick=\{\(\) => startResetPassword\(user\)\}/);
|
||||
assert.match(source, /<KeyRound className="h-3\.5 w-3\.5" \/>重置密码/);
|
||||
assert.match(source, /onClick=\{\(\) => startResetPassword\(editingUser\)\}/);
|
||||
});
|
||||
|
||||
await runTest('admin password reset upserts auth credentials instead of silently updating zero rows', () => {
|
||||
const source = read('src/lib/admin-users-service.ts');
|
||||
|
||||
assert.match(source, /INSERT INTO auth\.users \(id, email, password_hash, created_at\)/);
|
||||
assert.match(source, /VALUES \(\$1, \$2, crypt\(\$3, gen_salt\('bf'\)\), NOW\(\)\)/);
|
||||
assert.match(source, /ON CONFLICT \(id\) DO UPDATE SET password_hash = crypt\(\$3, gen_salt\('bf'\)\)/);
|
||||
assert.match(source, /\[userId,\s*currentResult\.rows\[0\]\.email,\s*newPassword\]/);
|
||||
});
|
||||
|
||||
await runTest('creation detail reuse supports text-to-video and image-to-video history records', () => {
|
||||
const source = read('src/components/creation-detail-dialog.tsx');
|
||||
|
||||
assert.match(source, /buildCreationReuseDraft,\s*writeCreationReuseDraft/);
|
||||
assert.match(source, /function getReuseTarget\(record: CreationRecord\)/);
|
||||
assert.match(source, /return mode === 'img2video' \? 'img2video' : 'text2video'/);
|
||||
assert.match(source, /const target = getReuseTarget\(record\)/);
|
||||
assert.match(source, /writeCreationReuseDraft\(target,\s*draft\)/);
|
||||
assert.match(source, /router\.push\(`\/create\?type=\$\{target\}&reuse=\$\{encodeURIComponent\(record\.id\)\}`\)/);
|
||||
assert.doesNotMatch(source, /disabled=\{record\.type !== 'image'\}/);
|
||||
assert.doesNotMatch(source, /当前仅支持将图片创作配置复用到文生图/);
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
@@ -228,6 +228,12 @@ export default function UserManagementTab() {
|
||||
setEditWatermarkDisabled(user.watermarkDisabled === true);
|
||||
};
|
||||
|
||||
const startResetPassword = (user: ManagedUser) => {
|
||||
setResetPwUser(user);
|
||||
setNewPassword('');
|
||||
setEditingUser(null);
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editingUser) return;
|
||||
// Save to localStorage (admin-store)
|
||||
@@ -698,6 +704,9 @@ export default function UserManagementTab() {
|
||||
<Button variant="ghost" size="sm" className="gap-1 text-xs" onClick={() => startRecharge(user)}>
|
||||
<Coins className="h-3.5 w-3.5" />充值
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="gap-1 text-xs" onClick={() => startResetPassword(user)}>
|
||||
<KeyRound className="h-3.5 w-3.5" />重置密码
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => startEdit(user)}>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -977,10 +986,7 @@ export default function UserManagementTab() {
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-1.5 text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
setResetPwUser(editingUser);
|
||||
setNewPassword('');
|
||||
}}
|
||||
onClick={() => startResetPassword(editingUser)}
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />重置密码
|
||||
</Button>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { type CreationRecord, deleteCreationRecord, isPlaceholder, shareToGallery, isUrlPublished } from '@/lib/creation-history-store';
|
||||
import { buildImageCreationReuseDraft, writeImageCreationReuseDraft } from '@/lib/creation-reuse';
|
||||
import { type CreationRecord, deleteCreationRecord, getCreationMode, isPlaceholder, shareToGallery, isUrlPublished } from '@/lib/creation-history-store';
|
||||
import { buildCreationReuseDraft, writeCreationReuseDraft, type CreationReuseTarget } from '@/lib/creation-reuse';
|
||||
import { copyTextToClipboard, downloadFile, getImageDownloadExtension } from '@/lib/utils';
|
||||
import { useAuth } from '@/lib/auth-store';
|
||||
import {
|
||||
@@ -131,6 +131,18 @@ function getRecordReferenceImages(record: CreationRecord): string[] {
|
||||
return [...new Set([...single, ...fromArray, ...fromParams].filter(url => url && !url.startsWith('data:') && !url.startsWith('[')))];
|
||||
}
|
||||
|
||||
function getReuseTarget(record: CreationRecord): CreationReuseTarget | null {
|
||||
if (record.type === 'image') {
|
||||
const mode = getCreationMode(record);
|
||||
return mode === 'img2img' ? 'img2img' : 'text2img';
|
||||
}
|
||||
if (record.type === 'video') {
|
||||
const mode = getCreationMode(record);
|
||||
return mode === 'img2video' ? 'img2video' : 'text2video';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function CreationDetailDialog({ record, open, onClose, onPublishChange, onDelete }: CreationDetailDialogProps) {
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
@@ -263,15 +275,25 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
|
||||
};
|
||||
|
||||
const handleReuseConfig = () => {
|
||||
if (record.type !== 'image') {
|
||||
toast.info('当前仅支持将图片创作配置复用到文生图');
|
||||
const target = getReuseTarget(record);
|
||||
if (!target) {
|
||||
toast.info('当前作品暂不支持复用配置');
|
||||
return;
|
||||
}
|
||||
const draft = buildImageCreationReuseDraft(record, 'text2img');
|
||||
writeImageCreationReuseDraft('text2img', draft);
|
||||
const draft = buildCreationReuseDraft(record, target, {
|
||||
source: 'creation-detail',
|
||||
useOutputAsReference: target === 'img2img' || target === 'img2video',
|
||||
});
|
||||
writeCreationReuseDraft(target, draft);
|
||||
onClose();
|
||||
router.push(`/create?type=text2img&reuse=${encodeURIComponent(record.id)}`);
|
||||
toast.success('已填入文生图');
|
||||
router.push(`/create?type=${target}&reuse=${encodeURIComponent(record.id)}`);
|
||||
const targetLabel: Record<CreationReuseTarget, string> = {
|
||||
text2img: '文生图',
|
||||
img2img: '图生图',
|
||||
text2video: '文生视频',
|
||||
img2video: '图生视频',
|
||||
};
|
||||
toast.success(`已填入${targetLabel[target]}`);
|
||||
};
|
||||
|
||||
const handleEditOutput = () => {
|
||||
@@ -279,8 +301,8 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
|
||||
toast.info('当前作品没有可用图片,无法作为图生图参考图');
|
||||
return;
|
||||
}
|
||||
const draft = buildImageCreationReuseDraft(record, 'img2img');
|
||||
writeImageCreationReuseDraft('img2img', draft);
|
||||
const draft = buildCreationReuseDraft(record, 'img2img', { source: 'creation-detail', useOutputAsReference: true });
|
||||
writeCreationReuseDraft('img2img', draft);
|
||||
onClose();
|
||||
router.push(`/create?type=img2img&reuse=${encodeURIComponent(record.id)}`);
|
||||
toast.success('已填入图生图');
|
||||
@@ -738,7 +760,7 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
|
||||
variant="secondary"
|
||||
className="h-10 min-w-[102px] gap-1.5 px-3 text-sm font-semibold text-blue-600 hover:text-blue-700 dark:text-blue-300"
|
||||
onClick={handleReuseConfig}
|
||||
disabled={record.type !== 'image'}
|
||||
disabled={!getReuseTarget(record)}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
复用配置
|
||||
|
||||
@@ -194,10 +194,10 @@ export async function updateAdminUser(client: PoolClient, body: Record<string, u
|
||||
return { status: 400, body: { error: '密码至少6位' } };
|
||||
}
|
||||
await client.query(
|
||||
`UPDATE auth.users
|
||||
SET password_hash = crypt($1, gen_salt('bf'))
|
||||
WHERE id = $2`,
|
||||
[newPassword, userId]
|
||||
`INSERT INTO auth.users (id, email, password_hash, created_at)
|
||||
VALUES ($1, $2, crypt($3, gen_salt('bf')), NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET password_hash = crypt($3, gen_salt('bf'))`,
|
||||
[userId, currentResult.rows[0].email, newPassword]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user