feat: 초기 커밋 — Pretendard 폰트 적용 및 Docker 배포 구성
- Pretendard Regular/Bold/Thin 폰트를 app/static/fonts/ 에 추가하고 @font-face 로 등록하여 전체 페이지에 적용 - Dockerfile(python:3.12-slim + uvicorn --proxy-headers) 작성 - docker-compose.yml: NPM 뒤 운영 서버용 (포트 80) - docker-compose.local.yml: 로컬 테스트용 (포트 8080) - .env.local.example: 로컬 OAuth 설정 가이드 포함 - .dockerignore: 이미지 빌드 컨텍스트 최소화 - .gitignore: .env / .env.local 제외 명시 - README: Docker 빌드·배포·업데이트 절차 문서화 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.dockerignore
|
||||||
|
nginx/
|
||||||
|
docker-compose*.yml
|
||||||
|
README.md
|
||||||
|
.claude/
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
GOOGLE_CLIENT_ID=your-google-oauth-client-id.apps.googleusercontent.com
|
||||||
|
GOOGLE_CLIENT_SECRET=your-google-oauth-client-secret
|
||||||
|
SESSION_SECRET_KEY=replace-with-a-long-random-secret
|
||||||
|
SESSION_COOKIE_SECURE=true
|
||||||
|
PUBLIC_BASE_URL=https://dbx.no1king.freeddns.org
|
||||||
|
CS_ORDER_URL=https://cs.example.com
|
||||||
|
CUSTOMER_ORDER_LIST_URL=https://orders.example.com
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 로컬 Docker 테스트용 환경 변수
|
||||||
|
# 이 파일을 복사하여 .env.local 로 저장한 뒤 값을 채워주세요.
|
||||||
|
#
|
||||||
|
# Google Cloud Console에서 아래 URI를 승인된 리디렉션 URI에 추가해야 합니다:
|
||||||
|
# http://localhost:8080/auth/google
|
||||||
|
|
||||||
|
GOOGLE_CLIENT_ID=your-google-oauth-client-id.apps.googleusercontent.com
|
||||||
|
GOOGLE_CLIENT_SECRET=your-google-oauth-client-secret
|
||||||
|
|
||||||
|
# 로컬은 HTTP이므로 false
|
||||||
|
SESSION_COOKIE_SECURE=false
|
||||||
|
SESSION_SECRET_KEY=replace-with-a-long-random-secret-for-local
|
||||||
|
|
||||||
|
# 로컬 접속 주소
|
||||||
|
PUBLIC_BASE_URL=http://localhost:8080
|
||||||
|
|
||||||
|
CS_ORDER_URL=https://cs.example.com
|
||||||
|
CUSTOMER_ORDER_LIST_URL=https://orders.example.com
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
# 환경 변수 (실제 키 포함 — 절대 커밋 금지)
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
# 작업 디렉토리
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 의존성 먼저 설치 (캐시 활용)
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# 소스 복사
|
||||||
|
COPY app/ ./app/
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# --proxy-headers: NPM(Nginx Proxy Manager)의 X-Forwarded-* 헤더 신뢰
|
||||||
|
CMD ["uvicorn", "app.main:app", \
|
||||||
|
"--host", "0.0.0.0", \
|
||||||
|
"--port", "8000", \
|
||||||
|
"--proxy-headers", \
|
||||||
|
"--forwarded-allow-ips=*"]
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# DBX 메인 페이지
|
||||||
|
|
||||||
|
Google Workspace 계정으로 로그인한 뒤 허용된 사용자만 업무 메뉴에 접근하는 FastAPI 웹앱입니다.
|
||||||
|
Nginx Proxy Manager(NPM) 뒤에서 Docker 컨테이너로 실행하는 구성을 기준으로 작성했습니다.
|
||||||
|
|
||||||
|
## 접속 허용 계정
|
||||||
|
|
||||||
|
- king@dbxcorp.co.kr
|
||||||
|
- julie@dbxcorp.co.kr
|
||||||
|
- ellen@dbxcorp.co.kr
|
||||||
|
- bj@dbxcorp.co.kr
|
||||||
|
|
||||||
|
허용 목록은 `app/main.py`의 `ALLOWED_EMAILS`에서 서버 측으로 검사합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Google OAuth 설정
|
||||||
|
|
||||||
|
Google Cloud Console에서 OAuth 클라이언트를 만들고 아래 값을 등록합니다.
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|---|---|
|
||||||
|
| 승인된 JavaScript 원본 | `https://dbx.no1king.freeddns.org` |
|
||||||
|
| 승인된 리디렉션 URI (운영) | `https://dbx.no1king.freeddns.org/auth/google` |
|
||||||
|
| 승인된 리디렉션 URI (로컬) | `http://localhost:8080/auth/google` |
|
||||||
|
|
||||||
|
Google 로그인 화면에서 `hd=dbxcorp.co.kr` 힌트를 보내지만,
|
||||||
|
실제 접근 제한은 서버에서 이메일 도메인과 허용 계정 목록으로 다시 검사합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 환경 변수
|
||||||
|
|
||||||
|
| 변수 | 설명 |
|
||||||
|
|---|---|
|
||||||
|
| `GOOGLE_CLIENT_ID` | Google OAuth 클라이언트 ID |
|
||||||
|
| `GOOGLE_CLIENT_SECRET` | Google OAuth 클라이언트 보안 비밀 |
|
||||||
|
| `SESSION_SECRET_KEY` | 세션 쿠키 서명용 랜덤 문자열 |
|
||||||
|
| `SESSION_COOKIE_SECURE` | HTTPS 환경 `true` / 로컬 HTTP `false` |
|
||||||
|
| `PUBLIC_BASE_URL` | 외부 접속 주소 (예: `https://dbx.no1king.freeddns.org`) |
|
||||||
|
| `CS_ORDER_URL` | CS 발주 업무 버튼 이동 주소 |
|
||||||
|
| `CUSTOMER_ORDER_LIST_URL` | 고객 주문리스트 프로그램 버튼 이동 주소 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 로컬 Docker 테스트
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 1. 환경 변수 파일 준비
|
||||||
|
copy .env.local.example .env.local
|
||||||
|
# .env.local 을 열어 Google OAuth 키 등 실제 값으로 수정
|
||||||
|
|
||||||
|
# 2. 이미지 빌드 및 컨테이너 실행
|
||||||
|
docker compose -f docker-compose.local.yml up --build
|
||||||
|
|
||||||
|
# 3. 브라우저에서 확인
|
||||||
|
# http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Google 로컬 OAuth 주의**
|
||||||
|
> Google Cloud Console → OAuth 클라이언트 → 승인된 리디렉션 URI에
|
||||||
|
> `http://localhost:8080/auth/google` 를 반드시 추가해야 합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 서버 배포 (Ubuntu + NPM)
|
||||||
|
|
||||||
|
NPM은 `dbx.no1king.freeddns.org` → `http://192.168.0.194:80` 으로 이미 설정되어 있습니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 서버에서 실행
|
||||||
|
|
||||||
|
# 1. 저장소 클론
|
||||||
|
git clone https://gitea.no1king.freeddns.org/king/dbx-main.git
|
||||||
|
cd dbx-main
|
||||||
|
|
||||||
|
# 2. 환경 변수 파일 준비
|
||||||
|
cp .env.example .env
|
||||||
|
# .env 를 열어 실제 값으로 수정
|
||||||
|
nano .env
|
||||||
|
|
||||||
|
# 3. 이미지 빌드 및 백그라운드 실행
|
||||||
|
docker compose up --build -d
|
||||||
|
|
||||||
|
# 4. 로그 확인
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### 업데이트 배포
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
docker compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 컨테이너 관리
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps # 상태 확인
|
||||||
|
docker compose down # 중지
|
||||||
|
docker compose restart web # 재시작
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 폰트
|
||||||
|
|
||||||
|
`app/static/fonts/` 에 Pretendard 폰트가 포함되어 있습니다.
|
||||||
|
|
||||||
|
| 파일 | 굵기 |
|
||||||
|
|---|---|
|
||||||
|
| `Pretendard-Thin.ttf` | 100 (얇음) |
|
||||||
|
| `Pretendard-Regular.ttf` | 400 (기본) |
|
||||||
|
| `Pretendard-Bold.ttf` | 700 (굵음) |
|
||||||
+190
@@ -0,0 +1,190 @@
|
|||||||
|
import inspect
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from authlib.integrations.starlette_client import OAuth, OAuthError
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
ALLOWED_DOMAIN = "dbxcorp.co.kr"
|
||||||
|
ALLOWED_EMAILS = {
|
||||||
|
"king@dbxcorp.co.kr",
|
||||||
|
"julie@dbxcorp.co.kr",
|
||||||
|
"ellen@dbxcorp.co.kr",
|
||||||
|
"bj@dbxcorp.co.kr",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def env(name: str, default: str = "") -> str:
|
||||||
|
return os.getenv(name, default).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def build_google_oauth() -> OAuth:
|
||||||
|
oauth = OAuth()
|
||||||
|
oauth.register(
|
||||||
|
name="google",
|
||||||
|
client_id=env("GOOGLE_CLIENT_ID"),
|
||||||
|
client_secret=env("GOOGLE_CLIENT_SECRET"),
|
||||||
|
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
||||||
|
client_kwargs={"scope": "openid email profile"},
|
||||||
|
)
|
||||||
|
return oauth
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="DBX 메인 페이지")
|
||||||
|
app.add_middleware(
|
||||||
|
SessionMiddleware,
|
||||||
|
secret_key=env("SESSION_SECRET_KEY", "change-this-session-secret"),
|
||||||
|
https_only=env("SESSION_COOKIE_SECURE", "true").lower() == "true",
|
||||||
|
same_site="lax",
|
||||||
|
max_age=60 * 60 * 8,
|
||||||
|
)
|
||||||
|
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||||
|
oauth = build_google_oauth()
|
||||||
|
|
||||||
|
|
||||||
|
def public_url_for(request: Request, route_name: str) -> str:
|
||||||
|
public_base_url = env("PUBLIC_BASE_URL").rstrip("/")
|
||||||
|
if public_base_url:
|
||||||
|
return f"{public_base_url}{request.url_for(route_name).path}"
|
||||||
|
return str(request.url_for(route_name))
|
||||||
|
|
||||||
|
|
||||||
|
def get_user(request: Request) -> dict[str, Any] | None:
|
||||||
|
user = request.session.get("user")
|
||||||
|
return user if isinstance(user, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def render_template(
|
||||||
|
request: Request,
|
||||||
|
name: str,
|
||||||
|
context: dict[str, Any] | None = None,
|
||||||
|
status_code: int = 200,
|
||||||
|
) -> HTMLResponse:
|
||||||
|
template_context = {"request": request, **(context or {})}
|
||||||
|
first_param = next(iter(inspect.signature(templates.TemplateResponse).parameters))
|
||||||
|
if first_param == "request":
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
name,
|
||||||
|
template_context,
|
||||||
|
status_code=status_code,
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
name,
|
||||||
|
template_context,
|
||||||
|
status_code=status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_allowed_google_user(userinfo: dict[str, Any]) -> tuple[bool, str]:
|
||||||
|
email = str(userinfo.get("email", "")).lower().strip()
|
||||||
|
email_verified = bool(userinfo.get("email_verified"))
|
||||||
|
domain = email.rsplit("@", 1)[-1] if "@" in email else ""
|
||||||
|
|
||||||
|
if not email_verified:
|
||||||
|
return False, "Google 계정 이메일 인증이 확인되지 않았습니다."
|
||||||
|
if domain != ALLOWED_DOMAIN:
|
||||||
|
return False, "회사 Google Workspace 계정만 접속할 수 있습니다."
|
||||||
|
if email not in ALLOWED_EMAILS:
|
||||||
|
return False, "접속 허용 목록에 없는 계정입니다."
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def home(request: Request) -> HTMLResponse:
|
||||||
|
user = get_user(request)
|
||||||
|
if not user:
|
||||||
|
return render_template(request, "login.html")
|
||||||
|
|
||||||
|
menu_items = [
|
||||||
|
{
|
||||||
|
"title": "CS 발주 업무",
|
||||||
|
"description": "CS 발주 업무 페이지로 이동",
|
||||||
|
"url": env("CS_ORDER_URL", "#"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "고객 주문리스트 프로그램",
|
||||||
|
"description": "고객 주문리스트 프로그램으로 이동",
|
||||||
|
"url": env("CUSTOMER_ORDER_LIST_URL", "#"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return render_template(
|
||||||
|
request,
|
||||||
|
"main.html",
|
||||||
|
{"user": user, "menu_items": menu_items},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/login")
|
||||||
|
async def login(request: Request):
|
||||||
|
if not env("GOOGLE_CLIENT_ID") or not env("GOOGLE_CLIENT_SECRET"):
|
||||||
|
return render_template(
|
||||||
|
request,
|
||||||
|
"denied.html",
|
||||||
|
{"reason": "Google OAuth 환경 변수가 아직 설정되지 않았습니다."},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
redirect_uri = public_url_for(request, "auth_google")
|
||||||
|
return await oauth.google.authorize_redirect(
|
||||||
|
request,
|
||||||
|
redirect_uri,
|
||||||
|
hd=ALLOWED_DOMAIN,
|
||||||
|
prompt="select_account",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/auth/google")
|
||||||
|
async def auth_google(request: Request):
|
||||||
|
try:
|
||||||
|
token = await oauth.google.authorize_access_token(request)
|
||||||
|
userinfo = token.get("userinfo")
|
||||||
|
if userinfo is None:
|
||||||
|
userinfo = await oauth.google.userinfo(token=token)
|
||||||
|
except OAuthError as exc:
|
||||||
|
return render_template(
|
||||||
|
request,
|
||||||
|
"denied.html",
|
||||||
|
{"reason": f"Google 로그인 실패: {exc.error}"},
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
|
||||||
|
allowed, reason = is_allowed_google_user(dict(userinfo))
|
||||||
|
if not allowed:
|
||||||
|
request.session.clear()
|
||||||
|
return render_template(
|
||||||
|
request,
|
||||||
|
"denied.html",
|
||||||
|
{"reason": reason},
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
|
||||||
|
request.session["user"] = {
|
||||||
|
"email": str(userinfo.get("email", "")).lower().strip(),
|
||||||
|
"name": userinfo.get("name") or userinfo.get("email"),
|
||||||
|
"picture": userinfo.get("picture", ""),
|
||||||
|
}
|
||||||
|
return RedirectResponse(url="/", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/logout")
|
||||||
|
async def logout(request: Request) -> RedirectResponse:
|
||||||
|
request.session.clear()
|
||||||
|
return RedirectResponse(url="/", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/healthz")
|
||||||
|
async def healthz() -> dict[str, str]:
|
||||||
|
return {"status": "ok"}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,222 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: "Pretendard";
|
||||||
|
src: url("/static/fonts/Pretendard-Thin.ttf") format("truetype");
|
||||||
|
font-weight: 100;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Pretendard";
|
||||||
|
src: url("/static/fonts/Pretendard-Regular.ttf") format("truetype");
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Pretendard";
|
||||||
|
src: url("/static/fonts/Pretendard-Bold.ttf") format("truetype");
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #f5f7f9;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--text: #17202a;
|
||||||
|
--muted: #5d6975;
|
||||||
|
--line: #d9e0e7;
|
||||||
|
--accent: #146c5f;
|
||||||
|
--accent-strong: #0e554b;
|
||||||
|
--shadow: 0 18px 50px rgba(22, 31, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family:
|
||||||
|
"Pretendard",
|
||||||
|
"Segoe UI",
|
||||||
|
"Apple SD Gothic Neo",
|
||||||
|
system-ui,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-shell {
|
||||||
|
width: min(100%, 460px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(30px, 4vw, 44px);
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
margin: 18px 0 28px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
margin: 18px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button,
|
||||||
|
.ghost-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 44px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
padding: 0 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button:hover {
|
||||||
|
background: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-button {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #fff;
|
||||||
|
padding: 0 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 28px clamp(20px, 5vw, 56px);
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account img {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account strong,
|
||||||
|
.account span {
|
||||||
|
display: block;
|
||||||
|
max-width: 240px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
padding: clamp(20px, 5vw, 56px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-card {
|
||||||
|
display: flex;
|
||||||
|
min-height: 150px;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 10px 30px rgba(22, 31, 42, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-card:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-card span {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-card small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.topbar {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account {
|
||||||
|
width: 100%;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-button {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>접속 불가</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body class="auth-page">
|
||||||
|
<main class="auth-shell">
|
||||||
|
<section class="auth-panel">
|
||||||
|
<p class="eyebrow">Access denied</p>
|
||||||
|
<h1>접속할 수 없습니다</h1>
|
||||||
|
<p class="lead">{{ reason }}</p>
|
||||||
|
<a class="primary-button" href="/login">다른 Google 계정으로 로그인</a>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>DBX 메인 페이지 로그인</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body class="auth-page">
|
||||||
|
<main class="auth-shell">
|
||||||
|
<section class="auth-panel">
|
||||||
|
<p class="eyebrow">DBX Workspace</p>
|
||||||
|
<h1>메인 페이지</h1>
|
||||||
|
<p class="lead">회사 Google Workspace 계정으로 로그인하세요.</p>
|
||||||
|
<a class="primary-button" href="/login">Google 계정으로 로그인</a>
|
||||||
|
<p class="note">허용된 계정만 접속할 수 있습니다.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>DBX 메인 페이지</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">DBX Workspace</p>
|
||||||
|
<h1>업무 메뉴</h1>
|
||||||
|
</div>
|
||||||
|
<div class="account">
|
||||||
|
{% if user.picture %}
|
||||||
|
<img src="{{ user.picture }}" alt="" />
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<strong>{{ user.name }}</strong>
|
||||||
|
<span>{{ user.email }}</span>
|
||||||
|
</div>
|
||||||
|
<a class="ghost-button" href="/logout">로그아웃</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="menu-grid">
|
||||||
|
{% for item in menu_items %}
|
||||||
|
<a class="menu-card" href="{{ item.url }}">
|
||||||
|
<span>{{ item.title }}</span>
|
||||||
|
<small>{{ item.description }}</small>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
container_name: dbx-main-local
|
||||||
|
restart: "no"
|
||||||
|
ports:
|
||||||
|
# 로컬에서는 http://localhost:8080 으로 접속
|
||||||
|
- "8080:8000"
|
||||||
|
env_file:
|
||||||
|
- .env.local
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
image: dbx-main:latest
|
||||||
|
container_name: dbx-main
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
# NPM이 192.168.0.194:80 으로 프록시 → 컨테이너 8000으로 전달
|
||||||
|
- "80:8000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name dbx.no1king.freeddns.org;
|
||||||
|
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name dbx.no1king.freeddns.org;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/dbx.no1king.freeddns.org/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/dbx.no1king.freeddns.org/privkey.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fastapi>=0.115
|
||||||
|
uvicorn[standard]>=0.34
|
||||||
|
authlib>=1.3
|
||||||
|
httpx>=0.28
|
||||||
|
jinja2>=3.1
|
||||||
|
itsdangerous>=2.2
|
||||||
|
python-dotenv>=1.0
|
||||||
Reference in New Issue
Block a user