feat: 비밀번호 관리 기능 추가
Some checks failed
Build and Push Images / build-backend (push) Has been cancelled
Some checks failed
Build and Push Images / build-backend (push) Has been cancelled
This commit is contained in:
160
backend/main.py
160
backend/main.py
@@ -8,6 +8,8 @@ import psycopg2.extras
|
|||||||
import bcrypt
|
import bcrypt
|
||||||
import jwt
|
import jwt
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
app = FastAPI(title="Web Portal API")
|
app = FastAPI(title="Web Portal API")
|
||||||
@@ -23,6 +25,7 @@ app.add_middleware(
|
|||||||
SECRET_KEY = os.getenv("JWT_SECRET", "supersecretkey1234")
|
SECRET_KEY = os.getenv("JWT_SECRET", "supersecretkey1234")
|
||||||
ALGORITHM = "HS256"
|
ALGORITHM = "HS256"
|
||||||
security = HTTPBearer()
|
security = HTTPBearer()
|
||||||
|
MAX_LOGIN_ATTEMPTS = 5
|
||||||
|
|
||||||
DB_CONFIG = {
|
DB_CONFIG = {
|
||||||
"host": os.getenv("DB_HOST", "postgres-service"),
|
"host": os.getenv("DB_HOST", "postgres-service"),
|
||||||
@@ -48,6 +51,10 @@ def init_db():
|
|||||||
username VARCHAR(100) UNIQUE NOT NULL,
|
username VARCHAR(100) UNIQUE NOT NULL,
|
||||||
password_hash VARCHAR(255) NOT NULL,
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
is_admin BOOLEAN DEFAULT FALSE,
|
is_admin BOOLEAN DEFAULT FALSE,
|
||||||
|
must_change_password BOOLEAN DEFAULT TRUE,
|
||||||
|
login_attempts INTEGER DEFAULT 0,
|
||||||
|
is_locked BOOLEAN DEFAULT FALSE,
|
||||||
|
password_change_requested BOOLEAN DEFAULT FALSE,
|
||||||
created_at TIMESTAMP DEFAULT NOW()
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS webpages (
|
CREATE TABLE IF NOT EXISTS webpages (
|
||||||
@@ -63,18 +70,32 @@ def init_db():
|
|||||||
PRIMARY KEY (user_id, webpage_id)
|
PRIMARY KEY (user_id, webpage_id)
|
||||||
);
|
);
|
||||||
""")
|
""")
|
||||||
|
# 컬럼 추가 (기존 DB 마이그레이션)
|
||||||
|
for col, definition in [
|
||||||
|
("must_change_password", "BOOLEAN DEFAULT TRUE"),
|
||||||
|
("login_attempts", "INTEGER DEFAULT 0"),
|
||||||
|
("is_locked", "BOOLEAN DEFAULT FALSE"),
|
||||||
|
("password_change_requested", "BOOLEAN DEFAULT FALSE"),
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
cur.execute(f"ALTER TABLE users ADD COLUMN IF NOT EXISTS {col} {definition}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 기본 관리자 계정 (최초 로그인 시 비밀번호 변경 불필요)
|
||||||
cur.execute("SELECT id FROM users WHERE username = 'admin'")
|
cur.execute("SELECT id FROM users WHERE username = 'admin'")
|
||||||
if not cur.fetchone():
|
if not cur.fetchone():
|
||||||
hashed = bcrypt.hashpw("admin1234".encode(), bcrypt.gensalt()).decode()
|
hashed = bcrypt.hashpw("admin1234".encode(), bcrypt.gensalt()).decode()
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"INSERT INTO users (username, password_hash, is_admin) VALUES (%s, %s, TRUE)",
|
"INSERT INTO users (username, password_hash, is_admin, must_change_password) VALUES (%s, %s, TRUE, FALSE)",
|
||||||
("admin", hashed)
|
("admin", hashed)
|
||||||
)
|
)
|
||||||
|
# 테스트 일반 사용자 (최초 로그인 시 비밀번호 변경 필요)
|
||||||
cur.execute("SELECT id FROM users WHERE username = 'user1'")
|
cur.execute("SELECT id FROM users WHERE username = 'user1'")
|
||||||
if not cur.fetchone():
|
if not cur.fetchone():
|
||||||
hashed = bcrypt.hashpw("user1234".encode(), bcrypt.gensalt()).decode()
|
hashed = bcrypt.hashpw("user1234".encode(), bcrypt.gensalt()).decode()
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"INSERT INTO users (username, password_hash, is_admin) VALUES (%s, %s, FALSE)",
|
"INSERT INTO users (username, password_hash, is_admin, must_change_password) VALUES (%s, %s, FALSE, TRUE)",
|
||||||
("user1", hashed)
|
("user1", hashed)
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -93,11 +114,12 @@ def startup():
|
|||||||
print(f"DB not ready, retrying... {e}")
|
print(f"DB not ready, retrying... {e}")
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
|
|
||||||
def create_token(user_id: int, username: str, is_admin: bool):
|
def create_token(user_id: int, username: str, is_admin: bool, must_change_password: bool):
|
||||||
payload = {
|
payload = {
|
||||||
"sub": str(user_id),
|
"sub": str(user_id),
|
||||||
"username": username,
|
"username": username,
|
||||||
"is_admin": is_admin,
|
"is_admin": is_admin,
|
||||||
|
"must_change_password": must_change_password,
|
||||||
"exp": datetime.utcnow() + timedelta(hours=8)
|
"exp": datetime.utcnow() + timedelta(hours=8)
|
||||||
}
|
}
|
||||||
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
|
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
@@ -116,6 +138,11 @@ def require_admin(token=Depends(verify_token)):
|
|||||||
raise HTTPException(status_code=403, detail="Admin only")
|
raise HTTPException(status_code=403, detail="Admin only")
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
def generate_temp_password(length=10):
|
||||||
|
chars = string.ascii_letters + string.digits
|
||||||
|
return ''.join(secrets.choice(chars) for _ in range(length))
|
||||||
|
|
||||||
|
# ─── Auth ───────────────────────────────────────────────
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
password: str
|
password: str
|
||||||
@@ -125,15 +152,77 @@ def login(req: LoginRequest, conn=Depends(get_db)):
|
|||||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
cur.execute("SELECT * FROM users WHERE username = %s", (req.username,))
|
cur.execute("SELECT * FROM users WHERE username = %s", (req.username,))
|
||||||
user = cur.fetchone()
|
user = cur.fetchone()
|
||||||
if not user or not bcrypt.checkpw(req.password.encode(), user["password_hash"].encode()):
|
|
||||||
|
if not user:
|
||||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||||
token = create_token(user["id"], user["username"], user["is_admin"])
|
|
||||||
return {"token": token, "username": user["username"], "is_admin": user["is_admin"]}
|
# 계정 잠금 확인
|
||||||
|
if user["is_locked"]:
|
||||||
|
raise HTTPException(status_code=403, detail="Account locked. Please contact admin.")
|
||||||
|
|
||||||
|
# 비밀번호 검증
|
||||||
|
if not bcrypt.checkpw(req.password.encode(), user["password_hash"].encode()):
|
||||||
|
attempts = (user["login_attempts"] or 0) + 1
|
||||||
|
locked = attempts >= MAX_LOGIN_ATTEMPTS
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE users SET login_attempts=%s, is_locked=%s, password_change_requested=%s WHERE id=%s",
|
||||||
|
(attempts, locked, locked, user["id"])
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
if locked:
|
||||||
|
raise HTTPException(status_code=403, detail="Account locked due to too many failed attempts. Please contact admin.")
|
||||||
|
remaining = MAX_LOGIN_ATTEMPTS - attempts
|
||||||
|
raise HTTPException(status_code=401, detail=f"Invalid credentials. {remaining} attempts remaining.")
|
||||||
|
|
||||||
|
# 로그인 성공 - 실패 횟수 초기화
|
||||||
|
cur.execute("UPDATE users SET login_attempts=0 WHERE id=%s", (user["id"],))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
token = create_token(user["id"], user["username"], user["is_admin"], user["must_change_password"])
|
||||||
|
return {
|
||||||
|
"token": token,
|
||||||
|
"username": user["username"],
|
||||||
|
"is_admin": user["is_admin"],
|
||||||
|
"must_change_password": user["must_change_password"]
|
||||||
|
}
|
||||||
|
|
||||||
@app.get("/api/auth/me")
|
@app.get("/api/auth/me")
|
||||||
def me(token=Depends(verify_token)):
|
def me(token=Depends(verify_token)):
|
||||||
return {"username": token["username"], "is_admin": token["is_admin"]}
|
return {
|
||||||
|
"username": token["username"],
|
||||||
|
"is_admin": token["is_admin"],
|
||||||
|
"must_change_password": token.get("must_change_password", False)
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── 비밀번호 변경 (본인) ────────────────────────────────
|
||||||
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
current_password: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
@app.post("/api/auth/change-password")
|
||||||
|
def change_password(req: ChangePasswordRequest, token=Depends(verify_token), conn=Depends(get_db)):
|
||||||
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
user_id = int(token["sub"])
|
||||||
|
cur.execute("SELECT * FROM users WHERE id = %s", (user_id,))
|
||||||
|
user = cur.fetchone()
|
||||||
|
|
||||||
|
if not bcrypt.checkpw(req.current_password.encode(), user["password_hash"].encode()):
|
||||||
|
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
||||||
|
|
||||||
|
if len(req.new_password) < 6:
|
||||||
|
raise HTTPException(status_code=400, detail="New password must be at least 6 characters")
|
||||||
|
|
||||||
|
hashed = bcrypt.hashpw(req.new_password.encode(), bcrypt.gensalt()).decode()
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE users SET password_hash=%s, must_change_password=FALSE, login_attempts=0, is_locked=FALSE WHERE id=%s",
|
||||||
|
(hashed, user_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
new_token = create_token(user["id"], user["username"], user["is_admin"], False)
|
||||||
|
return {"ok": True, "token": new_token}
|
||||||
|
|
||||||
|
# ─── My Pages ───────────────────────────────────────────
|
||||||
@app.get("/api/my-pages")
|
@app.get("/api/my-pages")
|
||||||
def my_pages(token=Depends(verify_token), conn=Depends(get_db)):
|
def my_pages(token=Depends(verify_token), conn=Depends(get_db)):
|
||||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
@@ -148,6 +237,7 @@ def my_pages(token=Depends(verify_token), conn=Depends(get_db)):
|
|||||||
""", (user_id,))
|
""", (user_id,))
|
||||||
return cur.fetchall()
|
return cur.fetchall()
|
||||||
|
|
||||||
|
# ─── Admin: Webpages CRUD ────────────────────────────────
|
||||||
@app.get("/api/admin/webpages")
|
@app.get("/api/admin/webpages")
|
||||||
def list_webpages(token=Depends(require_admin), conn=Depends(get_db)):
|
def list_webpages(token=Depends(require_admin), conn=Depends(get_db)):
|
||||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
@@ -189,10 +279,15 @@ def delete_webpage(page_id: int, token=Depends(require_admin), conn=Depends(get_
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
# ─── Admin: Users ────────────────────────────────────────
|
||||||
@app.get("/api/admin/users")
|
@app.get("/api/admin/users")
|
||||||
def list_users(token=Depends(require_admin), conn=Depends(get_db)):
|
def list_users(token=Depends(require_admin), conn=Depends(get_db)):
|
||||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
cur.execute("SELECT id, username, is_admin, created_at FROM users ORDER BY username")
|
cur.execute("""
|
||||||
|
SELECT id, username, is_admin, must_change_password,
|
||||||
|
login_attempts, is_locked, password_change_requested, created_at
|
||||||
|
FROM users ORDER BY username
|
||||||
|
""")
|
||||||
return cur.fetchall()
|
return cur.fetchall()
|
||||||
|
|
||||||
class UserCreate(BaseModel):
|
class UserCreate(BaseModel):
|
||||||
@@ -206,7 +301,8 @@ def create_user(data: UserCreate, token=Depends(require_admin), conn=Depends(get
|
|||||||
hashed = bcrypt.hashpw(data.password.encode(), bcrypt.gensalt()).decode()
|
hashed = bcrypt.hashpw(data.password.encode(), bcrypt.gensalt()).decode()
|
||||||
try:
|
try:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"INSERT INTO users (username, password_hash, is_admin) VALUES (%s, %s, %s) RETURNING id, username, is_admin",
|
"""INSERT INTO users (username, password_hash, is_admin, must_change_password)
|
||||||
|
VALUES (%s, %s, %s, TRUE) RETURNING id, username, is_admin""",
|
||||||
(data.username, hashed, data.is_admin)
|
(data.username, hashed, data.is_admin)
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -221,6 +317,52 @@ def delete_user(user_id: int, token=Depends(require_admin), conn=Depends(get_db)
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
# ─── Admin: 비밀번호 변경 (관리자가 타 사용자) ──────────────
|
||||||
|
class AdminPasswordChange(BaseModel):
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
@app.put("/api/admin/users/{user_id}/password")
|
||||||
|
def admin_change_password(user_id: int, data: AdminPasswordChange, token=Depends(require_admin), conn=Depends(get_db)):
|
||||||
|
if len(data.new_password) < 6:
|
||||||
|
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
|
||||||
|
cur = conn.cursor()
|
||||||
|
hashed = bcrypt.hashpw(data.new_password.encode(), bcrypt.gensalt()).decode()
|
||||||
|
cur.execute(
|
||||||
|
"""UPDATE users SET password_hash=%s, must_change_password=TRUE,
|
||||||
|
login_attempts=0, is_locked=FALSE, password_change_requested=FALSE
|
||||||
|
WHERE id=%s AND username != 'admin'""",
|
||||||
|
(hashed, user_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
# ─── Admin: 임시 비밀번호 발급 ───────────────────────────
|
||||||
|
@app.post("/api/admin/users/{user_id}/reset-password")
|
||||||
|
def reset_password(user_id: int, token=Depends(require_admin), conn=Depends(get_db)):
|
||||||
|
temp_pw = generate_temp_password()
|
||||||
|
hashed = bcrypt.hashpw(temp_pw.encode(), bcrypt.gensalt()).decode()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""UPDATE users SET password_hash=%s, must_change_password=TRUE,
|
||||||
|
login_attempts=0, is_locked=FALSE, password_change_requested=FALSE
|
||||||
|
WHERE id=%s""",
|
||||||
|
(hashed, user_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True, "temp_password": temp_pw}
|
||||||
|
|
||||||
|
# ─── Admin: 계정 잠금 해제 ───────────────────────────────
|
||||||
|
@app.post("/api/admin/users/{user_id}/unlock")
|
||||||
|
def unlock_user(user_id: int, token=Depends(require_admin), conn=Depends(get_db)):
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE users SET is_locked=FALSE, login_attempts=0 WHERE id=%s",
|
||||||
|
(user_id,)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
# ─── Admin: User Access ──────────────────────────────────
|
||||||
@app.get("/api/admin/users/{user_id}/pages")
|
@app.get("/api/admin/users/{user_id}/pages")
|
||||||
def get_user_pages(user_id: int, token=Depends(require_admin), conn=Depends(get_db)):
|
def get_user_pages(user_id: int, token=Depends(require_admin), conn=Depends(get_db)):
|
||||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
|||||||
@@ -6,89 +6,53 @@
|
|||||||
<title>Web Portal</title>
|
<title>Web Portal</title>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--primary: #2563eb;
|
--primary: #2563eb; --primary-dark: #1d4ed8;
|
||||||
--primary-dark: #1d4ed8;
|
--bg: #f1f5f9; --card: #ffffff; --text: #1e293b;
|
||||||
--bg: #f1f5f9;
|
--muted: #64748b; --danger: #ef4444; --success: #22c55e;
|
||||||
--card: #ffffff;
|
--warning: #f59e0b; --border: #e2e8f0; --admin: #7c3aed;
|
||||||
--text: #1e293b;
|
|
||||||
--muted: #64748b;
|
|
||||||
--danger: #ef4444;
|
|
||||||
--success: #22c55e;
|
|
||||||
--border: #e2e8f0;
|
|
||||||
--admin: #7c3aed;
|
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
|
body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
|
||||||
|
|
||||||
/* ── Login ── */
|
/* ── Login ── */
|
||||||
#login-page {
|
#login-page { display: flex; align-items: center; justify-content: center; min-height: 100vh; background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%); }
|
||||||
display: flex; align-items: center; justify-content: center; min-height: 100vh;
|
.login-box { background: white; border-radius: 16px; padding: 48px 40px; width: 400px; box-shadow: 0 25px 60px rgba(0,0,0,0.3); }
|
||||||
background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%);
|
|
||||||
}
|
|
||||||
.login-box {
|
|
||||||
background: white; border-radius: 16px; padding: 48px 40px; width: 380px;
|
|
||||||
box-shadow: 0 25px 60px rgba(0,0,0,0.3);
|
|
||||||
}
|
|
||||||
.login-logo { text-align: center; margin-bottom: 32px; }
|
.login-logo { text-align: center; margin-bottom: 32px; }
|
||||||
.login-logo svg { width: 56px; height: 56px; }
|
.login-logo svg { width: 56px; height: 56px; }
|
||||||
.login-logo h1 { font-size: 1.5rem; font-weight: 700; color: var(--primary); margin-top: 12px; }
|
.login-logo h1 { font-size: 1.5rem; font-weight: 700; color: var(--primary); margin-top: 12px; }
|
||||||
.login-logo p { color: var(--muted); font-size: 0.875rem; margin-top: 4px; }
|
.login-logo p { color: var(--muted); font-size: 0.875rem; margin-top: 4px; }
|
||||||
.form-group { margin-bottom: 18px; }
|
.form-group { margin-bottom: 18px; }
|
||||||
.form-group label { display: block; font-size: 0.875rem; font-weight: 600; margin-bottom: 6px; color: var(--text); }
|
.form-group label { display: block; font-size: 0.875rem; font-weight: 600; margin-bottom: 6px; }
|
||||||
.form-group input {
|
.form-group input { width: 100%; padding: 11px 14px; border: 2px solid var(--border); border-radius: 8px; font-size: 0.95rem; transition: border-color .2s; }
|
||||||
width: 100%; padding: 11px 14px; border: 2px solid var(--border); border-radius: 8px;
|
|
||||||
font-size: 0.95rem; transition: border-color .2s;
|
|
||||||
}
|
|
||||||
.form-group input:focus { outline: none; border-color: var(--primary); }
|
.form-group input:focus { outline: none; border-color: var(--primary); }
|
||||||
.btn {
|
.btn { width: 100%; padding: 12px; background: var(--primary); color: white; border: none; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: background .2s; }
|
||||||
width: 100%; padding: 12px; background: var(--primary); color: white;
|
|
||||||
border: none; border-radius: 8px; font-size: 1rem; font-weight: 600;
|
|
||||||
cursor: pointer; transition: background .2s;
|
|
||||||
}
|
|
||||||
.btn:hover { background: var(--primary-dark); }
|
.btn:hover { background: var(--primary-dark); }
|
||||||
.btn-sm {
|
.btn-sm { width: auto; padding: 7px 16px; font-size: 0.8rem; border-radius: 6px; }
|
||||||
width: auto; padding: 7px 16px; font-size: 0.8rem; border-radius: 6px;
|
.btn-danger { background: var(--danger); } .btn-danger:hover { background: #dc2626; }
|
||||||
}
|
.btn-success { background: var(--success); } .btn-success:hover { background: #16a34a; }
|
||||||
.btn-danger { background: var(--danger); }
|
.btn-warning { background: var(--warning); color: white; } .btn-warning:hover { background: #d97706; }
|
||||||
.btn-danger:hover { background: #dc2626; }
|
.btn-gray { background: #94a3b8; } .btn-gray:hover { background: #64748b; }
|
||||||
.btn-success { background: var(--success); }
|
.btn-purple { background: var(--admin); } .btn-purple:hover { background: #6d28d9; }
|
||||||
.btn-success:hover { background: #16a34a; }
|
|
||||||
.btn-gray { background: #94a3b8; }
|
|
||||||
.btn-gray:hover { background: #64748b; }
|
|
||||||
.btn-purple { background: var(--admin); }
|
|
||||||
.btn-purple:hover { background: #6d28d9; }
|
|
||||||
.error-msg { color: var(--danger); font-size: 0.85rem; margin-top: 10px; text-align: center; }
|
.error-msg { color: var(--danger); font-size: 0.85rem; margin-top: 10px; text-align: center; }
|
||||||
|
.warning-msg { color: var(--warning); font-size: 0.85rem; margin-top: 10px; text-align: center; }
|
||||||
|
|
||||||
|
/* ── Force Change Password Page ── */
|
||||||
|
#change-pw-page { display: none; align-items: center; justify-content: center; min-height: 100vh; background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%); }
|
||||||
|
.change-pw-box { background: white; border-radius: 16px; padding: 48px 40px; width: 400px; box-shadow: 0 25px 60px rgba(0,0,0,0.3); }
|
||||||
|
.change-pw-box h2 { font-size: 1.3rem; font-weight: 700; margin-bottom: 8px; color: var(--text); }
|
||||||
|
.change-pw-box p { color: var(--muted); font-size: 0.875rem; margin-bottom: 24px; line-height: 1.5; }
|
||||||
|
.notice-box { background: #fef3c7; border: 1px solid #fbbf24; border-radius: 8px; padding: 12px 16px; margin-bottom: 20px; font-size: 0.85rem; color: #92400e; }
|
||||||
|
|
||||||
/* ── App Layout ── */
|
/* ── App Layout ── */
|
||||||
#app-page { display: none; min-height: 100vh; flex-direction: column; }
|
#app-page { display: none; min-height: 100vh; flex-direction: column; }
|
||||||
header {
|
header { background: white; border-bottom: 1px solid var(--border); padding: 0 32px; height: 60px; display: flex; align-items: center; justify-content: space-between; box-shadow: 0 1px 6px rgba(0,0,0,0.06); }
|
||||||
background: white; border-bottom: 1px solid var(--border);
|
|
||||||
padding: 0 32px; height: 60px;
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
box-shadow: 0 1px 6px rgba(0,0,0,0.06);
|
|
||||||
}
|
|
||||||
.header-left { display: flex; align-items: center; gap: 12px; }
|
.header-left { display: flex; align-items: center; gap: 12px; }
|
||||||
.header-logo { font-weight: 700; font-size: 1.1rem; color: var(--primary); }
|
.header-logo { font-weight: 700; font-size: 1.1rem; color: var(--primary); }
|
||||||
.badge-admin {
|
.badge-admin { background: var(--admin); color: white; font-size: 0.7rem; font-weight: 700; padding: 2px 8px; border-radius: 20px; }
|
||||||
background: var(--admin); color: white; font-size: 0.7rem;
|
|
||||||
font-weight: 700; padding: 2px 8px; border-radius: 20px; letter-spacing: .5px;
|
|
||||||
}
|
|
||||||
.header-right { display: flex; align-items: center; gap: 14px; }
|
.header-right { display: flex; align-items: center; gap: 14px; }
|
||||||
.user-chip {
|
.user-chip { display: flex; align-items: center; gap: 7px; background: var(--bg); border-radius: 20px; padding: 5px 14px; font-size: 0.875rem; font-weight: 500; }
|
||||||
display: flex; align-items: center; gap: 7px;
|
nav { background: white; border-bottom: 1px solid var(--border); padding: 0 32px; display: flex; gap: 4px; }
|
||||||
background: var(--bg); border-radius: 20px; padding: 5px 14px;
|
.nav-btn { padding: 14px 18px; border: none; background: none; cursor: pointer; font-size: 0.9rem; color: var(--muted); font-weight: 500; border-bottom: 3px solid transparent; transition: all .2s; }
|
||||||
font-size: 0.875rem; font-weight: 500;
|
|
||||||
}
|
|
||||||
.user-chip svg { color: var(--muted); }
|
|
||||||
nav {
|
|
||||||
background: white; border-bottom: 1px solid var(--border);
|
|
||||||
padding: 0 32px; display: flex; gap: 4px;
|
|
||||||
}
|
|
||||||
.nav-btn {
|
|
||||||
padding: 14px 18px; border: none; background: none; cursor: pointer;
|
|
||||||
font-size: 0.9rem; color: var(--muted); font-weight: 500;
|
|
||||||
border-bottom: 3px solid transparent; transition: all .2s;
|
|
||||||
}
|
|
||||||
.nav-btn.active { color: var(--primary); border-bottom-color: var(--primary); }
|
.nav-btn.active { color: var(--primary); border-bottom-color: var(--primary); }
|
||||||
.nav-btn:hover:not(.active) { color: var(--text); }
|
.nav-btn:hover:not(.active) { color: var(--text); }
|
||||||
main { padding: 32px; flex: 1; }
|
main { padding: 32px; flex: 1; }
|
||||||
@@ -100,63 +64,50 @@
|
|||||||
.page-header h2 { font-size: 1.3rem; font-weight: 700; }
|
.page-header h2 { font-size: 1.3rem; font-weight: 700; }
|
||||||
.page-header p { color: var(--muted); font-size: 0.875rem; margin-top: 2px; }
|
.page-header p { color: var(--muted); font-size: 0.875rem; margin-top: 2px; }
|
||||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px; }
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px; }
|
||||||
.card {
|
.card { background: white; border-radius: 12px; padding: 24px; box-shadow: 0 1px 4px rgba(0,0,0,0.07); border: 1px solid var(--border); transition: transform .15s, box-shadow .15s; cursor: pointer; text-decoration: none; color: inherit; }
|
||||||
background: white; border-radius: 12px; padding: 24px;
|
|
||||||
box-shadow: 0 1px 4px rgba(0,0,0,0.07); border: 1px solid var(--border);
|
|
||||||
transition: transform .15s, box-shadow .15s; cursor: pointer; text-decoration: none; color: inherit;
|
|
||||||
}
|
|
||||||
.card:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,0.1); }
|
.card:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,0.1); }
|
||||||
.card-icon {
|
.card-icon { width: 48px; height: 48px; border-radius: 10px; background: linear-gradient(135deg, var(--primary), #60a5fa); display: flex; align-items: center; justify-content: center; margin-bottom: 14px; }
|
||||||
width: 48px; height: 48px; border-radius: 10px;
|
|
||||||
background: linear-gradient(135deg, var(--primary), #60a5fa);
|
|
||||||
display: flex; align-items: center; justify-content: center; margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
.card-icon svg { color: white; }
|
.card-icon svg { color: white; }
|
||||||
.card h3 { font-size: 1rem; font-weight: 600; margin-bottom: 6px; }
|
.card h3 { font-size: 1rem; font-weight: 600; margin-bottom: 6px; }
|
||||||
.card p { font-size: 0.825rem; color: var(--muted); line-height: 1.5; }
|
.card p { font-size: 0.825rem; color: var(--muted); line-height: 1.5; }
|
||||||
.card-url { font-size: 0.75rem; color: var(--primary); margin-top: 10px; word-break: break-all; }
|
.card-url { font-size: 0.75rem; color: var(--primary); margin-top: 10px; word-break: break-all; }
|
||||||
.empty-state { text-align: center; padding: 80px 20px; color: var(--muted); }
|
.empty-state { text-align: center; padding: 80px 20px; color: var(--muted); }
|
||||||
.empty-state svg { opacity: .3; margin-bottom: 16px; }
|
|
||||||
.empty-state p { font-size: 1rem; }
|
|
||||||
|
|
||||||
/* ── Admin Table ── */
|
/* ── Table ── */
|
||||||
.table-wrapper { background: white; border-radius: 12px; border: 1px solid var(--border); overflow: hidden; }
|
.table-wrapper { background: white; border-radius: 12px; border: 1px solid var(--border); overflow: hidden; }
|
||||||
table { width: 100%; border-collapse: collapse; }
|
table { width: 100%; border-collapse: collapse; }
|
||||||
th { background: var(--bg); padding: 12px 16px; text-align: left; font-size: 0.8rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }
|
th { background: var(--bg); padding: 12px 16px; text-align: left; font-size: 0.8rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }
|
||||||
td { padding: 13px 16px; font-size: 0.875rem; border-top: 1px solid var(--border); vertical-align: middle; }
|
td { padding: 13px 16px; font-size: 0.875rem; border-top: 1px solid var(--border); vertical-align: middle; }
|
||||||
tr:hover td { background: #f8fafc; }
|
tr:hover td { background: #f8fafc; }
|
||||||
.actions { display: flex; gap: 8px; }
|
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
.tag { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 0.75rem; font-weight: 600; }
|
.tag { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 0.75rem; font-weight: 600; }
|
||||||
.tag-admin { background: #ede9fe; color: var(--admin); }
|
.tag-admin { background: #ede9fe; color: var(--admin); }
|
||||||
.tag-user { background: #dbeafe; color: var(--primary); }
|
.tag-user { background: #dbeafe; color: var(--primary); }
|
||||||
|
.tag-locked { background: #fee2e2; color: var(--danger); }
|
||||||
|
.tag-warning { background: #fef3c7; color: #92400e; }
|
||||||
|
.tag-ok { background: #dcfce7; color: #166534; }
|
||||||
|
|
||||||
/* ── Modal ── */
|
/* ── Modal ── */
|
||||||
.modal-overlay {
|
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 100; align-items: center; justify-content: center; }
|
||||||
display: none; position: fixed; inset: 0;
|
|
||||||
background: rgba(0,0,0,0.5); z-index: 100;
|
|
||||||
align-items: center; justify-content: center;
|
|
||||||
}
|
|
||||||
.modal-overlay.open { display: flex; }
|
.modal-overlay.open { display: flex; }
|
||||||
.modal {
|
.modal { background: white; border-radius: 16px; padding: 32px; width: 480px; max-width: 95vw; box-shadow: 0 30px 80px rgba(0,0,0,0.25); max-height: 90vh; overflow-y: auto; }
|
||||||
background: white; border-radius: 16px; padding: 32px; width: 480px;
|
|
||||||
max-width: 95vw; box-shadow: 0 30px 80px rgba(0,0,0,0.25);
|
|
||||||
max-height: 90vh; overflow-y: auto;
|
|
||||||
}
|
|
||||||
.modal h3 { font-size: 1.2rem; font-weight: 700; margin-bottom: 20px; }
|
.modal h3 { font-size: 1.2rem; font-weight: 700; margin-bottom: 20px; }
|
||||||
.modal-footer { display: flex; gap: 10px; margin-top: 24px; justify-content: flex-end; }
|
.modal-footer { display: flex; gap: 10px; margin-top: 24px; justify-content: flex-end; }
|
||||||
.modal-footer .btn { width: auto; }
|
.modal-footer .btn { width: auto; }
|
||||||
.checkbox-list { max-height: 280px; overflow-y: auto; border: 1px solid var(--border); border-radius: 8px; }
|
.checkbox-list { max-height: 280px; overflow-y: auto; border: 1px solid var(--border); border-radius: 8px; }
|
||||||
.checkbox-item {
|
.checkbox-item { display: flex; align-items: center; gap: 10px; padding: 11px 14px; border-bottom: 1px solid var(--border); cursor: pointer; }
|
||||||
display: flex; align-items: center; gap: 10px;
|
|
||||||
padding: 11px 14px; border-bottom: 1px solid var(--border); cursor: pointer;
|
|
||||||
}
|
|
||||||
.checkbox-item:last-child { border-bottom: none; }
|
.checkbox-item:last-child { border-bottom: none; }
|
||||||
.checkbox-item:hover { background: var(--bg); }
|
.checkbox-item:hover { background: var(--bg); }
|
||||||
.checkbox-item input[type=checkbox] { width: 16px; height: 16px; accent-color: var(--primary); }
|
.checkbox-item input[type=checkbox] { width: 16px; height: 16px; accent-color: var(--primary); }
|
||||||
.checkbox-item .item-info { flex: 1; }
|
.checkbox-item .item-info { flex: 1; }
|
||||||
.checkbox-item .item-info strong { font-size: 0.875rem; display: block; }
|
.checkbox-item .item-info strong { font-size: 0.875rem; display: block; }
|
||||||
.checkbox-item .item-info span { font-size: 0.75rem; color: var(--muted); }
|
.checkbox-item .item-info span { font-size: 0.75rem; color: var(--muted); }
|
||||||
.section-divider { border: none; border-top: 1px solid var(--border); margin: 28px 0; }
|
|
||||||
|
/* ── Temp PW Result ── */
|
||||||
|
.temp-pw-box { background: #f0fdf4; border: 2px solid var(--success); border-radius: 10px; padding: 20px; margin-top: 16px; text-align: center; }
|
||||||
|
.temp-pw-box p { font-size: 0.85rem; color: var(--muted); margin-bottom: 8px; }
|
||||||
|
.temp-pw-box strong { font-size: 1.4rem; font-family: monospace; color: var(--text); letter-spacing: 2px; }
|
||||||
|
.temp-pw-box small { display: block; margin-top: 8px; font-size: 0.75rem; color: var(--danger); }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -172,19 +123,27 @@
|
|||||||
<h1>Web Portal</h1>
|
<h1>Web Portal</h1>
|
||||||
<p>조직 내부 웹페이지 통합 포털</p>
|
<p>조직 내부 웹페이지 통합 포털</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group"><label>아이디</label><input type="text" id="login-user" placeholder="사용자명 입력"/></div>
|
||||||
<label>아이디</label>
|
<div class="form-group"><label>비밀번호</label><input type="password" id="login-pass" placeholder="비밀번호 입력" onkeydown="if(event.key==='Enter')doLogin()"/></div>
|
||||||
<input type="text" id="login-user" placeholder="사용자명 입력" />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>비밀번호</label>
|
|
||||||
<input type="password" id="login-pass" placeholder="비밀번호 입력" onkeydown="if(event.key==='Enter')doLogin()" />
|
|
||||||
</div>
|
|
||||||
<button class="btn" onclick="doLogin()">로그인</button>
|
<button class="btn" onclick="doLogin()">로그인</button>
|
||||||
<div id="login-error" class="error-msg"></div>
|
<div id="login-error" class="error-msg"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ══════════ 강제 비밀번호 변경 ══════════ -->
|
||||||
|
<div id="change-pw-page">
|
||||||
|
<div class="change-pw-box">
|
||||||
|
<h2>🔒 비밀번호 변경 필요</h2>
|
||||||
|
<p>보안을 위해 비밀번호를 변경해야 합니다.<br>변경 후 서비스를 이용하실 수 있습니다.</p>
|
||||||
|
<div class="notice-box">⚠️ 초기 비밀번호는 반드시 변경해주세요.</div>
|
||||||
|
<div class="form-group"><label>현재 비밀번호</label><input type="password" id="force-current-pw" placeholder="현재 비밀번호 입력"/></div>
|
||||||
|
<div class="form-group"><label>새 비밀번호</label><input type="password" id="force-new-pw" placeholder="6자 이상 입력"/></div>
|
||||||
|
<div class="form-group"><label>새 비밀번호 확인</label><input type="password" id="force-confirm-pw" placeholder="새 비밀번호 재입력" onkeydown="if(event.key==='Enter')doForceChangePw()"/></div>
|
||||||
|
<button class="btn" onclick="doForceChangePw()">비밀번호 변경</button>
|
||||||
|
<div id="force-pw-error" class="error-msg"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ══════════ APP ══════════ -->
|
<!-- ══════════ APP ══════════ -->
|
||||||
<div id="app-page">
|
<div id="app-page">
|
||||||
<header>
|
<header>
|
||||||
@@ -197,10 +156,11 @@
|
|||||||
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg>
|
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg>
|
||||||
<span id="header-username"></span>
|
<span id="header-username"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="btn btn-sm btn-gray" onclick="showPage('my-password')">🔑 비밀번호 변경</button>
|
||||||
<button class="btn btn-sm btn-gray" onclick="doLogout()">로그아웃</button>
|
<button class="btn btn-sm btn-gray" onclick="doLogout()">로그아웃</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<nav id="main-nav">
|
<nav>
|
||||||
<button class="nav-btn active" onclick="showPage('my-pages')">🏠 내 페이지</button>
|
<button class="nav-btn active" onclick="showPage('my-pages')">🏠 내 페이지</button>
|
||||||
<button class="nav-btn admin-only" onclick="showPage('admin-pages')" style="display:none">📋 페이지 관리</button>
|
<button class="nav-btn admin-only" onclick="showPage('admin-pages')" style="display:none">📋 페이지 관리</button>
|
||||||
<button class="nav-btn admin-only" onclick="showPage('admin-users')" style="display:none">👥 사용자 관리</button>
|
<button class="nav-btn admin-only" onclick="showPage('admin-users')" style="display:none">👥 사용자 관리</button>
|
||||||
@@ -208,12 +168,22 @@
|
|||||||
<main>
|
<main>
|
||||||
<!-- My Pages -->
|
<!-- My Pages -->
|
||||||
<div id="page-my-pages" class="page active">
|
<div id="page-my-pages" class="page active">
|
||||||
<div class="page-header">
|
<div class="page-header"><div><h2>내 페이지 목록</h2><p>접속 가능한 웹페이지입니다. 클릭하면 새 탭에서 열립니다.</p></div></div>
|
||||||
<div><h2>내 페이지 목록</h2><p>접속 가능한 웹페이지입니다. 클릭하면 새 탭에서 열립니다.</p></div>
|
|
||||||
</div>
|
|
||||||
<div id="my-pages-grid" class="grid"></div>
|
<div id="my-pages-grid" class="grid"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- My Password -->
|
||||||
|
<div id="page-my-password" class="page">
|
||||||
|
<div class="page-header"><div><h2>비밀번호 변경</h2><p>현재 비밀번호 확인 후 새 비밀번호로 변경합니다.</p></div></div>
|
||||||
|
<div style="background:white;border-radius:12px;padding:32px;max-width:440px;border:1px solid var(--border)">
|
||||||
|
<div class="form-group"><label>현재 비밀번호</label><input type="password" id="my-current-pw" placeholder="현재 비밀번호 입력"/></div>
|
||||||
|
<div class="form-group"><label>새 비밀번호</label><input type="password" id="my-new-pw" placeholder="6자 이상 입력"/></div>
|
||||||
|
<div class="form-group"><label>새 비밀번호 확인</label><input type="password" id="my-confirm-pw" placeholder="새 비밀번호 재입력"/></div>
|
||||||
|
<button class="btn" onclick="doChangePw()">변경하기</button>
|
||||||
|
<div id="my-pw-msg" class="error-msg"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Admin: Pages -->
|
<!-- Admin: Pages -->
|
||||||
<div id="page-admin-pages" class="page">
|
<div id="page-admin-pages" class="page">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
@@ -221,24 +191,20 @@
|
|||||||
<button class="btn btn-sm" onclick="openPageModal()">+ 페이지 추가</button>
|
<button class="btn btn-sm" onclick="openPageModal()">+ 페이지 추가</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrapper">
|
<div class="table-wrapper">
|
||||||
<table>
|
<table><thead><tr><th>이름</th><th>URL</th><th>설명</th><th>작업</th></tr></thead>
|
||||||
<thead><tr><th>이름</th><th>URL</th><th>설명</th><th>작업</th></tr></thead>
|
<tbody id="pages-table-body"></tbody></table>
|
||||||
<tbody id="pages-table-body"></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Admin: Users -->
|
<!-- Admin: Users -->
|
||||||
<div id="page-admin-users" class="page">
|
<div id="page-admin-users" class="page">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div><h2>사용자 관리</h2><p>계정 생성, 삭제, 페이지 접근 권한을 설정합니다.</p></div>
|
<div><h2>사용자 관리</h2><p>계정 생성, 삭제, 비밀번호 변경, 페이지 접근 권한을 설정합니다.</p></div>
|
||||||
<button class="btn btn-sm" onclick="openUserModal()">+ 사용자 추가</button>
|
<button class="btn btn-sm" onclick="openUserModal()">+ 사용자 추가</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrapper">
|
<div class="table-wrapper">
|
||||||
<table>
|
<table><thead><tr><th>사용자명</th><th>권한</th><th>상태</th><th>작업</th></tr></thead>
|
||||||
<thead><tr><th>사용자명</th><th>권한</th><th>작업</th></tr></thead>
|
<tbody id="users-table-body"></tbody></table>
|
||||||
<tbody id="users-table-body"></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -249,9 +215,9 @@
|
|||||||
<div id="page-modal" class="modal-overlay">
|
<div id="page-modal" class="modal-overlay">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h3 id="page-modal-title">페이지 추가</h3>
|
<h3 id="page-modal-title">페이지 추가</h3>
|
||||||
<div class="form-group"><label>페이지 이름 *</label><input type="text" id="pm-name" placeholder="예: Jenkins CI"></div>
|
<div class="form-group"><label>페이지 이름 *</label><input type="text" id="pm-name" placeholder="예: Jenkins CI"/></div>
|
||||||
<div class="form-group"><label>URL *</label><input type="text" id="pm-url" placeholder="예: http://192.168.1.10:8080"></div>
|
<div class="form-group"><label>URL *</label><input type="text" id="pm-url" placeholder="예: http://192.168.1.10:8080"/></div>
|
||||||
<div class="form-group"><label>설명</label><input type="text" id="pm-desc" placeholder="짧은 설명 (선택)"></div>
|
<div class="form-group"><label>설명</label><input type="text" id="pm-desc" placeholder="짧은 설명 (선택)"/></div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-sm btn-gray" onclick="closeModal('page-modal')">취소</button>
|
<button class="btn btn-sm btn-gray" onclick="closeModal('page-modal')">취소</button>
|
||||||
<button class="btn btn-sm" onclick="savePage()">저장</button>
|
<button class="btn btn-sm" onclick="savePage()">저장</button>
|
||||||
@@ -263,14 +229,14 @@
|
|||||||
<div id="user-modal" class="modal-overlay">
|
<div id="user-modal" class="modal-overlay">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h3>사용자 추가</h3>
|
<h3>사용자 추가</h3>
|
||||||
<div class="form-group"><label>사용자명 *</label><input type="text" id="um-username" placeholder="예: john"></div>
|
<div class="form-group"><label>사용자명 *</label><input type="text" id="um-username" placeholder="예: john"/></div>
|
||||||
<div class="form-group"><label>비밀번호 *</label><input type="password" id="um-password" placeholder="비밀번호 입력"></div>
|
<div class="form-group"><label>초기 비밀번호 *</label><input type="password" id="um-password" placeholder="6자 이상"/></div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
<input type="checkbox" id="um-admin" style="width:16px;height:16px;accent-color:var(--admin)">
|
<input type="checkbox" id="um-admin" style="width:16px;height:16px;accent-color:var(--admin)"> 관리자 권한 부여
|
||||||
관리자 권한 부여
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<p style="font-size:0.8rem;color:var(--muted);margin-top:-8px">※ 신규 사용자는 최초 로그인 시 비밀번호 변경이 강제됩니다.</p>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-sm btn-gray" onclick="closeModal('user-modal')">취소</button>
|
<button class="btn btn-sm btn-gray" onclick="closeModal('user-modal')">취소</button>
|
||||||
<button class="btn btn-sm" onclick="saveUser()">저장</button>
|
<button class="btn btn-sm" onclick="saveUser()">저장</button>
|
||||||
@@ -291,6 +257,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin Change Password Modal -->
|
||||||
|
<div id="admin-pw-modal" class="modal-overlay">
|
||||||
|
<div class="modal">
|
||||||
|
<h3 id="admin-pw-modal-title">비밀번호 변경</h3>
|
||||||
|
<div class="form-group"><label>새 비밀번호 *</label><input type="password" id="apm-password" placeholder="6자 이상 입력"/></div>
|
||||||
|
<div class="form-group"><label>새 비밀번호 확인 *</label><input type="password" id="apm-confirm" placeholder="새 비밀번호 재입력"/></div>
|
||||||
|
<p style="font-size:0.8rem;color:var(--muted)">※ 변경 후 해당 사용자는 다음 로그인 시 비밀번호 변경이 강제됩니다.</p>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-sm btn-gray" onclick="closeModal('admin-pw-modal')">취소</button>
|
||||||
|
<button class="btn btn-sm btn-warning" onclick="saveAdminPw()">변경</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reset Password Result Modal -->
|
||||||
|
<div id="reset-pw-modal" class="modal-overlay">
|
||||||
|
<div class="modal">
|
||||||
|
<h3>임시 비밀번호 발급 완료</h3>
|
||||||
|
<div class="temp-pw-box">
|
||||||
|
<p>아래 임시 비밀번호를 사용자에게 전달하세요.</p>
|
||||||
|
<strong id="temp-pw-display"></strong>
|
||||||
|
<small>⚠️ 이 창을 닫으면 다시 확인할 수 없습니다!</small>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-sm" onclick="closeModal('reset-pw-modal');loadAdminUsers()">확인</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const API = '/api';
|
const API = '/api';
|
||||||
let token = localStorage.getItem('portal_token');
|
let token = localStorage.getItem('portal_token');
|
||||||
@@ -298,7 +293,6 @@ let currentUser = null;
|
|||||||
let editingPageId = null;
|
let editingPageId = null;
|
||||||
let editingUserId = null;
|
let editingUserId = null;
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────
|
|
||||||
async function apiFetch(path, opts = {}) {
|
async function apiFetch(path, opts = {}) {
|
||||||
const res = await fetch(API + path, {
|
const res = await fetch(API + path, {
|
||||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}) },
|
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}) },
|
||||||
@@ -313,8 +307,9 @@ function showPage(name) {
|
|||||||
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||||||
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
|
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
|
||||||
document.getElementById('page-' + name).classList.add('active');
|
document.getElementById('page-' + name).classList.add('active');
|
||||||
const btns = document.querySelectorAll('.nav-btn');
|
document.querySelectorAll('.nav-btn').forEach(b => {
|
||||||
btns.forEach(b => { if (b.getAttribute('onclick') && b.getAttribute('onclick').includes(name)) b.classList.add('active'); });
|
if (b.getAttribute('onclick') && b.getAttribute('onclick').includes(name)) b.classList.add('active');
|
||||||
|
});
|
||||||
if (name === 'my-pages') loadMyPages();
|
if (name === 'my-pages') loadMyPages();
|
||||||
if (name === 'admin-pages') loadAdminPages();
|
if (name === 'admin-pages') loadAdminPages();
|
||||||
if (name === 'admin-users') loadAdminUsers();
|
if (name === 'admin-users') loadAdminUsers();
|
||||||
@@ -329,15 +324,17 @@ async function doLogin() {
|
|||||||
const password = document.getElementById('login-pass').value;
|
const password = document.getElementById('login-pass').value;
|
||||||
document.getElementById('login-error').textContent = '';
|
document.getElementById('login-error').textContent = '';
|
||||||
try {
|
try {
|
||||||
const data = await apiFetch('/auth/login', {
|
const data = await apiFetch('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) });
|
||||||
method: 'POST', body: JSON.stringify({ username, password })
|
|
||||||
});
|
|
||||||
token = data.token;
|
token = data.token;
|
||||||
localStorage.setItem('portal_token', token);
|
localStorage.setItem('portal_token', token);
|
||||||
currentUser = { username: data.username, is_admin: data.is_admin };
|
currentUser = { username: data.username, is_admin: data.is_admin, must_change_password: data.must_change_password };
|
||||||
showApp();
|
if (data.must_change_password) {
|
||||||
|
showForceChangePw();
|
||||||
|
} else {
|
||||||
|
showApp();
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.getElementById('login-error').textContent = '아이디 또는 비밀번호가 올바르지 않습니다.';
|
document.getElementById('login-error').textContent = e.message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,13 +342,43 @@ function doLogout() {
|
|||||||
token = null; currentUser = null;
|
token = null; currentUser = null;
|
||||||
localStorage.removeItem('portal_token');
|
localStorage.removeItem('portal_token');
|
||||||
document.getElementById('app-page').style.display = 'none';
|
document.getElementById('app-page').style.display = 'none';
|
||||||
|
document.getElementById('change-pw-page').style.display = 'none';
|
||||||
document.getElementById('login-page').style.display = 'flex';
|
document.getElementById('login-page').style.display = 'flex';
|
||||||
document.getElementById('login-user').value = '';
|
document.getElementById('login-user').value = '';
|
||||||
document.getElementById('login-pass').value = '';
|
document.getElementById('login-pass').value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showForceChangePw() {
|
||||||
|
document.getElementById('login-page').style.display = 'none';
|
||||||
|
document.getElementById('change-pw-page').style.display = 'flex';
|
||||||
|
document.getElementById('force-current-pw').value = '';
|
||||||
|
document.getElementById('force-new-pw').value = '';
|
||||||
|
document.getElementById('force-confirm-pw').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doForceChangePw() {
|
||||||
|
const current = document.getElementById('force-current-pw').value;
|
||||||
|
const newPw = document.getElementById('force-new-pw').value;
|
||||||
|
const confirm = document.getElementById('force-confirm-pw').value;
|
||||||
|
const errEl = document.getElementById('force-pw-error');
|
||||||
|
errEl.textContent = '';
|
||||||
|
if (newPw.length < 6) { errEl.textContent = '새 비밀번호는 6자 이상이어야 합니다.'; return; }
|
||||||
|
if (newPw !== confirm) { errEl.textContent = '새 비밀번호가 일치하지 않습니다.'; return; }
|
||||||
|
try {
|
||||||
|
const data = await apiFetch('/auth/change-password', {
|
||||||
|
method: 'POST', body: JSON.stringify({ current_password: current, new_password: newPw })
|
||||||
|
});
|
||||||
|
token = data.token;
|
||||||
|
localStorage.setItem('portal_token', token);
|
||||||
|
currentUser.must_change_password = false;
|
||||||
|
document.getElementById('change-pw-page').style.display = 'none';
|
||||||
|
showApp();
|
||||||
|
} catch(e) { errEl.textContent = e.message; }
|
||||||
|
}
|
||||||
|
|
||||||
function showApp() {
|
function showApp() {
|
||||||
document.getElementById('login-page').style.display = 'none';
|
document.getElementById('login-page').style.display = 'none';
|
||||||
|
document.getElementById('change-pw-page').style.display = 'none';
|
||||||
document.getElementById('app-page').style.display = 'flex';
|
document.getElementById('app-page').style.display = 'flex';
|
||||||
document.getElementById('header-username').textContent = currentUser.username;
|
document.getElementById('header-username').textContent = currentUser.username;
|
||||||
document.getElementById('admin-badge').style.display = currentUser.is_admin ? 'inline' : 'none';
|
document.getElementById('admin-badge').style.display = currentUser.is_admin ? 'inline' : 'none';
|
||||||
@@ -361,21 +388,41 @@ function showApp() {
|
|||||||
showPage('my-pages');
|
showPage('my-pages');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── My Password Change ───────────────────────────────────
|
||||||
|
async function doChangePw() {
|
||||||
|
const current = document.getElementById('my-current-pw').value;
|
||||||
|
const newPw = document.getElementById('my-new-pw').value;
|
||||||
|
const confirm = document.getElementById('my-confirm-pw').value;
|
||||||
|
const msgEl = document.getElementById('my-pw-msg');
|
||||||
|
msgEl.style.color = 'var(--danger)';
|
||||||
|
msgEl.textContent = '';
|
||||||
|
if (newPw.length < 6) { msgEl.textContent = '새 비밀번호는 6자 이상이어야 합니다.'; return; }
|
||||||
|
if (newPw !== confirm) { msgEl.textContent = '새 비밀번호가 일치하지 않습니다.'; return; }
|
||||||
|
try {
|
||||||
|
const data = await apiFetch('/auth/change-password', {
|
||||||
|
method: 'POST', body: JSON.stringify({ current_password: current, new_password: newPw })
|
||||||
|
});
|
||||||
|
token = data.token;
|
||||||
|
localStorage.setItem('portal_token', token);
|
||||||
|
msgEl.style.color = 'var(--success)';
|
||||||
|
msgEl.textContent = '✅ 비밀번호가 성공적으로 변경되었습니다.';
|
||||||
|
document.getElementById('my-current-pw').value = '';
|
||||||
|
document.getElementById('my-new-pw').value = '';
|
||||||
|
document.getElementById('my-confirm-pw').value = '';
|
||||||
|
} catch(e) { msgEl.textContent = e.message; }
|
||||||
|
}
|
||||||
|
|
||||||
// ── My Pages ─────────────────────────────────────────────
|
// ── My Pages ─────────────────────────────────────────────
|
||||||
async function loadMyPages() {
|
async function loadMyPages() {
|
||||||
const pages = await apiFetch('/my-pages');
|
const pages = await apiFetch('/my-pages');
|
||||||
const grid = document.getElementById('my-pages-grid');
|
const grid = document.getElementById('my-pages-grid');
|
||||||
if (!pages || pages.length === 0) {
|
if (!pages || pages.length === 0) {
|
||||||
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1">
|
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><p>접근 가능한 페이지가 없습니다.<br>관리자에게 문의하세요.</p></div>`;
|
||||||
<svg width="64" height="64" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>
|
|
||||||
<p>접근 가능한 페이지가 없습니다.<br>관리자에게 문의하세요.</p></div>`;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
grid.innerHTML = pages.map(p => `
|
grid.innerHTML = pages.map(p => `
|
||||||
<a class="card" href="${escHtml(p.url)}" target="_blank" rel="noopener">
|
<a class="card" href="${escHtml(p.url)}" target="_blank" rel="noopener">
|
||||||
<div class="card-icon">
|
<div class="card-icon"><svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 010 20M12 2a15 15 0 000 20"/></svg></div>
|
||||||
<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 010 20M12 2a15 15 0 000 20"/></svg>
|
|
||||||
</div>
|
|
||||||
<h3>${escHtml(p.name)}</h3>
|
<h3>${escHtml(p.name)}</h3>
|
||||||
<p>${escHtml(p.description || '설명 없음')}</p>
|
<p>${escHtml(p.description || '설명 없음')}</p>
|
||||||
<div class="card-url">${escHtml(p.url)}</div>
|
<div class="card-url">${escHtml(p.url)}</div>
|
||||||
@@ -408,20 +455,12 @@ function openPageModal(id, name, url, desc) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function savePage() {
|
async function savePage() {
|
||||||
const body = {
|
const body = { name: document.getElementById('pm-name').value.trim(), url: document.getElementById('pm-url').value.trim(), description: document.getElementById('pm-desc').value.trim() };
|
||||||
name: document.getElementById('pm-name').value.trim(),
|
|
||||||
url: document.getElementById('pm-url').value.trim(),
|
|
||||||
description: document.getElementById('pm-desc').value.trim()
|
|
||||||
};
|
|
||||||
if (!body.name || !body.url) { alert('이름과 URL은 필수입니다.'); return; }
|
if (!body.name || !body.url) { alert('이름과 URL은 필수입니다.'); return; }
|
||||||
try {
|
try {
|
||||||
if (editingPageId) {
|
if (editingPageId) await apiFetch('/admin/webpages/' + editingPageId, { method: 'PUT', body: JSON.stringify(body) });
|
||||||
await apiFetch('/admin/webpages/' + editingPageId, { method: 'PUT', body: JSON.stringify(body) });
|
else await apiFetch('/admin/webpages', { method: 'POST', body: JSON.stringify(body) });
|
||||||
} else {
|
closeModal('page-modal'); loadAdminPages();
|
||||||
await apiFetch('/admin/webpages', { method: 'POST', body: JSON.stringify(body) });
|
|
||||||
}
|
|
||||||
closeModal('page-modal');
|
|
||||||
loadAdminPages();
|
|
||||||
} catch(e) { alert(e.message); }
|
} catch(e) { alert(e.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,15 +474,29 @@ async function deletePage(id) {
|
|||||||
async function loadAdminUsers() {
|
async function loadAdminUsers() {
|
||||||
const users = await apiFetch('/admin/users');
|
const users = await apiFetch('/admin/users');
|
||||||
const tbody = document.getElementById('users-table-body');
|
const tbody = document.getElementById('users-table-body');
|
||||||
tbody.innerHTML = users.map(u => `
|
tbody.innerHTML = users.map(u => {
|
||||||
<tr>
|
const statusTags = [];
|
||||||
|
if (u.is_locked) statusTags.push(`<span class="tag tag-locked">🔒 잠김</span>`);
|
||||||
|
else statusTags.push(`<span class="tag tag-ok">정상</span>`);
|
||||||
|
if (u.must_change_password) statusTags.push(`<span class="tag tag-warning">초기PW</span>`);
|
||||||
|
if (u.password_change_requested) statusTags.push(`<span class="tag tag-locked">변경요청</span>`);
|
||||||
|
|
||||||
|
const actions = [];
|
||||||
|
if (!u.is_admin) actions.push(`<button class="btn btn-sm btn-purple" onclick="openAccessModal(${u.id},'${escJs(u.username)}')">권한</button>`);
|
||||||
|
if (u.username !== 'admin') {
|
||||||
|
actions.push(`<button class="btn btn-sm btn-warning" onclick="openAdminPwModal(${u.id},'${escJs(u.username)}')">PW변경</button>`);
|
||||||
|
actions.push(`<button class="btn btn-sm btn-gray" onclick="resetPassword(${u.id},'${escJs(u.username)}')">임시PW</button>`);
|
||||||
|
}
|
||||||
|
if (u.is_locked) actions.push(`<button class="btn btn-sm btn-success" onclick="unlockUser(${u.id})">잠금해제</button>`);
|
||||||
|
if (u.username !== 'admin') actions.push(`<button class="btn btn-sm btn-danger" onclick="deleteUser(${u.id})">삭제</button>`);
|
||||||
|
|
||||||
|
return `<tr>
|
||||||
<td><strong>${escHtml(u.username)}</strong></td>
|
<td><strong>${escHtml(u.username)}</strong></td>
|
||||||
<td><span class="tag ${u.is_admin ? 'tag-admin' : 'tag-user'}">${u.is_admin ? '관리자' : '일반사용자'}</span></td>
|
<td><span class="tag ${u.is_admin ? 'tag-admin' : 'tag-user'}">${u.is_admin ? '관리자' : '일반사용자'}</span></td>
|
||||||
<td><div class="actions">
|
<td>${statusTags.join(' ')}</td>
|
||||||
${!u.is_admin ? `<button class="btn btn-sm btn-purple" onclick="openAccessModal(${u.id},'${escJs(u.username)}')">권한 설정</button>` : ''}
|
<td><div class="actions">${actions.join('')}</div></td>
|
||||||
${u.username !== 'admin' ? `<button class="btn btn-sm btn-danger" onclick="deleteUser(${u.id})">삭제</button>` : ''}
|
</tr>`;
|
||||||
</div></td>
|
}).join('') || '<tr><td colspan="4" style="text-align:center;color:var(--muted);padding:40px">사용자가 없습니다.</td></tr>';
|
||||||
</tr>`).join('') || '<tr><td colspan="3" style="text-align:center;color:var(--muted);padding:40px">사용자가 없습니다.</td></tr>';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openUserModal() {
|
function openUserModal() {
|
||||||
@@ -454,17 +507,10 @@ function openUserModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveUser() {
|
async function saveUser() {
|
||||||
const body = {
|
const body = { username: document.getElementById('um-username').value.trim(), password: document.getElementById('um-password').value, is_admin: document.getElementById('um-admin').checked };
|
||||||
username: document.getElementById('um-username').value.trim(),
|
|
||||||
password: document.getElementById('um-password').value,
|
|
||||||
is_admin: document.getElementById('um-admin').checked
|
|
||||||
};
|
|
||||||
if (!body.username || !body.password) { alert('사용자명과 비밀번호는 필수입니다.'); return; }
|
if (!body.username || !body.password) { alert('사용자명과 비밀번호는 필수입니다.'); return; }
|
||||||
try {
|
try { await apiFetch('/admin/users', { method: 'POST', body: JSON.stringify(body) }); closeModal('user-modal'); loadAdminUsers(); }
|
||||||
await apiFetch('/admin/users', { method: 'POST', body: JSON.stringify(body) });
|
catch(e) { alert(e.message); }
|
||||||
closeModal('user-modal');
|
|
||||||
loadAdminUsers();
|
|
||||||
} catch(e) { alert(e.message); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteUser(id) {
|
async function deleteUser(id) {
|
||||||
@@ -473,6 +519,46 @@ async function deleteUser(id) {
|
|||||||
loadAdminUsers();
|
loadAdminUsers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Admin: 비밀번호 변경 ─────────────────────────────────
|
||||||
|
function openAdminPwModal(userId, username) {
|
||||||
|
editingUserId = userId;
|
||||||
|
document.getElementById('admin-pw-modal-title').textContent = `'${username}' 비밀번호 변경`;
|
||||||
|
document.getElementById('apm-password').value = '';
|
||||||
|
document.getElementById('apm-confirm').value = '';
|
||||||
|
openModal('admin-pw-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAdminPw() {
|
||||||
|
const pw = document.getElementById('apm-password').value;
|
||||||
|
const confirm = document.getElementById('apm-confirm').value;
|
||||||
|
if (pw.length < 6) { alert('비밀번호는 6자 이상이어야 합니다.'); return; }
|
||||||
|
if (pw !== confirm) { alert('비밀번호가 일치하지 않습니다.'); return; }
|
||||||
|
try {
|
||||||
|
await apiFetch('/admin/users/' + editingUserId + '/password', { method: 'PUT', body: JSON.stringify({ new_password: pw }) });
|
||||||
|
closeModal('admin-pw-modal');
|
||||||
|
alert('비밀번호가 변경되었습니다.');
|
||||||
|
loadAdminUsers();
|
||||||
|
} catch(e) { alert(e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin: 임시 비밀번호 발급 ────────────────────────────
|
||||||
|
async function resetPassword(userId, username) {
|
||||||
|
if (!confirm(`'${username}' 의 임시 비밀번호를 발급하시겠습니까?`)) return;
|
||||||
|
try {
|
||||||
|
const data = await apiFetch('/admin/users/' + userId + '/reset-password', { method: 'POST' });
|
||||||
|
document.getElementById('temp-pw-display').textContent = data.temp_password;
|
||||||
|
openModal('reset-pw-modal');
|
||||||
|
} catch(e) { alert(e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin: 잠금 해제 ─────────────────────────────────────
|
||||||
|
async function unlockUser(userId) {
|
||||||
|
if (!confirm('이 계정의 잠금을 해제하시겠습니까?')) return;
|
||||||
|
await apiFetch('/admin/users/' + userId + '/unlock', { method: 'POST' });
|
||||||
|
loadAdminUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin: Access ────────────────────────────────────────
|
||||||
async function openAccessModal(userId, username) {
|
async function openAccessModal(userId, username) {
|
||||||
editingUserId = userId;
|
editingUserId = userId;
|
||||||
document.getElementById('access-modal-title').textContent = `'${username}' 접근 권한 설정`;
|
document.getElementById('access-modal-title').textContent = `'${username}' 접근 권한 설정`;
|
||||||
@@ -481,19 +567,14 @@ async function openAccessModal(userId, username) {
|
|||||||
list.innerHTML = pages.map(p => `
|
list.innerHTML = pages.map(p => `
|
||||||
<label class="checkbox-item">
|
<label class="checkbox-item">
|
||||||
<input type="checkbox" value="${p.id}" ${p.has_access ? 'checked' : ''}>
|
<input type="checkbox" value="${p.id}" ${p.has_access ? 'checked' : ''}>
|
||||||
<div class="item-info">
|
<div class="item-info"><strong>${escHtml(p.name)}</strong><span>${escHtml(p.url)}</span></div>
|
||||||
<strong>${escHtml(p.name)}</strong>
|
|
||||||
<span>${escHtml(p.url)}</span>
|
|
||||||
</div>
|
|
||||||
</label>`).join('') || '<div style="padding:20px;text-align:center;color:var(--muted)">등록된 페이지가 없습니다.</div>';
|
</label>`).join('') || '<div style="padding:20px;text-align:center;color:var(--muted)">등록된 페이지가 없습니다.</div>';
|
||||||
openModal('access-modal');
|
openModal('access-modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveAccess() {
|
async function saveAccess() {
|
||||||
const checked = [...document.querySelectorAll('#access-checkbox-list input:checked')].map(i => parseInt(i.value));
|
const checked = [...document.querySelectorAll('#access-checkbox-list input:checked')].map(i => parseInt(i.value));
|
||||||
await apiFetch('/admin/users/' + editingUserId + '/pages', {
|
await apiFetch('/admin/users/' + editingUserId + '/pages', { method: 'PUT', body: JSON.stringify({ webpage_ids: checked }) });
|
||||||
method: 'PUT', body: JSON.stringify({ webpage_ids: checked })
|
|
||||||
});
|
|
||||||
closeModal('access-modal');
|
closeModal('access-modal');
|
||||||
alert('권한이 저장되었습니다.');
|
alert('권한이 저장되었습니다.');
|
||||||
}
|
}
|
||||||
@@ -507,7 +588,8 @@ function escJs(s) { return String(s||'').replace(/\\/g,'\\\\').replace(/'/g,"\\'
|
|||||||
if (token) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
currentUser = await apiFetch('/auth/me');
|
currentUser = await apiFetch('/auth/me');
|
||||||
showApp();
|
if (currentUser.must_change_password) showForceChangePw();
|
||||||
|
else showApp();
|
||||||
} catch { doLogout(); }
|
} catch { doLogout(); }
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user