feat: 支持 OAuth 无密码用户设置密码和查看 API Key

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-03 13:39:54 +08:00
5 changed files with 178 additions and 58 deletions

View File

@@ -90,6 +90,33 @@ router.post('/refresh', async (req, res) => {
}
});
const setPasswordSchema = z.object({
password: z.string().min(8),
});
router.post('/set-password', requireAuth, async (req, res) => {
const parsed = setPasswordSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } });
return;
}
const user = await prisma.user.findUnique({ where: { id: req.user!.userId } });
if (!user) {
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'User not found' } });
return;
}
if (user.passwordHash) {
res.status(400).json({ success: false, error: { code: 'ALREADY_HAS_PASSWORD', message: 'Password already set. Use change-password instead.' } });
return;
}
const passwordHash = await hashPassword(parsed.data.password);
await prisma.user.update({ where: { id: user.id }, data: { passwordHash } });
res.json({ success: true, data: { message: 'Password set successfully' } });
});
const changePasswordSchema = z.object({
currentPassword: z.string(),
newPassword: z.string().min(8),
@@ -143,13 +170,14 @@ router.put('/profile', requireAuth, async (req, res) => {
router.get('/me', requireAuth, async (req, res) => {
const user = await prisma.user.findUnique({
where: { id: req.user!.userId },
select: { id: true, email: true, name: true, avatarUrl: true },
select: { id: true, email: true, name: true, avatarUrl: true, passwordHash: true },
});
if (!user) {
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'User not found' } });
return;
}
res.json({ success: true, data: user });
const { passwordHash, ...rest } = user;
res.json({ success: true, data: { ...rest, hasPassword: !!passwordHash } });
});
// --- API Key Management ---

View File

@@ -108,6 +108,29 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
}
};
const handleSetPassword = async () => {
if (newPassword !== confirmPassword) {
setPasswordMsg({ type: 'error', text: t('dashboard.settings.passwordMismatch') });
return;
}
setPasswordLoading(true);
setPasswordMsg(null);
try {
await apiFetch('/auth/set-password', {
method: 'POST', body: JSON.stringify({ password: newPassword }),
});
setPasswordMsg({ type: 'success', text: t('dashboard.settings.passwordSet') });
updateUser({ hasPassword: true });
setNewPassword('');
setConfirmPassword('');
setTimeout(() => setPasswordMsg(null), 3000);
} catch (err) {
setPasswordMsg({ type: 'error', text: err instanceof Error ? err.message : 'Failed to set password' });
} finally {
setPasswordLoading(false);
}
};
// API Key handlers
const handleGenerateKey = async () => {
setKeyLoading(true);
@@ -139,6 +162,8 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
}
};
const hasPassword = user?.hasPassword !== false;
const handleVerifyAndAction = async () => {
setVerifyLoading(true);
setVerifyError('');
@@ -307,32 +332,51 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
</button>
</div>
{/* Password prompt inline */}
{showPasswordPrompt && (
<div className="p-3 rounded-lg border border-border-default bg-bg-primary space-y-2 animate-fade-in">
<p className="text-[13px] text-text-secondary">
{t('dashboard.settings.passwordPrompt', {
action: showPasswordPrompt === 'copy'
? t('dashboard.settings.passwordPromptCopy')
: t('dashboard.settings.passwordPromptReveal'),
})}
</p>
<input
type="password"
value={verifyPassword}
onChange={(e) => setVerifyPassword(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && verifyPassword) handleVerifyAndAction(); }}
className="input-base"
placeholder={t('dashboard.settings.currentPassword')}
autoFocus
/>
{verifyError && <p className="text-[12px] text-danger">{verifyError}</p>}
<div className="flex gap-2">
<button onClick={handleVerifyAndAction} disabled={verifyLoading || !verifyPassword} className="btn-primary text-[13px] py-1.5">
{verifyLoading ? t('dashboard.settings.verifying') : t('common.confirm')}
</button>
<button onClick={() => setShowPasswordPrompt(null)} className="btn-ghost text-[13px] py-1.5">{t('common.cancel')}</button>
</div>
{hasPassword ? (
<>
<p className="text-[13px] text-text-secondary">
{t('dashboard.settings.passwordPrompt', {
action: showPasswordPrompt === 'copy'
? t('dashboard.settings.passwordPromptCopy')
: t('dashboard.settings.passwordPromptReveal'),
})}
</p>
<input
type="password"
value={verifyPassword}
onChange={(e) => setVerifyPassword(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && verifyPassword) handleVerifyAndAction(); }}
className="input-base"
placeholder={t('dashboard.settings.currentPassword')}
autoFocus
/>
{verifyError && <p className="text-[12px] text-danger">{verifyError}</p>}
<div className="flex gap-2">
<button onClick={handleVerifyAndAction} disabled={verifyLoading || !verifyPassword} className="btn-primary text-[13px] py-1.5">
{verifyLoading ? t('dashboard.settings.verifying') : t('common.confirm')}
</button>
<button onClick={() => setShowPasswordPrompt(null)} className="btn-ghost text-[13px] py-1.5">{t('common.cancel')}</button>
</div>
</>
) : (
<>
<p className="text-[13px] text-text-secondary">{t('dashboard.settings.setPasswordToReveal')}</p>
<div className="flex gap-2">
<button
onClick={() => {
setShowPasswordPrompt(null);
document.getElementById('set-password-section')?.scrollIntoView({ behavior: 'smooth' });
}}
className="btn-primary text-[13px] py-1.5"
>
{t('dashboard.settings.setPasswordAction')}
</button>
<button onClick={() => setShowPasswordPrompt(null)} className="btn-ghost text-[13px] py-1.5">{t('common.cancel')}</button>
</div>
</>
)}
</div>
)}
@@ -358,38 +402,72 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
</section>
{/* Password */}
<section className="border-t border-border-default pt-5">
<p className="section-title">{t('dashboard.settings.changePasswordTitle')}</p>
<p className="section-desc mb-4">{t('dashboard.settings.changePasswordDesc')}</p>
<div className="space-y-3">
<div>
<label className="block text-[13px] text-text-secondary mb-1.5">{t('dashboard.settings.currentPasswordLabel')}</label>
<input type="password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} className="input-base" placeholder={t('dashboard.settings.enterCurrentPassword')} />
</div>
<div>
<label className="block text-[13px] text-text-secondary mb-1.5">{t('dashboard.settings.newPasswordLabel')}</label>
<input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} className="input-base" placeholder={t('dashboard.settings.atLeast8Chars')} minLength={8} />
</div>
<div>
<label className="block text-[13px] text-text-secondary mb-1.5">{t('dashboard.settings.confirmPasswordLabel')}</label>
<input type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} className="input-base" placeholder={t('dashboard.settings.confirmNewPassword')} />
</div>
{passwordMsg && (
<div className={`p-3 rounded-lg text-[13px] flex items-center gap-2 ${passwordMsg.type === 'success' ? 'bg-success-muted text-success' : 'bg-danger-muted text-danger'}`}>
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
{passwordMsg.type === 'success' ? <path d="M5 13l4 4L19 7" /> : <path d="M6 18L18 6M6 6l12 12" />}
</svg>
{passwordMsg.text}
<section id="set-password-section" className="border-t border-border-default pt-5">
{hasPassword ? (
<>
<p className="section-title">{t('dashboard.settings.changePasswordTitle')}</p>
<p className="section-desc mb-4">{t('dashboard.settings.changePasswordDesc')}</p>
<div className="space-y-3">
<div>
<label className="block text-[13px] text-text-secondary mb-1.5">{t('dashboard.settings.currentPasswordLabel')}</label>
<input type="password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} className="input-base" placeholder={t('dashboard.settings.enterCurrentPassword')} />
</div>
<div>
<label className="block text-[13px] text-text-secondary mb-1.5">{t('dashboard.settings.newPasswordLabel')}</label>
<input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} className="input-base" placeholder={t('dashboard.settings.atLeast8Chars')} minLength={8} />
</div>
<div>
<label className="block text-[13px] text-text-secondary mb-1.5">{t('dashboard.settings.confirmPasswordLabel')}</label>
<input type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} className="input-base" placeholder={t('dashboard.settings.confirmNewPassword')} />
</div>
{passwordMsg && (
<div className={`p-3 rounded-lg text-[13px] flex items-center gap-2 ${passwordMsg.type === 'success' ? 'bg-success-muted text-success' : 'bg-danger-muted text-danger'}`}>
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
{passwordMsg.type === 'success' ? <path d="M5 13l4 4L19 7" /> : <path d="M6 18L18 6M6 6l12 12" />}
</svg>
{passwordMsg.text}
</div>
)}
<button
onClick={handlePasswordChange}
disabled={passwordLoading || !currentPassword || !newPassword || newPassword.length < 8}
className="btn-primary"
>
{passwordLoading ? t('dashboard.settings.changingPassword') : t('dashboard.settings.changePassword')}
</button>
</div>
)}
<button
onClick={handlePasswordChange}
disabled={passwordLoading || !currentPassword || !newPassword || newPassword.length < 8}
className="btn-primary"
>
{passwordLoading ? t('dashboard.settings.changingPassword') : t('dashboard.settings.changePassword')}
</button>
</div>
</>
) : (
<>
<p className="section-title">{t('dashboard.settings.setPasswordTitle')}</p>
<p className="section-desc mb-4">{t('dashboard.settings.setPasswordDesc')}</p>
<div className="space-y-3">
<div>
<label className="block text-[13px] text-text-secondary mb-1.5">{t('dashboard.settings.newPasswordLabel')}</label>
<input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} className="input-base" placeholder={t('dashboard.settings.atLeast8Chars')} minLength={8} />
</div>
<div>
<label className="block text-[13px] text-text-secondary mb-1.5">{t('dashboard.settings.confirmPasswordLabel')}</label>
<input type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} className="input-base" placeholder={t('dashboard.settings.confirmNewPassword')} />
</div>
{passwordMsg && (
<div className={`p-3 rounded-lg text-[13px] flex items-center gap-2 ${passwordMsg.type === 'success' ? 'bg-success-muted text-success' : 'bg-danger-muted text-danger'}`}>
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
{passwordMsg.type === 'success' ? <path d="M5 13l4 4L19 7" /> : <path d="M6 18L18 6M6 6l12 12" />}
</svg>
{passwordMsg.text}
</div>
)}
<button
onClick={handleSetPassword}
disabled={passwordLoading || !newPassword || newPassword.length < 8 || newPassword !== confirmPassword}
className="btn-primary"
>
{passwordLoading ? t('dashboard.settings.settingPassword') : t('dashboard.settings.setPassword')}
</button>
</div>
</>
)}
</section>
</div>
</dialog>

