prepare app for public app store distribution
- rebrand from 습관 트래커 to 해빗랩 across templates, manifest, service worker - add HTTPS redirect middleware for reverse-proxied deployments - add public landing page (/) and privacy policy page with real data handling disclosures - add in-app account deletion (Apple review requirement) - add Android TWA Digital Asset Links support (/.well-known/assetlinks.json) - add SFTP deployment script for the Synology-hosted server Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+18
-2
@@ -2,7 +2,7 @@ import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
@@ -24,12 +24,22 @@ async def lifespan(app: FastAPI):
|
||||
scheduler_service.shutdown_scheduler()
|
||||
|
||||
|
||||
app = FastAPI(title="습관 트래커", lifespan=lifespan)
|
||||
app = FastAPI(title="해빗랩", lifespan=lifespan)
|
||||
|
||||
# 구글 OAuth 핸드셰이크 중 state/nonce를 임시로 저장하는 데만 쓰는 세션이다.
|
||||
# 로그인 유지용 쿠키(habit_session)와는 별개.
|
||||
app.add_middleware(SessionMiddleware, secret_key=settings.secret_key, session_cookie="oauth_session", max_age=600)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def redirect_http_to_https(request: Request, call_next):
|
||||
# 리버스 프록시가 X-Forwarded-Proto로 원래 스킴을 알려줄 때만 동작한다
|
||||
# (로컬 uvicorn 직접 실행 시에는 이 헤더가 없어 그냥 통과).
|
||||
if request.headers.get("x-forwarded-proto") == "http":
|
||||
return RedirectResponse(str(request.url.replace(scheme="https")), status_code=301)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
|
||||
app.include_router(auth.router)
|
||||
@@ -75,3 +85,9 @@ async def unhandled_exception_handler(request: Request, exc: Exception):
|
||||
def service_worker():
|
||||
# 서비스워커 scope가 앱 전체를 커버하려면 /static/ 하위가 아닌 루트 경로로 서빙해야 한다.
|
||||
return FileResponse("app/static/service-worker.js", media_type="application/javascript")
|
||||
|
||||
|
||||
@app.get("/.well-known/assetlinks.json")
|
||||
def assetlinks():
|
||||
# Android TWA(kr.co.alphalok.habit.twa)의 도메인 소유권 증명용 — 반드시 /.well-known/ 루트 경로여야 한다.
|
||||
return FileResponse("app/static/.well-known/assetlinks.json", media_type="application/json")
|
||||
|
||||
+48
-4
@@ -8,12 +8,13 @@ from fastapi.templating import Jinja2Templates
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.habit import HabitDifficulty, HabitStatus, HabitType
|
||||
from app.models.user import User
|
||||
from app.schemas.habit import HabitCreate, HabitUpdate
|
||||
from app.security import get_current_user_optional
|
||||
from app.services import habit_service, log_service
|
||||
from app.services import habit_service, log_service, user_service
|
||||
from app.template_utils import (
|
||||
difficulty_label,
|
||||
goal_progress,
|
||||
@@ -46,14 +47,57 @@ def _current_user_or_redirect(request: Request, db: Session) -> User | RedirectR
|
||||
def index(request: Request, db: Session = Depends(get_db)):
|
||||
if get_current_user_optional(request, db) is not None:
|
||||
return RedirectResponse(url="/today", status_code=303)
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
# 로그인 없이도 앱 목적을 설명하는 페이지가 있어야 한다(구글 OAuth 동의 화면 "홈페이지" 요건).
|
||||
return templates.TemplateResponse(request, "home.html", {"logged_in": False, "current_user": None})
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
def login_page(request: Request, db: Session = Depends(get_db)):
|
||||
def login_page(request: Request, deleted: bool = False, db: Session = Depends(get_db)):
|
||||
if get_current_user_optional(request, db) is not None:
|
||||
return RedirectResponse(url="/today", status_code=303)
|
||||
return templates.TemplateResponse(request, "login.html", {"logged_in": False, "current_user": None})
|
||||
return templates.TemplateResponse(
|
||||
request, "login.html", {"logged_in": False, "current_user": None, "deleted": deleted}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/privacy")
|
||||
def privacy_page(request: Request, db: Session = Depends(get_db)):
|
||||
user = get_current_user_optional(request, db)
|
||||
return templates.TemplateResponse(
|
||||
request, "privacy.html", {"logged_in": user is not None, "current_user": user}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/account")
|
||||
def account_page(request: Request, db: Session = Depends(get_db)):
|
||||
current = _current_user_or_redirect(request, db)
|
||||
if isinstance(current, RedirectResponse):
|
||||
return current
|
||||
return templates.TemplateResponse(request, "account.html", {"logged_in": True, "current_user": current})
|
||||
|
||||
|
||||
@router.post("/account/delete")
|
||||
def delete_account_page(request: Request, confirm_email: str = Form(""), db: Session = Depends(get_db)):
|
||||
current = _current_user_or_redirect(request, db)
|
||||
if isinstance(current, RedirectResponse):
|
||||
return current
|
||||
|
||||
if confirm_email.strip().lower() != current.email.lower():
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"account.html",
|
||||
{
|
||||
"logged_in": True,
|
||||
"current_user": current,
|
||||
"delete_error": "입력한 이메일이 계정 이메일과 일치하지 않습니다.",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
user_service.delete_account(db, current)
|
||||
response = RedirectResponse(url="/login?deleted=1", status_code=303)
|
||||
response.delete_cookie(settings.session_cookie_name)
|
||||
return response
|
||||
|
||||
|
||||
def _today_context(db: Session, user_id: int, **extra) -> dict:
|
||||
|
||||
+1
-1
@@ -42,5 +42,5 @@ def unsubscribe(
|
||||
|
||||
@router.post("/test")
|
||||
def send_test(db: Session = Depends(get_db), current_user: User = Depends(require_login)):
|
||||
sent = push_service.send_to_user(db, current_user.id, title="습관 트래커", body="테스트 알림입니다.")
|
||||
sent = push_service.send_to_user(db, current_user.id, title="해빗랩", body="테스트 알림입니다.")
|
||||
return {"sent": sent}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def delete_account(db: Session, user: User) -> None:
|
||||
"""계정과 그에 딸린 모든 데이터를 삭제한다.
|
||||
|
||||
Habit/PushSubscription/HabitLog/HabitNotificationLog/SummaryNotificationLog는 전부
|
||||
user.id 기준 DB 레벨 ON DELETE CASCADE로 연결돼 있어(마이그레이션 0004/0005 참고),
|
||||
User 행만 지우면 나머지는 MariaDB가 알아서 정리한다.
|
||||
"""
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
@@ -0,0 +1,8 @@
|
||||
[{
|
||||
"relation": ["delegate_permission/common.handle_all_urls"],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "kr.co.alphalok.habit.twa",
|
||||
"sha256_cert_fingerprints": ["2A:BC:19:7D:91:65:3A:88:68:02:8C:3B:1D:58:42:C7:17:02:FA:B9:C9:4F:01:02:3D:60:B7:C9:CE:6F:05:20"]
|
||||
}
|
||||
}]
|
||||
@@ -111,6 +111,16 @@ p {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
text-align: center;
|
||||
padding: var(--space-4) 0 var(--space-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.app-footer a {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* iOS 홈 화면 추가 안내 배너 */
|
||||
.ios-install-banner {
|
||||
display: none;
|
||||
@@ -799,3 +809,154 @@ label {
|
||||
.wm-dash {
|
||||
color: var(--color-border);
|
||||
}
|
||||
|
||||
/* 랜딩 페이지 (/) */
|
||||
.landing {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-4) var(--space-2) var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.landing-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.landing-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.landing-brand img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.landing-hero h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
text-wrap: balance;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.landing-tagline {
|
||||
font-size: 16px;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 요일 스트릭 시연: 실제 습관 체크 인터랙션을 그대로 축소해 보여준다 */
|
||||
.landing-streak {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.landing-day {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.landing-day.done {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: #fff;
|
||||
opacity: 0;
|
||||
animation: landing-day-in 0.35s ease-out forwards;
|
||||
}
|
||||
|
||||
.landing-day.done svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.landing-day:nth-of-type(1).done { animation-delay: 0.1s; }
|
||||
.landing-day:nth-of-type(2).done { animation-delay: 0.25s; }
|
||||
.landing-day:nth-of-type(3).done { animation-delay: 0.4s; }
|
||||
.landing-day:nth-of-type(4).done { animation-delay: 0.55s; }
|
||||
.landing-day:nth-of-type(5).done { animation-delay: 0.7s; }
|
||||
|
||||
@keyframes landing-day-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.6);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.landing-day.done {
|
||||
opacity: 1;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.landing-copy {
|
||||
font-size: 15.5px;
|
||||
line-height: 1.75;
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.landing-copy strong {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.landing-features {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.landing-feature {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
|
||||
.landing-feature svg {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.landing-feature p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.landing-feature p strong {
|
||||
display: block;
|
||||
font-size: 14.5px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "습관 트래커",
|
||||
"short_name": "습관 트래커",
|
||||
"name": "해빗랩",
|
||||
"short_name": "해빗랩",
|
||||
"description": "형성하고 싶은 습관과 끊고 싶은 습관을 요일별로 관리하고 매일 체크하는 개인용 습관 관리 앱",
|
||||
"start_url": "/today",
|
||||
"scope": "/",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>오프라인 - 습관 트래커</title>
|
||||
<title>오프라인 - 해빗랩</title>
|
||||
<link rel="icon" href="/static/icons/icon-192.png" />
|
||||
<link rel="stylesheet" href="/static/css/style.css" />
|
||||
</head>
|
||||
|
||||
@@ -78,7 +78,7 @@ self.addEventListener("push", (event) => {
|
||||
if (!event.data) return;
|
||||
const data = event.data.json();
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title || "습관 트래커", {
|
||||
self.registration.showNotification(data.title || "해빗랩", {
|
||||
body: data.body || "",
|
||||
icon: "/static/icons/icon-192.png",
|
||||
badge: "/static/icons/icon-192.png",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}페이지를 찾을 수 없어요 · 습관 트래커{% endblock %}
|
||||
{% block title %}페이지를 찾을 수 없어요 · 해빗랩{% endblock %}
|
||||
{% block content %}
|
||||
<div style="min-height: 60vh; display:flex; align-items:center; justify-content:center; text-align:center;">
|
||||
<div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}오류가 발생했어요 · 습관 트래커{% endblock %}
|
||||
{% block title %}오류가 발생했어요 · 해빗랩{% endblock %}
|
||||
{% block content %}
|
||||
<div style="min-height: 60vh; display:flex; align-items:center; justify-content:center; text-align:center;">
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}계정 · 해빗랩{% endblock %}
|
||||
{% block content %}
|
||||
<h1>계정</h1>
|
||||
|
||||
<div class="card">
|
||||
<p><strong>이메일</strong><br />{{ current_user.email }}</p>
|
||||
{% if current_user.name %}<p><strong>이름</strong><br />{{ current_user.name }}</p>{% endif %}
|
||||
<p><strong>가입일</strong><br />{{ current_user.created_at.strftime('%Y-%m-%d') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>계정 삭제</h2>
|
||||
<p>
|
||||
계정을 삭제하면 등록한 모든 습관, 체크 기록, 알림 구독 정보가 즉시 영구적으로 삭제되며 되돌릴 수 없습니다.
|
||||
</p>
|
||||
{% if delete_error %}<p style="color: var(--color-danger);">{{ delete_error }}</p>{% endif %}
|
||||
<form method="post" action="/account/delete">
|
||||
<label for="confirm_email">확인을 위해 본인 이메일({{ current_user.email }})을 입력하세요</label>
|
||||
<input type="text" id="confirm_email" name="confirm_email" autocomplete="off" />
|
||||
<button type="submit" class="btn btn-danger-ghost btn-block" style="margin-top: var(--space-2);">계정 영구 삭제</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -3,14 +3,14 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>{% block title %}습관 트래커{% endblock %}</title>
|
||||
<title>{% block title %}해빗랩{% endblock %}</title>
|
||||
|
||||
<link rel="manifest" href="/static/manifest.json" />
|
||||
<meta name="theme-color" content="#d97757" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#e08962" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="습관 트래커" />
|
||||
<meta name="apple-mobile-web-app-title" content="해빗랩" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/icons/icon-apple-180.png" />
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png" />
|
||||
<link rel="icon" href="/static/icons/icon-192.png" />
|
||||
@@ -31,7 +31,7 @@
|
||||
<button type="button" onclick="dismissIosInstallBanner()" aria-label="닫기">×</button>
|
||||
</div>
|
||||
<nav class="top-nav">
|
||||
<span class="brand">습관 트래커</span>
|
||||
<span class="brand">해빗랩</span>
|
||||
<div class="nav-links">
|
||||
<a href="/today" class="{% block nav_today %}{% endblock %}">오늘</a>
|
||||
<a href="/habits" class="{% block nav_habits %}{% endblock %}">습관 관리</a>
|
||||
@@ -40,7 +40,7 @@
|
||||
{% if current_user %}
|
||||
<div class="nav-user">
|
||||
{% if account_level %}<span class="nav-level-pill">Lv.{{ account_level.level }}</span>{% endif %}
|
||||
<span>{{ current_user.name or current_user.email }}</span>
|
||||
<a href="/account">{{ current_user.name or current_user.email }}</a>
|
||||
<form method="post" action="/auth/logout">
|
||||
<button type="submit" class="btn-link">로그아웃</button>
|
||||
</form>
|
||||
@@ -63,6 +63,9 @@
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% block content %}{% endblock %}
|
||||
<footer class="app-footer">
|
||||
<a href="/privacy">개인정보처리방침</a>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}습관 관리 · 습관 트래커{% endblock %}
|
||||
{% block title %}습관 관리 · 해빗랩{% endblock %}
|
||||
{% block nav_habits %}active{% endblock %}
|
||||
{% block content %}
|
||||
<h1>습관 관리</h1>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}기록 · 습관 트래커{% endblock %}
|
||||
{% block title %}기록 · 해빗랩{% endblock %}
|
||||
{% block nav_history %}active{% endblock %}
|
||||
{% block shell_class %} wide{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}해빗랩 · 습관이 만들어지는 진짜 시간{% endblock %}
|
||||
{% block content %}
|
||||
<div class="landing">
|
||||
<div class="landing-hero">
|
||||
<div class="landing-brand"><img src="/static/icons/icon-192.png" alt="" /> 해빗랩</div>
|
||||
<h1>습관이 만들어지는<br />진짜 시간</h1>
|
||||
<p class="landing-tagline">21일의 법칙 대신, 습관마다 다른 진짜 목표 기간을 알려드려요</p>
|
||||
|
||||
<div class="landing-streak" aria-hidden="true">
|
||||
<div class="landing-day done">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5 9-9" /></svg>
|
||||
</div>
|
||||
<div class="landing-day done">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5 9-9" /></svg>
|
||||
</div>
|
||||
<div class="landing-day done">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5 9-9" /></svg>
|
||||
</div>
|
||||
<div class="landing-day done">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5 9-9" /></svg>
|
||||
</div>
|
||||
<div class="landing-day done">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5 9-9" /></svg>
|
||||
</div>
|
||||
<div class="landing-day">토</div>
|
||||
<div class="landing-day">일</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="landing-copy">
|
||||
<p>
|
||||
"습관은 21일이면 만들어진다"는 말, 많이 들어보셨을 거예요. 사실 근거가 약한 통설이에요.
|
||||
실증 연구(<strong>Lally et al., 2010, UCL</strong>)에 따르면 습관이 몸에 붙기까지 걸리는 시간은
|
||||
사람마다, 습관마다 달라서 짧게는 3주, 길게는 8개월 넘게 걸리기도 해요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>해빗랩</strong>은 여러분이 만들고 싶은 습관도, 멈추고 싶은 습관도 함께 만들어가는 동반자예요.
|
||||
검증된 연구를 시스템에 그대로 담아 목표 기간과 진행 상황을 짚어드리고, 그 여정을 끝까지 응원할게요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="landing-features">
|
||||
<div class="landing-feature">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="5" /><circle cx="12" cy="12" r="1" /></svg>
|
||||
<p><strong>연구 기반 목표 기간</strong>속설이 아니라 실제 연구값(21·66·254일)으로 목표를 잡아요</p>
|
||||
</div>
|
||||
<div class="landing-feature">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="12" r="6" /><line x1="8" y1="9" x2="8" y2="15" /><line x1="5" y1="12" x2="11" y2="12" /><circle cx="17" cy="12" r="6" /><line x1="14" y1="12" x2="20" y2="12" /></svg>
|
||||
<p><strong>만들기와 끊기, 동시에</strong>새 습관을 만드는 것과 나쁜 습관을 끊는 것을 똑같은 무게로 다뤄요</p>
|
||||
</div>
|
||||
<div class="landing-feature">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
|
||||
<p><strong>광고·구독 없음</strong>습관관리에만 집중해요, 그 외엔 아무것도 없어요</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="/auth/google/login" class="btn btn-primary btn-block">Google로 시작하기</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,9 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}로그인 · 습관 트래커{% endblock %}
|
||||
{% block title %}로그인 · 해빗랩{% endblock %}
|
||||
{% block content %}
|
||||
<div style="min-height: 70vh; display: flex; align-items: center; justify-content: center;">
|
||||
<div class="card" style="width: 100%; max-width: 320px;">
|
||||
<h1 style="text-align:center;">습관 트래커</h1>
|
||||
<h1 style="text-align:center;">해빗랩</h1>
|
||||
{% if deleted %}<p style="text-align:center; color: var(--color-success);">계정이 삭제되었습니다.</p>{% endif %}
|
||||
<p style="text-align:center;">구글 계정으로 로그인하세요</p>
|
||||
<a href="/auth/google/login" class="btn btn-primary btn-block">Google로 로그인</a>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}개인정보처리방침 · 해빗랩{% endblock %}
|
||||
{% block content %}
|
||||
<div style="max-width: 640px; margin: 0 auto; padding: 24px 16px 48px;">
|
||||
<div class="card">
|
||||
<h1>개인정보처리방침</h1>
|
||||
<p>시행일자: 2026년 7월 22일</p>
|
||||
|
||||
<p>해빗랩(이하 "서비스")는 이용자의 개인정보를 소중히 다루며, 아래와 같이 개인정보를 수집·이용·보관합니다.</p>
|
||||
|
||||
<h2>1. 수집하는 개인정보 항목</h2>
|
||||
<p><strong>가. 구글 로그인 시 (필수)</strong><br />
|
||||
이메일 주소, 이름, 프로필 사진 URL, 구글 계정 고유 식별자(sub). 서비스는 구글 계정 정보를 이용해 자동으로 계정을 생성하며, 비밀번호는 별도로 수집·저장하지 않습니다.</p>
|
||||
<p><strong>나. 서비스 이용 중 직접 입력하는 정보</strong><br />
|
||||
등록한 습관의 이름, 유형(만들기/끊기), 요일 스케줄, 목표 난이도, 달성 조건, 알림 시각, 날짜별 체크·실패 기록.</p>
|
||||
<p><strong>다. 알림(Web Push)을 켠 경우에만</strong><br />
|
||||
브라우저가 발급하는 푸시 구독 정보(endpoint, 암호화 키)와 브라우저 종류(User-Agent). 알림 중복 발송을 막기 위한 발송 이력도 함께 기록됩니다.</p>
|
||||
<p><strong>라. 자동 수집 정보</strong><br />
|
||||
로그인 유지를 위한 서명된 세션 쿠키. 별도의 광고·분석·트래킹 쿠키는 사용하지 않습니다.</p>
|
||||
|
||||
<h2>2. 개인정보의 수집 및 이용 목적</h2>
|
||||
<p>구글 계정 인증 및 로그인 유지, 습관 등록·체크·통계(완료율, 연속 달성일 등) 제공, 설정한 시각에 맞춘 습관 알림(Web Push) 발송을 위해서만 이용합니다. 광고, 마케팅, 프로필링 목적으로는 이용하지 않습니다.</p>
|
||||
|
||||
<h2>3. 개인정보의 보유 및 이용 기간</h2>
|
||||
<p>회원 탈퇴 시까지 보관하며, 탈퇴 시 계정 정보와 습관·체크 기록, 푸시 구독 정보를 지체 없이 삭제합니다. 관계 법령에 따라 보존이 필요한 경우가 아니면 별도 보관하지 않습니다.</p>
|
||||
|
||||
<h2>4. 개인정보의 제3자 제공</h2>
|
||||
<p>서비스는 이용자의 개인정보를 원칙적으로 외부에 제공하지 않습니다. 다만 구글 로그인 인증 과정에서 구글(Google LLC)과 통신이 발생하며, 이는 이용자 인증을 위한 목적에 한정됩니다.</p>
|
||||
|
||||
<h2>5. 이용자의 권리와 행사 방법</h2>
|
||||
<p>이용자는 언제든지 자신의 개인정보 열람·정정·삭제를 요청할 수 있습니다. 로그인 후 <a href="/account">계정</a> 페이지에서 본인 이메일 확인 후 즉시 계정과 모든 데이터를 직접 삭제할 수 있으며, 앱을 이용하기 어려운 경우 아래 문의처로 요청하셔도 확인 후 지체 없이 처리해드립니다.</p>
|
||||
|
||||
<h2>6. 보안을 위한 조치</h2>
|
||||
<p>비밀번호를 직접 저장하지 않는 구글 OAuth 인증 방식을 사용하며, 로그인 세션은 서명된 쿠키로 관리합니다. 모든 통신은 HTTPS로 암호화되며, 각 이용자의 데이터는 계정 단위로 분리되어 다른 이용자가 접근할 수 없습니다.</p>
|
||||
|
||||
<h2>7. 문의처</h2>
|
||||
<p>개인정보 관련 문의, 열람·정정·삭제 요청은 아래로 연락해주세요.</p>
|
||||
<p>해빗랩 운영자<br />
|
||||
이메일: <a href="mailto:shinalok357@gmail.com">shinalok357@gmail.com</a></p>
|
||||
|
||||
<h2>8. 고지의 의무</h2>
|
||||
<p>이 개인정보처리방침은 법령이나 서비스 변경사항을 반영하기 위해 수정될 수 있으며, 변경 시 이 페이지를 통해 고지합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}오늘 · 습관 트래커{% endblock %}
|
||||
{% block title %}오늘 · 해빗랩{% endblock %}
|
||||
{% block nav_today %}active{% endblock %}
|
||||
{% block content %}
|
||||
<div id="today-content">
|
||||
|
||||
Reference in New Issue
Block a user