260706_1_계정, 로그인, 보안 초안 작성
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
|
||||
async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
credentials: "include",
|
||||
...init,
|
||||
headers: { "Content-Type": "application/json", ...init?.headers },
|
||||
});
|
||||
const result = (await response.json()) as T & { detail?: string };
|
||||
if (!response.ok) throw new Error(result.detail ?? "Request failed");
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getMasterDashboard(): Promise<Record<string, unknown>> {
|
||||
return api("/security/master/dashboard");
|
||||
}
|
||||
|
||||
export function getMembers(): Promise<Record<string, unknown>> {
|
||||
return api("/security/master/members");
|
||||
}
|
||||
|
||||
export function getJoinRequests(): Promise<Record<string, unknown>> {
|
||||
return api("/security/master/join-requests");
|
||||
}
|
||||
|
||||
export function decideJoinRequest(
|
||||
requestId: number,
|
||||
approved: boolean,
|
||||
comment?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return api(`/security/master/join-requests/${requestId}/decision`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ approved, comment }),
|
||||
});
|
||||
}
|
||||
|
||||
export function removeMember(userId: number): Promise<Record<string, unknown>> {
|
||||
return api(`/security/master/members/${userId}/remove`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function getAdminDashboard(): Promise<Record<string, unknown>> {
|
||||
return api("/security/admin/dashboard");
|
||||
}
|
||||
|
||||
export function getAdminCollection(
|
||||
collection: "companies" | "users" | "support",
|
||||
): Promise<Record<string, unknown>> {
|
||||
return api(`/security/admin/${collection}`);
|
||||
}
|
||||
|
||||
export function updateAdminStatus(
|
||||
entity: "company" | "user" | "support",
|
||||
targetId: number,
|
||||
status: string,
|
||||
reason: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return api(`/security/admin/${entity}/${targetId}/status`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status, reason }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"""마스터 조직 관리, 활동 기록 및 시스템 관리자 API."""
|
||||
|
||||
import json
|
||||
|
||||
import psutil
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from common_util.common_util_auth import require_master, require_system_admin, verify_session
|
||||
from common_util.common_util_auth_repository import (
|
||||
admin_dashboard,
|
||||
admin_list,
|
||||
admin_update_status,
|
||||
decide_join_request,
|
||||
list_company_members,
|
||||
list_join_requests,
|
||||
master_dashboard,
|
||||
remove_member,
|
||||
)
|
||||
from common_util.common_util_email import send_email_background
|
||||
from common_util.common_util_email_templates import join_result_email
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
from .A09_Security_Schema import ActivityLogRequest, AdminStatusRequest, JoinDecisionRequest
|
||||
|
||||
router = APIRouter(prefix="/api/security", tags=["Security"])
|
||||
|
||||
|
||||
@router.get("/master/dashboard")
|
||||
async def get_master_dashboard(session: dict = Depends(require_master)):
|
||||
return {"status": "success", "dashboard": await master_dashboard(session["company_id"])}
|
||||
|
||||
|
||||
@router.get("/master/members")
|
||||
async def get_members(session: dict = Depends(require_master)):
|
||||
return {"status": "success", "members": await list_company_members(session["company_id"])}
|
||||
|
||||
|
||||
@router.get("/master/join-requests")
|
||||
async def get_join_requests(session: dict = Depends(require_master)):
|
||||
return {"status": "success", "requests": await list_join_requests(session["company_id"])}
|
||||
|
||||
|
||||
@router.post("/master/join-requests/{request_id}/decision")
|
||||
async def review_join_request(
|
||||
request_id: int,
|
||||
payload: JoinDecisionRequest,
|
||||
session: dict = Depends(require_master),
|
||||
):
|
||||
user = await decide_join_request(
|
||||
request_id, session["company_id"], session["user_id"], payload.approved, payload.comment
|
||||
)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="처리할 가입 요청이 없습니다.")
|
||||
subject, html = join_result_email(payload.approved)
|
||||
send_email_background(user["email"], subject, html)
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.post("/master/members/{user_id}/remove")
|
||||
async def deactivate_member(user_id: int, session: dict = Depends(require_master)):
|
||||
if not await remove_member(user_id, session["company_id"]):
|
||||
raise HTTPException(status_code=404, detail="내보낼 팀원을 찾을 수 없습니다.")
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.post("/activity", status_code=202)
|
||||
async def write_activity(payload: ActivityLogRequest, session: dict = Depends(verify_session)):
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""INSERT INTO activity_logs
|
||||
(user_id, action_type, resource_id, status, details)
|
||||
VALUES (%s, %s, %s, %s, %s)""",
|
||||
(
|
||||
session["user_id"],
|
||||
payload.action_type,
|
||||
payload.resource_id,
|
||||
payload.status,
|
||||
json.dumps(payload.details),
|
||||
),
|
||||
)
|
||||
await connection.commit()
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.get("/admin/dashboard")
|
||||
async def get_admin_dashboard(session: dict = Depends(require_system_admin)):
|
||||
disk = psutil.disk_usage("/")
|
||||
process = psutil.Process()
|
||||
resources = {
|
||||
"cpu_percent": psutil.cpu_percent(interval=None),
|
||||
"memory_percent": psutil.virtual_memory().percent,
|
||||
"disk_total_gb": round(disk.total / 1024**3, 2),
|
||||
"disk_used_gb": round(disk.used / 1024**3, 2),
|
||||
"process_count": len(psutil.pids()),
|
||||
"app_memory_mb": round(process.memory_info().rss / 1024**2, 2),
|
||||
}
|
||||
return {"status": "success", "dashboard": await admin_dashboard(), "resources": resources}
|
||||
|
||||
|
||||
@router.get("/admin/{collection}")
|
||||
async def get_admin_collection(collection: str, session: dict = Depends(require_system_admin)):
|
||||
try:
|
||||
rows = await admin_list(collection)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return {"status": "success", "items": rows}
|
||||
|
||||
|
||||
@router.patch("/admin/{entity}/{target_id}/status")
|
||||
async def set_admin_status(
|
||||
entity: str,
|
||||
target_id: int,
|
||||
payload: AdminStatusRequest,
|
||||
session: dict = Depends(require_system_admin),
|
||||
):
|
||||
changed = await admin_update_status(
|
||||
session["user_id"], entity, target_id, payload.status, {"reason": payload.reason}
|
||||
)
|
||||
if not changed:
|
||||
raise HTTPException(status_code=400, detail="대상 또는 상태값이 올바르지 않습니다.")
|
||||
return {"status": "success"}
|
||||
@@ -0,0 +1,24 @@
|
||||
"""조직 승인 및 시스템 관리자 변경 요청 스키마."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class JoinDecisionRequest(BaseModel):
|
||||
approved: bool
|
||||
comment: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class AdminStatusRequest(BaseModel):
|
||||
status: str = Field(min_length=3, max_length=20)
|
||||
reason: str = Field(min_length=1, max_length=500)
|
||||
|
||||
|
||||
class ActivityLogRequest(BaseModel):
|
||||
action_type: Literal[
|
||||
"FILE_UPLOAD", "FILE_DOWNLOAD", "PROJECT_SAVE", "DATA_PROCESS", "PAGE_VIEW"
|
||||
]
|
||||
resource_id: str | None = Field(default=None, max_length=255)
|
||||
status: Literal["SUCCESS", "FAILURE"] = "SUCCESS"
|
||||
details: dict = Field(default_factory=dict)
|
||||
@@ -0,0 +1,16 @@
|
||||
export const SECURITY_TERMS_VERSION = "1.0";
|
||||
|
||||
export const securityTerms = {
|
||||
terms: [
|
||||
"Aislo는 임도 설계 및 견적 자동화 기능을 제공하며, 계정의 무단 공유와 서비스 방해 행위를 금지합니다.",
|
||||
"Aislo provides forest-road design and estimation automation. Account sharing and service disruption are prohibited.",
|
||||
],
|
||||
privacy: [
|
||||
"계정, 회사, User-Agent 및 서비스 활동 기록을 인증·보안·운영 목적으로 처리합니다. IP 주소는 수집하지 않습니다.",
|
||||
"Account, company, user-agent, and activity records are processed for authentication, security, and operations. IP addresses are not collected.",
|
||||
],
|
||||
security: [
|
||||
"세션은 Secure HttpOnly 쿠키로 보호되며, 12시간 또는 4시간 비활동 후 만료됩니다.",
|
||||
"Sessions use Secure HttpOnly cookies and expire after 12 hours or 4 hours of inactivity.",
|
||||
],
|
||||
} as const;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { SECURITY_TERMS_VERSION, securityTerms } from "./A09_Security_Terms";
|
||||
import "./A09_Security_UI_Style.css";
|
||||
|
||||
const L = (key: keyof typeof ui_locales): string => ui_locales[key][currentLanguageIndex];
|
||||
|
||||
export function renderA09Security(root: HTMLElement): void {
|
||||
const page = document.createElement("article");
|
||||
page.className = "a09-security";
|
||||
const title = document.createElement("h1");
|
||||
title.textContent = L("A09_Security_Title");
|
||||
const version = document.createElement("p");
|
||||
version.textContent = `${L("A09_Security_Version")} ${SECURITY_TERMS_VERSION}`;
|
||||
page.append(title, version);
|
||||
const sections = [
|
||||
["A09_Security_Terms", securityTerms.terms],
|
||||
["A09_Security_Privacy", securityTerms.privacy],
|
||||
["A09_Security_Policy", securityTerms.security],
|
||||
] as const;
|
||||
for (const [headingKey, body] of sections) {
|
||||
const section = document.createElement("section");
|
||||
const heading = document.createElement("h2");
|
||||
heading.textContent = L(headingKey);
|
||||
const paragraph = document.createElement("p");
|
||||
paragraph.textContent = body[currentLanguageIndex];
|
||||
section.append(heading, paragraph);
|
||||
page.append(section);
|
||||
}
|
||||
root.append(page);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
.a09-security {
|
||||
max-width: 800px;
|
||||
margin: var(--spacing-64) auto;
|
||||
padding: var(--spacing-40);
|
||||
color: var(--color-text);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.a09-security section {
|
||||
margin-top: var(--spacing-32);
|
||||
}
|
||||
Reference in New Issue
Block a user