View File

@@ -1,7 +1,7 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
import { getAccessToken, clearTokens, setTokens, apiFetch } from './api';
type User = { id: string; email: string; name: string };
type User = { id: string; email: string; name: string; hasPassword?: boolean };
type AuthContextType = {
user: User | null;

View File

@@ -375,6 +375,13 @@ const en = {
'dashboard.settings.enterCurrentPassword': 'Enter current password',
'dashboard.settings.atLeast8Chars': 'At least 8 characters',
'dashboard.settings.confirmNewPassword': 'Confirm new password',
'dashboard.settings.setPasswordTitle': 'Set Password',
'dashboard.settings.setPasswordDesc': 'You signed in with a third-party account. Set a password to reveal or copy your API key.',
'dashboard.settings.setPassword': 'Set Password',
'dashboard.settings.settingPassword': 'Setting...',
'dashboard.settings.passwordSet': 'Password set successfully',
'dashboard.settings.setPasswordToReveal': 'Set a password first to reveal your API key.',
'dashboard.settings.setPasswordAction': 'Set Password',
};
export default en;

View File

@@ -377,6 +377,13 @@ const zh: Record<TranslationKey, string> = {
'dashboard.settings.enterCurrentPassword': '输入当前密码',
'dashboard.settings.atLeast8Chars': '至少 8 个字符',
'dashboard.settings.confirmNewPassword': '确认新密码',
'dashboard.settings.setPasswordTitle': '设置密码',
'dashboard.settings.setPasswordDesc': '您通过第三方账号登录,设置密码后可以查看或复制 API Key。',
'dashboard.settings.setPassword': '设置密码',
'dashboard.settings.settingPassword': '设置中...',
'dashboard.settings.passwordSet': '密码设置成功',
'dashboard.settings.setPasswordToReveal': '请先设置密码才能查看 API Key。',
'dashboard.settings.setPasswordAction': '设置密码',
};
export default zh;