diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index 0dd6f07..f3af704 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -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 --- diff --git a/packages/web/src/components/SettingsDialog.tsx b/packages/web/src/components/SettingsDialog.tsx index 64187bd..f5235b1 100644 --- a/packages/web/src/components/SettingsDialog.tsx +++ b/packages/web/src/components/SettingsDialog.tsx @@ -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 - {/* Password prompt inline */} {showPasswordPrompt && (
-

- {t('dashboard.settings.passwordPrompt', { - action: showPasswordPrompt === 'copy' - ? t('dashboard.settings.passwordPromptCopy') - : t('dashboard.settings.passwordPromptReveal'), - })} -

- setVerifyPassword(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter' && verifyPassword) handleVerifyAndAction(); }} - className="input-base" - placeholder={t('dashboard.settings.currentPassword')} - autoFocus - /> - {verifyError &&

{verifyError}

} -
- - -
+ {hasPassword ? ( + <> +

+ {t('dashboard.settings.passwordPrompt', { + action: showPasswordPrompt === 'copy' + ? t('dashboard.settings.passwordPromptCopy') + : t('dashboard.settings.passwordPromptReveal'), + })} +

+ setVerifyPassword(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter' && verifyPassword) handleVerifyAndAction(); }} + className="input-base" + placeholder={t('dashboard.settings.currentPassword')} + autoFocus + /> + {verifyError &&

{verifyError}

} +
+ + +
+ + ) : ( + <> +

{t('dashboard.settings.setPasswordToReveal')}

+
+ + +
+ + )}
)} @@ -358,38 +402,72 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo {/* Password */} -
-

{t('dashboard.settings.changePasswordTitle')}

-

{t('dashboard.settings.changePasswordDesc')}

-
-
- - setCurrentPassword(e.target.value)} className="input-base" placeholder={t('dashboard.settings.enterCurrentPassword')} /> -
-
- - setNewPassword(e.target.value)} className="input-base" placeholder={t('dashboard.settings.atLeast8Chars')} minLength={8} /> -
-
- - setConfirmPassword(e.target.value)} className="input-base" placeholder={t('dashboard.settings.confirmNewPassword')} /> -
- {passwordMsg && ( -
- - {passwordMsg.type === 'success' ? : } - - {passwordMsg.text} +
+ {hasPassword ? ( + <> +

{t('dashboard.settings.changePasswordTitle')}

+

{t('dashboard.settings.changePasswordDesc')}

+
+
+ + setCurrentPassword(e.target.value)} className="input-base" placeholder={t('dashboard.settings.enterCurrentPassword')} /> +
+
+ + setNewPassword(e.target.value)} className="input-base" placeholder={t('dashboard.settings.atLeast8Chars')} minLength={8} /> +
+
+ + setConfirmPassword(e.target.value)} className="input-base" placeholder={t('dashboard.settings.confirmNewPassword')} /> +
+ {passwordMsg && ( +
+ + {passwordMsg.type === 'success' ? : } + + {passwordMsg.text} +
+ )} +
- )} - -
+ + ) : ( + <> +

{t('dashboard.settings.setPasswordTitle')}

+

{t('dashboard.settings.setPasswordDesc')}

+
+
+ + setNewPassword(e.target.value)} className="input-base" placeholder={t('dashboard.settings.atLeast8Chars')} minLength={8} /> +
+
+ + setConfirmPassword(e.target.value)} className="input-base" placeholder={t('dashboard.settings.confirmNewPassword')} /> +
+ {passwordMsg && ( +
+ + {passwordMsg.type === 'success' ? : } + + {passwordMsg.text} +
+ )} + +
+ + )}
diff --git a/packages/web/src/lib/auth.tsx b/packages/web/src/lib/auth.tsx index 47ae7f7..e06649b 100644 --- a/packages/web/src/lib/auth.tsx +++ b/packages/web/src/lib/auth.tsx @@ -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; diff --git a/packages/web/src/lib/i18n/en.ts b/packages/web/src/lib/i18n/en.ts index 3949da1..8443204 100644 --- a/packages/web/src/lib/i18n/en.ts +++ b/packages/web/src/lib/i18n/en.ts @@ -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; diff --git a/packages/web/src/lib/i18n/zh.ts b/packages/web/src/lib/i18n/zh.ts index 9cabad0..4cb447d 100644 --- a/packages/web/src/lib/i18n/zh.ts +++ b/packages/web/src/lib/i18n/zh.ts @@ -377,6 +377,13 @@ const zh: Record = { '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;