diff --git a/AGENTS.md b/AGENTS.md index 8452443..34e9f42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,8 +10,8 @@ copying content into prompts or new files. When documents disagree, follow this order: -1. [`docs/decision-log.md`](docs/decision-log.md) — working assumptions for Q1~Q7 (확정) - and Q8/Q9 (계정·설정 IA / L2 의미 — 제안 until Master confirms) +1. [`docs/decision-log.md`](docs/decision-log.md) — working assumptions for Q1~Q8 (확정) + and Q9 (L2 의미 — 제안 until Master confirms) 2. [`docs/vision.md`](docs/vision.md) / [`docs/PRD.md`](docs/PRD.md) / [`docs/tech-design.md`](docs/tech-design.md) 3. [`docs/roadmap.md`](docs/roadmap.md) / [`docs/risk-log.md`](docs/risk-log.md) 4. [`docs/PLANNING.md`](docs/PLANNING.md) — process guide @@ -19,12 +19,12 @@ When documents disagree, follow this order: Notes: -- Q1~Q7 in `decision-log.md` are **확정** (Phase 1 C, 2026-07-30). PoC-dependent - sub-questions (default autonomy level, whitelist defaults, final branding) - stay open — do not invent those. Q8/Q9 (logout/settings IA, L2 semantics) are - **제안** — implement only after Master marks them 확정; see - `docs/account-settings-ia.md`. To reverse a Q, update `decision-log.md` and - derived docs in the same change. +- Q1~Q8 in `decision-log.md` are **확정** (Q1~Q7: Phase 1 C, 2026-07-30; + Q8 account/settings IA: 2026-08-03). PoC-dependent sub-questions (default + autonomy level, whitelist defaults, final branding) stay open — do not invent + those. Q9 (L2 receive-triggered auto-reply) remains **제안** — implement only + after Master marks it 확정; see `docs/account-settings-ia.md`. To reverse a Q, + update `decision-log.md` and derived docs in the same change. - Prefer `decision-log.md` (and the synced summary in `PLANNING.md` §2) for current working answers. - Working delivery order is **자체 앱 클로즈드 베타 first → OS 레이어 later** diff --git a/core-backend/a1_a2_routes.go b/core-backend/a1_a2_routes.go index 6ca0ff4..6d4afec 100644 --- a/core-backend/a1_a2_routes.go +++ b/core-backend/a1_a2_routes.go @@ -3,6 +3,7 @@ package main import ( "net/http" "strconv" + "strings" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -46,29 +47,49 @@ func contactJSON(ct Contact) gin.H { } type loginRequest struct { - InviteCode string `json:"invite_code" binding:"required"` + InviteCode string `json:"invite_code" binding:"required"` + DisplayName string `json:"display_name"` } func registerA1A2Routes(r *gin.Engine, db *gorm.DB) { + // Q8b: re-enter closed beta with invite code (+ display_name for shared DEMO). r.POST("/auth/login", func(c *gin.Context) { var req loginRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) return } - var invite InviteCode - if err := db.Where("code = ?", req.InviteCode).First(&invite).Error; err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid invite code"}) - return - } - if invite.UsedAt == nil || invite.UsedByUserID == nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": "invite code not yet used for signup"}) - return - } var user User - if err := db.First(&user, *invite.UsedByUserID).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "user not found"}) - return + if demoInviteEnabled() && isDemoInviteCode(req.InviteCode) { + name := strings.TrimSpace(req.DisplayName) + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{"detail": "display_name is required for DEMO invite login"}) + return + } + // Demo signups store a unique per-user invite_code, not DEMO-YKAVU. + // Match the most recent user with this display_name (testers reuse names rarely). + if err := db.Where("display_name = ?", name).Order("id desc").First(&user).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "no user with that display_name under DEMO signup"}) + return + } + } else { + var invite InviteCode + if err := db.Where("code = ?", req.InviteCode).First(&invite).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid invite code"}) + return + } + if invite.UsedAt == nil || invite.UsedByUserID == nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invite code not yet used for signup"}) + return + } + if err := db.First(&user, *invite.UsedByUserID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "user not found"}) + return + } + if name := strings.TrimSpace(req.DisplayName); name != "" && user.DisplayName != name { + c.JSON(http.StatusForbidden, gin.H{"detail": "display_name does not match this invite"}) + return + } } session, err := createSession(db, user.ID) if err != nil { diff --git a/core-backend/a1_a2_test.go b/core-backend/a1_a2_test.go index c3d2abb..de625c8 100644 --- a/core-backend/a1_a2_test.go +++ b/core-backend/a1_a2_test.go @@ -63,7 +63,7 @@ func TestLoginReturnsSessionToken(t *testing.T) { t.Fatalf("signup: %d", signup.StatusCode) } - login := postJSON(t, server.URL+"/auth/login", loginRequest{InviteCode: code}) + login := postJSON(t, server.URL+"/auth/login", loginRequest{InviteCode: code, DisplayName: "로그인"}) if login.StatusCode != http.StatusOK { t.Fatalf("expected 200 login, got %d", login.StatusCode) } @@ -74,6 +74,23 @@ func TestLoginReturnsSessionToken(t *testing.T) { } } +func TestDemoLoginRequiresDisplayName(t *testing.T) { + t.Setenv("ALLOW_DEMO_INVITE", "1") + server, _ := setupTestServer(t) + signup := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: demoInviteCode, DisplayName: "데모유저"}) + if signup.StatusCode != http.StatusOK { + t.Fatalf("demo signup: %d", signup.StatusCode) + } + missing := postJSON(t, server.URL+"/auth/login", loginRequest{InviteCode: demoInviteCode}) + if missing.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 without display_name, got %d", missing.StatusCode) + } + ok := postJSON(t, server.URL+"/auth/login", loginRequest{InviteCode: demoInviteCode, DisplayName: "데모유저"}) + if ok.StatusCode != http.StatusOK { + t.Fatalf("expected 200 demo login, got %d", ok.StatusCode) + } +} + func TestConversationContactMessageHistoryAndEscalationLogs(t *testing.T) { server, _ := setupTestServer(t) ownerID, ownerToken := mustSignup(t, server.URL, "주인") diff --git a/docs/PLANNING.md b/docs/PLANNING.md index dd5c60e..03e9a39 100644 --- a/docs/PLANNING.md +++ b/docs/PLANNING.md @@ -30,9 +30,9 @@ 기능 명세를 쓰기 전에 아래 표를 채운다. 답이 안 나온 항목은 "보류 사유"를 적어두고 다음 회의 안건으로 남긴다. 현재 답은 [`decision-log.md`](./decision-log.md)에 있으며, **Q1~Q7은 Phase 1 C에서 확정**(2026-07-30). -PoC 의존 하위 질문(자율성 기본값 등)과 **Q8/Q9(계정·설정 IA / L2 의미, 2026-08-03 제안)** 가 -열려 있다 — decision-log를 단일 기준으로 따른다. 계정 UX 상세는 -[`account-settings-ia.md`](./account-settings-ia.md). +PoC 의존 하위 질문(자율성 기본값 등)과 **Q9(L2 의미, 제안)** 가 열려 있다. +**Q8(계정·설정 IA)은 2026-08-03 확정·구현** — decision-log를 단일 기준으로 따른다. +계정 UX 상세는 [`account-settings-ia.md`](./account-settings-ia.md). | # | 질문 | 결정 | 상태 | |---|------|------|------| @@ -107,7 +107,7 @@ PoC 의존 하위 질문(자율성 기본값 등)과 **Q8/Q9(계정·설정 IA / 3. [`tech-design.md`](./tech-design.md) — 온디바이스/서버 경계, 데이터 흐름, 에스컬레이션 로직 4. [`risk-log.md`](./risk-log.md) — 회의 자료 §2-6, §oslayer §4의 리스크를 완화 상태와 함께 추적 5. [`roadmap.md`](./roadmap.md) — L0~L4 단계, 자체 앱→OS 레이어 확장 시점 반영 -6. [`decision-log.md`](./decision-log.md) — Q1~Q7 확정 + Q8/Q9 제안, 계속 누적 기록 +6. [`decision-log.md`](./decision-log.md) — Q1~Q8 확정 + Q9 제안, 계속 누적 기록 6a. [`account-settings-ia.md`](./account-settings-ia.md) — 초대 가입·로그아웃·설정 IA·L2 갭 명세 7. [`poc-plan.md`](./poc-plan.md) — PoC #1(말투 학습)·#3(사칭/신뢰 수용성) 실행 계획과 Go/No-Go 기준 8. [`poc-materials.md`](./poc-materials.md) — 모집 문구, 동의 안내, 역할극 스크립트, 인터뷰 질문지 초안 diff --git a/docs/PRD.md b/docs/PRD.md index abe3238..5ea69d1 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -15,12 +15,12 @@ ### 2.0 계정 · 입장 (클로즈드 베타) Phase 1은 **이메일/비밀번호 계정이 아니라 초대 코드 입장**이다 -(`decision-log.md` Q8 제안, `invite-ops.md`, `account-settings-ia.md`). +(`decision-log.md` Q8 확정, `invite-ops.md`, `account-settings-ia.md`). 1. **새로 가입** — 발급된 초대 코드 + 표시 이름 → 세션 토큰 발급 → 말투 온보딩 -2. **이미 가입** — 같은 초대 코드로 로그인(토큰 재발급) → 메인 +2. **이미 가입** — 초대 코드 + 표시 이름으로 로그인(토큰 재발급) → 메인 + (`DEMO-YKAVU`는 표시 이름으로 사용자 구분) 3. **로그아웃** — 설정에서 명시적 로그아웃 → 서버 세션 종료 + 기기 토큰 삭제 → 입장 화면 - (2026-08-03 기준 앱 UI 미완 — P0 갭) 4. **탈퇴** — Phase 1 앱 UI 비범위 (운영 수동) ### 2.1 온보딩 (말투 학습) @@ -56,7 +56,7 @@ Phase 1은 **이메일/비밀번호 계정이 아니라 초대 코드 입장** | 기능 | 설명 | |---|---| | 초대 코드 가입·재로그인 | 클로즈드 베타 입장. 이메일/비번 없음 (`account-settings-ia.md`) | -| 로그아웃 | 설정에서 세션 종료 + 로컬 토큰 클리어 (P0 갭 — UI 미완) | +| 로그아웃 | 설정에서 세션 종료 + 로컬 토큰 클리어 | | 설정 IA | 말투·자율성·세션·데이터흐름·로그아웃을 한 진입점에서 발견 가능 | | 와카뷰 뱃지 | 와카뷰가 작성한 말풍선은 사람 말풍선과 시각적으로 구분(점선 테두리 + 뱃지 라벨) | | 자율성 설정(L0~L2) | 전역 기본값 + 상대별 예외 설정 | diff --git a/docs/account-settings-ia.md b/docs/account-settings-ia.md index 242fbba..f4b7dda 100644 --- a/docs/account-settings-ia.md +++ b/docs/account-settings-ia.md @@ -1,8 +1,7 @@ # 계정 · 인증 · 설정 IA (Phase 1 갭 명세) -권위: [`decision-log.md`](./decision-log.md) Q8·Q9 (제안) · [`PRD.md`](./PRD.md) · [`invite-ops.md`](./invite-ops.md). -이 문서는 **구현 전에** 제품 경계를 고정하기 위한 명세다. Q8/Q9가 `확정`되기 전에는 -코드를 크게 바꾸지 않는다. +권위: [`decision-log.md`](./decision-log.md) Q8(확정)·Q9(제안) · [`PRD.md`](./PRD.md) · [`invite-ops.md`](./invite-ops.md). +Q8은 2026-08-03 확정·구현. Q9(수신 트리거 L2)는 제안 상태로 코드 범위를 넓히지 않는다. ## 1. 현재 상태 (as-is) @@ -15,7 +14,7 @@ | 설정 | ⋮ → 말투 / 자율성(안에 세션·데이터흐름) | 단일 「설정」진입점·로그아웃 묶음 | | L2 | 서버 발송 게이트 + 화이트리스트 키워드 | PRD §2.2식 **수신 트리거 자동응대** | -## 2. Phase 1 목표 모델 (to-be, Q8 제안) +## 2. Phase 1 목표 모델 (to-be, Q8 확정) ### 2.1 계정 @@ -30,10 +29,12 @@ → 토큰 유효? → 메인(대화 목록) → 없음 → [입장 화면] ├─ 새로 가입 (초대 코드 + 표시 이름) → 말투 온보딩 → 메인 - └─ 이미 가입 (초대 코드로 로그인) → 메인 + └─ 이미 가입 (초대 코드 + 표시 이름) → 메인 ``` -- 「이미 가입」은 같은 초대 코드로 `POST /auth/login`을 호출해 새 세션 토큰을 받는다. +- 「이미 가입」은 `POST /auth/login`으로 새 세션 토큰을 받는다. +- 일반 초대: 사용된 초대 코드 → 사용자. 표시 이름이 있으면 일치해야 함. +- `DEMO-YKAVU`: 표시 이름 **필수** (공유 코드라 이름으로 사용자 구분). - 비밀번호 없음. 초대 코드 유출 = 계정 탈취 가능 → 베타 한정 리스크로 `invite-ops`에 명시. ### 2.3 로그아웃 (P0 갭) @@ -51,7 +52,7 @@ | 항목 | 화면 | 비고 | |------|------|------| -| 말투 샘플 | `OnboardingToneScreen` | 기존 | +| 말투 · 페르소나 | `OnboardingToneScreen` | 설정 허브에서 진입 | | 자율성 · 화이트리스트 · 관계 기본 | `AutonomySettingsScreen` | 기존 | | 로그인 세션 | `SessionsScreen` | 기존, 로그아웃과 구분 | | 데이터 흐름 | `DataFlowScreen` | 기존 | @@ -92,12 +93,12 @@ ## 4. 수락 기준 (구현 착수 시) -**계정/설정 (Q8 확정 후)** +**계정/설정 (Q8 확정)** -- [ ] 입장 화면에 가입 / 이미 가입(로그인) 구분 -- [ ] 로그아웃 시 서버 세션 무효 + 로컬 토큰 삭제 + 입장 화면 복귀 -- [ ] 설정 진입에서 로그아웃이 한 번의 탭 경로로 발견 가능 -- [ ] 이메일/비번 UI를 추가하지 않음 +- [x] 입장 화면에 가입 / 이미 가입(로그인) 구분 +- [x] 로그아웃 시 서버 세션 무효 + 로컬 토큰 삭제 + 입장 화면 복귀 +- [x] 설정 진입에서 로그아웃이 한 번의 탭 경로로 발견 가능 +- [x] 이메일/비번 UI를 추가하지 않음 **L2 정렬 (Q9 확정 후)** diff --git a/docs/decision-log.md b/docs/decision-log.md index 4ec6909..646d45c 100644 --- a/docs/decision-log.md +++ b/docs/decision-log.md @@ -34,21 +34,17 @@ **순서:** ① 자체 앱 클로즈드 베타로 핵심 가설 검증 → ② 검증되면 OS 레이어 읽기 전용 허브 → ③ 자체 앱은 L3·L4 등 완전한 기능의 최종 목적지로 유지. -## Q8 — 계정·인증·설정 IA (클로즈드 베타 갭) — 제안 +## Q8 — 계정·인증·설정 IA (클로즈드 베타 갭) — 확정 -2026-08-03 Master 피드백: 앱에 **로그아웃/설정 진입이 불명확**하고, 일반 이메일·비밀번호 -계정 시스템처럼 보이지 않는다. 코드부터 넣지 말고 문서에 먼저 못을 박는다. +2026-08-03 Master 피드백 후 문서화 → **2026-08-03 Master 「1번부터 진행」으로 Q8 확정·구현**. -| # | 질문 | 제안 결정 | 근거 | 상태 | -|---|------|----------|------|------| -| Q8a | Phase 1 계정 모델? | **초대 코드 클로즈드 베타 유지** (이메일/비번·소셜 로그인 **비범위**) | Q7 자체 앱 베타 + `invite-ops.md`. 인증 표면을 키우면 가설 검증보다 계정 인프라에 시간이 간다 | 제안 | -| Q8b | 재접속(로그인) UX? | **초대 코드 + 표시 이름 재입력**으로 토큰 재발급. 가입 화면에 「이미 가입」경로 명시 | 서버에 `POST /auth/login`(초대 코드)은 있음. 앱 UI가 없음 | 제안 | -| Q8c | 로그아웃? | **필수 P0 갭**. 메뉴에서 로그아웃 → 서버 현재 세션 revoke + 로컬 토큰/유저 상태 클리어 → 가입/로그인 화면 | 지금 `SessionsScreen` 종료는 로컬 클리어가 없어 불완전 | 제안 | -| Q8d | 설정 IA? | 대화 목록 **⋮ 또는 설정 진입점** 하나로 묶음: 말투 · 자율성 · 로그인 세션 · 데이터 흐름 · **로그아웃** | 자율성만 깊숙이 있어 발견성이 떨어짐 | 제안 | -| Q8e | 계정 삭제/탈퇴? | Phase 1에서는 **운영자 수동/후속**. 베타 UX에 「탈퇴」를 넣지 않음(초대 코드 1회용 정책과 맞춤). 필요 시 C 이후 별도 Q | 탈퇴·GDPR급 삭제는 베타 가설 검증보다 큼 | 제안 | - -**Master 승인 시** 위 행 상태를 `확정`으로 바꾸고 `PRD.md` / `tech-design.md` / `roadmap.md`의 -대응 항목을 같이 갱신한 뒤 구현한다. +| # | 질문 | 결정 | 근거 | 상태 | +|---|------|------|------|------| +| Q8a | Phase 1 계정 모델? | **초대 코드 클로즈드 베타 유지** (이메일/비번·소셜 로그인 **비범위**) | Q7 + `invite-ops.md` | 확정 | +| Q8b | 재접속(로그인) UX? | **초대 코드 + 표시 이름**으로 토큰 재발급. 입장 화면에 「이미 가입」탭. (`DEMO-YKAVU`는 표시 이름으로 사용자 구분) | 서버 login 확장 + 앱 UI | 확정 | +| Q8c | 로그아웃? | **P0**. 설정에서 로그아웃 → 현재 세션 revoke + 로컬 토큰/유저 클리어 → 입장 화면 | `SessionsScreen`만으로는 불완전했음 | 확정 | +| Q8d | 설정 IA? | 대화 목록 **설정** 화면: 말투 · 자율성 · 로그인 세션 · 데이터 흐름 · **로그아웃** | 발견성 | 확정 | +| Q8e | 계정 삭제/탈퇴? | Phase 1 앱 UI **비범위** (운영 수동) | 베타 범위 | 확정 | 상세 명세: [`account-settings-ia.md`](./account-settings-ia.md). @@ -64,5 +60,5 @@ - 자율성 기본 시작 레벨 L1 vs L2 (Q3 인터뷰) - 상표 정식 등록 여부 (Q6은 "와카뷰"로 명칭 확정, 등록 절차는 베타 반응 이후) - 베타 참가자 모집 규모와 방식 -- Q8 / Q9 제안 → Master 확정 +- Q9 제안 → Master 확정 (Q8은 2026-08-03 확정) diff --git a/docs/deploy-checklist.md b/docs/deploy-checklist.md index 4b48958..02b85e0 100644 --- a/docs/deploy-checklist.md +++ b/docs/deploy-checklist.md @@ -40,9 +40,9 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다. - 진행 중: **N4 FCM 코드 경로** → Master 시크릿 대기 → Android UI QA - UI: **iMessage-inspired light default** + soft charcoal dark 프로덕션 반영 - **Track C 콘텐츠 갭 A~F**: 프로덕션 반영 완료 (`95422bb`대) -- **문서 갭 (2026-08-03):** 로그아웃·설정 IA·L2 의미 — - [`decision-log.md`](./decision-log.md) Q8/Q9 **제안**, - [`account-settings-ia.md`](./account-settings-ia.md). **Master 확정 전 구현 착수하지 않음** +- **Q8 계정·설정 IA (2026-08-03 확정·구현):** 로그인 탭 · 설정 허브 · 로그아웃 +- **문서 갭 잔여:** L2 의미 [`decision-log.md`](./decision-log.md) Q9 **제안** — + [`account-settings-ia.md`](./account-settings-ia.md) §3. Master 확정 전 Q9 구현 금지 - 실 FCM 기기 수신 · Android 실기기 탭 · 사람 PoC 실행은 남음 ### NEXT 순서 diff --git a/docs/roadmap.md b/docs/roadmap.md index 516fc9b..715c5d9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -330,11 +330,11 @@ Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase - [x] 로그인 세션/토큰 (`Session`, signup/login 시 Bearer 발급) - [x] `/invites`, `/admin/metrics` 접근 제어 (`ADMIN_API_TOKEN`) - [x] 프로덕션 DB 마이그레이션 명령 (`go run . migrate`) -- [ ] **계정·설정 IA 갭** (`decision-log` Q8 제안, [`account-settings-ia.md`](./account-settings-ia.md)) - — Master 확정 후 착수. 이메일/비번 도입 금지(Phase 1) - - [ ] 입장 화면: 새로 가입 / 이미 가입(`POST /auth/login`) 구분 - - [ ] 로그아웃: 세션 revoke + 로컬 토큰 클리어 + 입장 화면 복귀 - - [ ] 설정 진입점에 로그아웃·세션·자율성·말투·데이터흐름 묶기 +- [x] **계정·설정 IA** (`decision-log` Q8 확정, [`account-settings-ia.md`](./account-settings-ia.md)) + — 이메일/비번 도입 금지(Phase 1) + - [x] 입장 화면: 새로 가입 / 이미 가입(`POST /auth/login`, DEMO는 display_name 필수) + - [x] 로그아웃: 세션 revoke + 로컬 토큰 클리어 + 입장 화면 복귀 + - [x] 설정 진입점에 로그아웃·세션·자율성·말투·데이터흐름 묶기 **A3. Flutter — 메신저답게 다듬기** (A1/A2 이후) - [x] 대화 목록/연락처 UI를 서버 API에 연결 diff --git a/docs/tech-design.md b/docs/tech-design.md index adf9fff..8f278c4 100644 --- a/docs/tech-design.md +++ b/docs/tech-design.md @@ -62,9 +62,9 @@ v1에서는 커스텀 모델을 새로 학습하지 않는다. 대신 **검색 | 경로 | 역할 | |------|------| | `POST /auth/signup` | 미사용 초대 코드 + `display_name` → User + Session(Bearer) | -| `POST /auth/login` | 이미 사용된 초대 코드 → 새 Session (앱 UI 갭) | +| `POST /auth/login` | 사용된 초대 코드(+ 선택적 display_name) → 새 Session. DEMO는 display_name 필수 | | `GET/DELETE /users/:id/sessions...` | 멀티 디바이스 목록·종료 | -| 클라이언트 | `shared_preferences`에 토큰 저장; **로그아웃 시 revoke + 로컬 삭제** (갭) | +| 클라이언트 | `shared_preferences`에 토큰 저장; 로그아웃 시 revoke + 로컬 삭제 → 입장 화면 | - Phase 1은 **비밀번호·OAuth 없음.** 초대 코드가 비밀에 해당한다. - 로그아웃은 서버 세션 무효화만으로는 부족하고, 클라이언트가 토큰을 지워야 입장 화면으로 돌아간다. diff --git a/mobile/lib/screens/conversation_list_screen.dart b/mobile/lib/screens/conversation_list_screen.dart index 55a4e32..0d5b54f 100644 --- a/mobile/lib/screens/conversation_list_screen.dart +++ b/mobile/lib/screens/conversation_list_screen.dart @@ -7,11 +7,10 @@ import '../services/snooze_service.dart'; import '../state/session_state.dart'; import '../widgets/gradient_text.dart'; import '../widgets/my_user_id_chip.dart'; -import 'autonomy_settings_screen.dart'; import 'chat_screen.dart'; import 'contacts_screen.dart'; import 'inbox_screen.dart'; -import 'onboarding_tone_screen.dart'; +import 'settings_screen.dart'; class ConversationListScreen extends StatefulWidget { const ConversationListScreen({super.key}); @@ -262,30 +261,14 @@ class _ConversationListScreenState extends State { }, icon: const Icon(Icons.contacts_outlined), ), - PopupMenuButton( - tooltip: '더보기', - icon: const Icon(Icons.more_vert), - onSelected: (action) => action(), - itemBuilder: (context) => [ - PopupMenuItem( - value: () => Navigator.of(context) - .push(MaterialPageRoute(builder: (_) => const OnboardingToneScreen())), - child: const ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon(Icons.record_voice_over_outlined), - title: Text('말투 샘플'), - ), - ), - PopupMenuItem( - value: () => Navigator.of(context) - .push(MaterialPageRoute(builder: (_) => const AutonomySettingsScreen())), - child: const ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon(Icons.tune), - title: Text('자율성 설정'), - ), - ), - ], + IconButton( + tooltip: '설정', + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const SettingsScreen()), + ); + }, + icon: const Icon(Icons.settings_outlined), ), const SizedBox(width: 4), ], diff --git a/mobile/lib/screens/sessions_screen.dart b/mobile/lib/screens/sessions_screen.dart index 493a77c..ab57c69 100644 --- a/mobile/lib/screens/sessions_screen.dart +++ b/mobile/lib/screens/sessions_screen.dart @@ -57,13 +57,16 @@ class _SessionsScreenState extends State { ], ), ); - if (ok != true) return; + if (ok != true || !mounted) return; + if (isCurrent) { + // Q8c — clear local token and return to entry via AuthGate. + await session.logout(); + if (!mounted) return; + Navigator.of(context).popUntil((route) => route.isFirst); + return; + } try { await session.api.revokeSession(session.user!.id, id.toInt()); - if (isCurrent && mounted) { - // Soft signal — full logout clearing is a follow-up; reload list for now. - setState(() => _error = '현재 세션이 종료되었습니다. 앱을 다시 시작해 주세요.'); - } await _load(); } on ApiException catch (e) { setState(() => _error = '세션 종료 실패 (${e.statusCode})'); diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart new file mode 100644 index 0000000..0b1a035 --- /dev/null +++ b/mobile/lib/screens/settings_screen.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../state/session_state.dart'; +import 'autonomy_settings_screen.dart'; +import 'data_flow_screen.dart'; +import 'onboarding_tone_screen.dart'; +import 'sessions_screen.dart'; + +/// Account / settings hub (Q8). +/// +/// Groups tone, autonomy, sessions, data-flow, and logout in one place +/// so the conversation list menu stays short. +class SettingsScreen extends StatelessWidget { + const SettingsScreen({super.key}); + + Future _confirmLogout(BuildContext context) async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('로그아웃'), + content: const Text( + '이 기기에서 로그아웃할까요?\n' + '다시 쓰려면 초대 코드와 표시 이름으로 로그인해야 합니다.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('취소'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('로그아웃'), + ), + ], + ), + ); + if (ok != true || !context.mounted) return; + + final session = context.read(); + await session.logout(); + if (!context.mounted) return; + // Pop back to root; AuthGate rebuilds to SignupScreen. + Navigator.of(context).popUntil((route) => route.isFirst); + } + + @override + Widget build(BuildContext context) { + final session = context.watch(); + final user = session.user; + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar(title: const Text('설정')), + body: ListView( + children: [ + if (user != null) + ListTile( + leading: CircleAvatar( + child: Text( + user.displayName.isNotEmpty + ? String.fromCharCode(user.displayName.runes.first).toUpperCase() + : '?', + ), + ), + title: Text(user.displayName), + subtitle: Text( + 'user #${user.id}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.record_voice_over_outlined), + title: const Text('말투 · 페르소나'), + subtitle: const Text('트윈이 쓰는 말투와 역할'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const OnboardingToneScreen()), + ); + }, + ), + ListTile( + leading: const Icon(Icons.tune), + title: const Text('자율성 수준'), + subtitle: const Text('L0 · L1 · L2'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const AutonomySettingsScreen(), + ), + ); + }, + ), + ListTile( + leading: const Icon(Icons.devices_outlined), + title: const Text('로그인 세션'), + subtitle: const Text('기기별 세션 조회 · 폐기'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const SessionsScreen()), + ); + }, + ), + ListTile( + leading: const Icon(Icons.account_tree_outlined), + title: const Text('데이터 흐름'), + subtitle: const Text('내 데이터가 어디로 가는지'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const DataFlowScreen()), + ); + }, + ), + const Divider(height: 24), + ListTile( + leading: Icon(Icons.logout, color: theme.colorScheme.error), + title: Text( + '로그아웃', + style: TextStyle(color: theme.colorScheme.error), + ), + subtitle: const Text('이 기기 세션 종료'), + onTap: () => _confirmLogout(context), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/screens/signup_screen.dart b/mobile/lib/screens/signup_screen.dart index cff5242..6dcf3f1 100644 --- a/mobile/lib/screens/signup_screen.dart +++ b/mobile/lib/screens/signup_screen.dart @@ -9,6 +9,7 @@ import '../widgets/brand_mark.dart'; import '../widgets/gradient_text.dart'; import '../widgets/primary_gradient_button.dart'; +/// Closed-beta entry: signup (new invite) or login (already registered). class SignupScreen extends StatefulWidget { const SignupScreen({super.key}); @@ -16,12 +17,26 @@ class SignupScreen extends StatefulWidget { State createState() => _SignupScreenState(); } -class _SignupScreenState extends State { +class _SignupScreenState extends State + with SingleTickerProviderStateMixin { final _invite = TextEditingController(); final _name = TextEditingController(); + late final TabController _tabs; + + bool get _isLogin => _tabs.index == 1; + + @override + void initState() { + super.initState(); + _tabs = TabController(length: 2, vsync: this); + _tabs.addListener(() { + if (!_tabs.indexIsChanging) setState(() {}); + }); + } @override void dispose() { + _tabs.dispose(); _invite.dispose(); _name.dispose(); super.dispose(); @@ -33,6 +48,16 @@ class _SignupScreenState extends State { setState(() {}); } + Future _submit(SessionState session) async { + final invite = _invite.text.trim(); + final name = _name.text.trim(); + if (_isLogin) { + await session.login(invite, name); + } else { + await session.signup(invite, name); + } + } + @override Widget build(BuildContext context) { final session = context.watch(); @@ -40,79 +65,100 @@ class _SignupScreenState extends State { final scheme = theme.colorScheme; return Scaffold( body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Spacer(flex: 2), - const Center(child: BrandMark(size: 72)), - const SizedBox(height: 20), - GradientText( - '와카뷰', - textAlign: TextAlign.center, - style: theme.textTheme.displaySmall?.copyWith(fontWeight: FontWeight.w800), - ), - const SizedBox(height: 8), - Text( - '나를 대신해 답하는, 나만의 와카뷰', - textAlign: TextAlign.center, - style: theme.textTheme.bodyLarge?.copyWith(color: scheme.onSurfaceVariant), - ), - const Spacer(flex: 1), - Text('초대 코드로 클로즈드 베타에 참여합니다', style: theme.textTheme.labelLarge), - const SizedBox(height: 12), - _DemoTestPanel( - onFill: _fillDemoCredentials, - onCopy: () async { - await Clipboard.setData(const ClipboardData(text: AppConfig.demoInviteCode)); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('테스트 초대 코드를 복사했습니다')), - ); - }, - ), - const SizedBox(height: 16), - TextField( - controller: _invite, - decoration: const InputDecoration( - labelText: '초대 코드', - prefixIcon: Icon(Icons.vpn_key), - ), - textInputAction: TextInputAction.next, - textCapitalization: TextCapitalization.characters, - ), - const SizedBox(height: 12), - TextField( - controller: _name, - decoration: const InputDecoration( - labelText: '표시 이름', - prefixIcon: Icon(Icons.person_outline), - ), - textInputAction: TextInputAction.done, - onSubmitted: (_) { - if (!session.loading) { - session.signup(_invite.text.trim(), _name.text.trim()); - } - }, - ), - if (session.error != null) ...[ - const SizedBox(height: 12), - Text(session.error!, style: TextStyle(color: scheme.error)), - ], - const SizedBox(height: 20), - _PressScale( - child: PrimaryGradientButton( - label: '시작하기', - loading: session.loading, - onPressed: session.loading - ? null - : () => session.signup(_invite.text.trim(), _name.text.trim()), + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: IntrinsicHeight( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Spacer(flex: 2), + const Center(child: BrandMark(size: 72)), + const SizedBox(height: 20), + GradientText( + '와카뷰', + textAlign: TextAlign.center, + style: theme.textTheme.displaySmall?.copyWith(fontWeight: FontWeight.w800), + ), + const SizedBox(height: 8), + Text( + '나를 대신해 답하는, 나만의 와카뷰', + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge?.copyWith(color: scheme.onSurfaceVariant), + ), + const Spacer(flex: 1), + TabBar( + controller: _tabs, + tabs: const [ + Tab(text: '새로 가입'), + Tab(text: '이미 가입'), + ], + ), + const SizedBox(height: 16), + Text( + _isLogin + ? '초대 코드와 표시 이름으로 다시 로그인합니다' + : '초대 코드로 클로즈드 베타에 참여합니다', + style: theme.textTheme.labelLarge, + ), + const SizedBox(height: 12), + _DemoTestPanel( + onFill: _fillDemoCredentials, + onCopy: () async { + await Clipboard.setData( + const ClipboardData(text: AppConfig.demoInviteCode), + ); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('테스트 초대 코드를 복사했습니다')), + ); + }, + ), + const SizedBox(height: 16), + TextField( + controller: _invite, + decoration: const InputDecoration( + labelText: '초대 코드', + prefixIcon: Icon(Icons.vpn_key), + ), + textInputAction: TextInputAction.next, + textCapitalization: TextCapitalization.characters, + ), + const SizedBox(height: 12), + TextField( + controller: _name, + decoration: InputDecoration( + labelText: '표시 이름', + hintText: _isLogin ? '가입 때 쓴 이름 (DEMO는 필수)' : null, + prefixIcon: const Icon(Icons.person_outline), + ), + textInputAction: TextInputAction.done, + onSubmitted: (_) { + if (!session.loading) _submit(session); + }, + ), + if (session.error != null) ...[ + const SizedBox(height: 12), + Text(session.error!, style: TextStyle(color: scheme.error)), + ], + const SizedBox(height: 20), + _PressScale( + child: PrimaryGradientButton( + label: _isLogin ? '로그인' : '시작하기', + loading: session.loading, + onPressed: session.loading ? null : () => _submit(session), + ), + ), + const SizedBox(height: 32), + ], + ), ), ), - const SizedBox(height: 32), - ], - ), + ); + }, ), ), ); diff --git a/mobile/lib/services/api_client.dart b/mobile/lib/services/api_client.dart index 7d3f094..fda6284 100644 --- a/mobile/lib/services/api_client.dart +++ b/mobile/lib/services/api_client.dart @@ -76,6 +76,27 @@ class ApiClient { ); } + /// Q8b — re-issue a session for an existing invite-based account. + Future<({User user, String token})> login({ + required String inviteCode, + required String displayName, + }) async { + final json = await _json('POST', '/auth/login', body: { + 'invite_code': inviteCode, + 'display_name': displayName, + }); + final token = json['token'] as String? ?? ''; + authToken = token; + return ( + user: User( + id: json['id'] as int, + displayName: json['display_name'] as String? ?? displayName, + inviteCode: inviteCode, + ), + token: token, + ); + } + Future> listConversations() async { final obj = await _getObject('/conversations'); final list = (obj['conversations'] as List? ?? const []); diff --git a/mobile/lib/state/session_state.dart b/mobile/lib/state/session_state.dart index 20b0d24..653c2b7 100644 --- a/mobile/lib/state/session_state.dart +++ b/mobile/lib/state/session_state.dart @@ -109,15 +109,7 @@ class SessionState extends ChangeNotifier { notifyListeners(); try { final result = await _api.signup(inviteCode: inviteCode, displayName: displayName); - user = result.user; - toneOnboardingDone = false; - _db ??= await _openDb(); - await _db!.setBoolKv(_kToneDone, false); - final prefs = await SharedPreferences.getInstance(); - await prefs.setInt(_kUserId, result.user.id); - await prefs.setString(_kDisplayName, result.user.displayName); - await prefs.setString(_kInvite, result.user.inviteCode); - await prefs.setString(_kToken, result.token); + await _persistAuth(result.user, result.token, resetToneOnboarding: true); await _registerDeviceTokenBestEffort(); } on ApiException catch (e) { error = '가입 실패 (${e.statusCode}): ${e.body}'; @@ -129,6 +121,71 @@ class SessionState extends ChangeNotifier { } } + /// Q8b — already registered: invite code + display name → new session. + Future login(String inviteCode, String displayName) async { + loading = true; + error = null; + notifyListeners(); + try { + final result = await _api.login(inviteCode: inviteCode, displayName: displayName); + await _persistAuth(result.user, result.token, resetToneOnboarding: false); + await _registerDeviceTokenBestEffort(); + } on ApiException catch (e) { + error = '로그인 실패 (${e.statusCode}): ${e.body}'; + } catch (e) { + error = e.toString(); + } finally { + loading = false; + notifyListeners(); + } + } + + Future _persistAuth(User u, String token, {required bool resetToneOnboarding}) async { + user = u; + _api.authToken = token; + _db ??= await _openDb(); + if (resetToneOnboarding) { + toneOnboardingDone = false; + await _db!.setBoolKv(_kToneDone, false); + } else { + toneOnboardingDone = await _db!.getBoolKv(_kToneDone); + styleExamples = await _db!.loadToneSamples(); + } + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_kUserId, u.id); + await prefs.setString(_kDisplayName, u.displayName); + await prefs.setString(_kInvite, u.inviteCode); + await prefs.setString(_kToken, token); + } + + /// Q8c — revoke current server session when possible, then clear local auth. + Future logout() async { + final u = user; + final token = _api.authToken; + if (u != null && token != null && token.isNotEmpty) { + try { + final sessions = await _api.listSessions(u.id); + for (final s in sessions) { + if (s['is_current'] == true && s['id'] is num) { + await _api.revokeSession(u.id, (s['id'] as num).toInt()); + break; + } + } + } catch (e) { + debugPrint('logout revoke skipped: $e'); + } + } + user = null; + error = null; + _api.authToken = null; + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_kUserId); + await prefs.remove(_kDisplayName); + await prefs.remove(_kInvite); + await prefs.remove(_kToken); + notifyListeners(); + } + Future saveToneSamples(List samples, {bool markDone = true}) async { final cleaned = samples.map((s) => s.trim()).where((s) => s.isNotEmpty).toList(); styleExamples = cleaned;