Feat: Add password change feature and update initial account security

This commit is contained in:
choibk 2026-01-23 22:09:07 +09:00
parent b853d35f2e
commit 0b327c8cf6
5 changed files with 150 additions and 5 deletions

View File

@ -1,6 +1,6 @@
import { useState, useEffect, useMemo } from 'react';
import axios from 'axios';
import { Key, Plus, Copy, CheckCircle, Clock, Search, Users, ChevronRight, ArrowLeft, Trash2, LogOut } from 'lucide-react';
import { Key, Plus, Copy, CheckCircle, Clock, Search, Users, ChevronRight, ArrowLeft, Trash2, LogOut, X, Loader2, Lock } from 'lucide-react';
import Login from './components/Login';
// Axios settings for cookies
@ -24,6 +24,13 @@ const App = () => {
const [user, setUser] = useState<any>(null);
const [authChecked, setAuthChecked] = useState(false);
const [licenses, setLicenses] = useState<License[]>([]);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const [passwordForm, setPasswordForm] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
const [passwordChanging, setPasswordChanging] = useState(false);
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [copySuccess, setCopySuccess] = useState<number | null>(null);
@ -108,6 +115,32 @@ const App = () => {
}
};
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
return alert('새 비밀번호가 일치하지 않습니다.');
}
if (passwordForm.newPassword.length < 4) {
return alert('비밀번호는 4자 이상이어야 합니다.');
}
setPasswordChanging(true);
try {
await axios.post(`${API_BASE}/auth/change-password`, {
currentPassword: passwordForm.currentPassword,
newPassword: passwordForm.newPassword
});
alert('비밀번호가 성공적으로 변경되었습니다. 보안을 위해 다시 로그인해 주세요.');
handleLogout();
setShowPasswordModal(false);
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
} catch (err: any) {
alert(err.response?.data?.error || '비밀번호 변경에 실패했습니다.');
} finally {
setPasswordChanging(false);
}
};
useEffect(() => {
if (user) {
fetchLicenses();
@ -197,7 +230,10 @@ const App = () => {
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center font-bold text-slate-500 text-xs">
{user?.name?.[0] || 'A'}
</div>
<span className="text-sm font-bold text-slate-700">{user?.name}</span>
<div className="flex flex-col -space-y-0.5">
<span className="text-sm font-bold text-slate-700">{user?.name}</span>
<button onClick={() => setShowPasswordModal(true)} className="text-[10px] text-indigo-500 font-bold hover:underline text-left"> </button>
</div>
</div>
<button onClick={handleLogout} className="flex items-center gap-2 text-slate-400 hover:text-red-500 transition-all group">
<span className="text-xs font-bold"></span>
@ -380,6 +416,90 @@ const App = () => {
</section>
</main>
{/* Password Change Modal */}
{showPasswordModal && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/40 backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-white w-full max-w-md rounded-3xl shadow-2xl border border-slate-200 overflow-hidden animate-in zoom-in-95 duration-200">
<div className="p-6 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
<div className="flex items-center gap-3">
<div className="bg-indigo-600 p-2 rounded-xl text-white">
<Lock size={18} />
</div>
<h3 className="text-lg font-bold"> </h3>
</div>
<button
onClick={() => {
setShowPasswordModal(false);
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
}}
className="p-2 hover:bg-slate-200 rounded-full transition-all text-slate-400"
>
<X size={20} />
</button>
</div>
<form onSubmit={handleChangePassword} className="p-6 space-y-5">
<div className="space-y-1.5">
<label className="block text-[11px] font-bold text-slate-400 uppercase tracking-wider ml-1"> </label>
<input
type="password"
required
value={passwordForm.currentPassword}
onChange={e => setPasswordForm({ ...passwordForm, currentPassword: e.target.value })}
placeholder="현재 비밀번호 입력"
className="w-full px-4 py-3 bg-slate-50 border border-slate-100 rounded-2xl focus:bg-white focus:ring-4 focus:ring-indigo-50 focus:border-indigo-500 outline-none transition-all text-sm"
/>
</div>
<div className="space-y-1.5">
<label className="block text-[11px] font-bold text-slate-400 uppercase tracking-wider ml-1"> </label>
<input
type="password"
required
value={passwordForm.newPassword}
onChange={e => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
placeholder="새 비밀번호 입력 (4자 이상)"
className="w-full px-4 py-3 bg-slate-50 border border-slate-100 rounded-2xl focus:bg-white focus:ring-4 focus:ring-indigo-50 focus:border-indigo-500 outline-none transition-all text-sm"
/>
</div>
<div className="space-y-1.5">
<label className="block text-[11px] font-bold text-slate-400 uppercase tracking-wider ml-1"> </label>
<input
type="password"
required
value={passwordForm.confirmPassword}
onChange={e => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
placeholder="새 비밀번호 다시 입력"
className="w-full px-4 py-3 bg-slate-50 border border-slate-100 rounded-2xl focus:bg-white focus:ring-4 focus:ring-indigo-50 focus:border-indigo-500 outline-none transition-all text-sm"
/>
</div>
<div className="pt-2 flex gap-3">
<button
type="button"
onClick={() => setShowPasswordModal(false)}
className="flex-1 px-4 py-3.5 bg-slate-100 text-slate-600 font-bold rounded-2xl hover:bg-slate-200 transition-all text-sm"
>
</button>
<button
type="submit"
disabled={passwordChanging}
className="flex-3 bg-slate-900 text-white font-bold py-3.5 px-8 rounded-2xl hover:bg-indigo-600 transition-all shadow-lg flex items-center justify-center gap-2 disabled:opacity-50"
>
{passwordChanging ? (
<>
<Loader2 size={18} className="animate-spin" />
<span> ...</span>
</>
) : (
<span> </span>
)}
</button>
</div>
</form>
</div>
</div>
)}
<style>{`
.custom-scrollbar::-webkit-scrollbar { width: 5px; height: 5px; }
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }

View File

@ -21,7 +21,7 @@ Synology NAS 환경에서 통합 운영을 위해 Express 서버가 빌드된
최초 로그인 시 아래 계정을 사용하세요.
- **ID**: `admin`
- **Password**: `^Ocean1472bk`
- **Password**: 보안을 위해 문서에서 관리하지 않습니다 (최초 1회 `admin1234` 접속 후 바로 변경 권장)
> [!NOTE]
> 만약 로그인이 되지 않는다면 서버 터미널에서 `node init_db.js`를 실행하여 계정을 생성해 주세요.

View File

@ -8,7 +8,10 @@
### 로그인
- **주소**: `http://[NAS-IP 또는 도메인]:3006`
- **초기 계정**: `admin` / `^Ocean1472bk`
- **초기 계정**: `admin` / `admin1234`
> [!IMPORTANT]
> 보안을 위해 최초 접속 후 우측 상단 사용자 이름 아래의 **'비밀번호 변경'** 버튼을 클릭하여 비밀번호를 반드시 수정해 주시기 바랍니다.
### 라이선스 신규 발급
1. 왼쪽 **'라이선스 신규 발급'** 패널에서 정보를 입력합니다.

View File

@ -56,7 +56,7 @@ async function init() {
// Seed Initial Admin
const bcrypt = require('bcryptjs');
const adminId = 'admin';
const adminPw = '^Ocean1472bk';
const adminPw = 'admin1234';
const [existing] = await connection.query('SELECT id FROM users WHERE login_id = ?', [adminId]);
if (existing.length === 0) {

View File

@ -81,6 +81,28 @@ app.post('/api/auth/logout', (req, res) => {
res.json({ success: true });
});
app.post('/api/auth/change-password', authenticateToken, async (req, res) => {
const { currentPassword, newPassword } = req.body;
const userId = req.user.id;
try {
const [users] = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
if (users.length === 0) return res.status(404).json({ error: 'User not found' });
const user = users[0];
const validPassword = await bcrypt.compare(currentPassword, user.password);
if (!validPassword) return res.status(400).json({ error: '현재 비밀번호가 일치하지 않습니다.' });
const hashedPw = await bcrypt.hash(newPassword, 10);
await db.query('UPDATE users SET password = ? WHERE id = ?', [hashedPw, userId]);
res.json({ success: true, message: '비밀번호가 성공적으로 변경되었습니다.' });
} catch (err) {
console.error(err);
res.status(500).json({ error: '비밀번호 변경 실패' });
}
});
// --- License Routes ---
// 1. Get All Issued Licenses
app.get('/api/licenses', authenticateToken, async (req, res) => {