feat(q8): invite login, settings hub, and logout
Confirm Q8 account/settings IA and ship the closed-beta gap: - Extend POST /auth/login with display_name (required for DEMO-YKAVU) - Entry screen tabs: signup vs already-registered login - Settings hub with tone, autonomy, sessions, data-flow, logout - Logout revokes current session and clears local token Co-authored-by: okuma <o0kuma@users.noreply.github.com>
This commit is contained in:
parent
a956db754b
commit
febf1fc637
16
AGENTS.md
16
AGENTS.md
|
|
@ -10,8 +10,8 @@ copying content into prompts or new files.
|
||||||
|
|
||||||
When documents disagree, follow this order:
|
When documents disagree, follow this order:
|
||||||
|
|
||||||
1. [`docs/decision-log.md`](docs/decision-log.md) — working assumptions for Q1~Q7 (확정)
|
1. [`docs/decision-log.md`](docs/decision-log.md) — working assumptions for Q1~Q8 (확정)
|
||||||
and Q8/Q9 (계정·설정 IA / L2 의미 — 제안 until Master confirms)
|
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)
|
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)
|
3. [`docs/roadmap.md`](docs/roadmap.md) / [`docs/risk-log.md`](docs/risk-log.md)
|
||||||
4. [`docs/PLANNING.md`](docs/PLANNING.md) — process guide
|
4. [`docs/PLANNING.md`](docs/PLANNING.md) — process guide
|
||||||
|
|
@ -19,12 +19,12 @@ When documents disagree, follow this order:
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- Q1~Q7 in `decision-log.md` are **확정** (Phase 1 C, 2026-07-30). PoC-dependent
|
- Q1~Q8 in `decision-log.md` are **확정** (Q1~Q7: Phase 1 C, 2026-07-30;
|
||||||
sub-questions (default autonomy level, whitelist defaults, final branding)
|
Q8 account/settings IA: 2026-08-03). PoC-dependent sub-questions (default
|
||||||
stay open — do not invent those. Q8/Q9 (logout/settings IA, L2 semantics) are
|
autonomy level, whitelist defaults, final branding) stay open — do not invent
|
||||||
**제안** — implement only after Master marks them 확정; see
|
those. Q9 (L2 receive-triggered auto-reply) remains **제안** — implement only
|
||||||
`docs/account-settings-ia.md`. To reverse a Q, update `decision-log.md` and
|
after Master marks it 확정; see `docs/account-settings-ia.md`. To reverse a Q,
|
||||||
derived docs in the same change.
|
update `decision-log.md` and derived docs in the same change.
|
||||||
- Prefer `decision-log.md` (and the synced summary in `PLANNING.md` §2) for
|
- Prefer `decision-log.md` (and the synced summary in `PLANNING.md` §2) for
|
||||||
current working answers.
|
current working answers.
|
||||||
- Working delivery order is **자체 앱 클로즈드 베타 first → OS 레이어 later**
|
- Working delivery order is **자체 앱 클로즈드 베타 first → OS 레이어 later**
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package main
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
@ -47,15 +48,31 @@ func contactJSON(ct Contact) gin.H {
|
||||||
|
|
||||||
type loginRequest struct {
|
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) {
|
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) {
|
r.POST("/auth/login", func(c *gin.Context) {
|
||||||
var req loginRequest
|
var req loginRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
var user User
|
||||||
|
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
|
var invite InviteCode
|
||||||
if err := db.Where("code = ?", req.InviteCode).First(&invite).Error; err != nil {
|
if err := db.Where("code = ?", req.InviteCode).First(&invite).Error; err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid invite code"})
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid invite code"})
|
||||||
|
|
@ -65,11 +82,15 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invite code not yet used for signup"})
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "invite code not yet used for signup"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var user User
|
|
||||||
if err := db.First(&user, *invite.UsedByUserID).Error; err != nil {
|
if err := db.First(&user, *invite.UsedByUserID).Error; err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"detail": "user not found"})
|
c.JSON(http.StatusNotFound, gin.H{"detail": "user not found"})
|
||||||
return
|
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)
|
session, err := createSession(db, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ func TestLoginReturnsSessionToken(t *testing.T) {
|
||||||
t.Fatalf("signup: %d", signup.StatusCode)
|
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 {
|
if login.StatusCode != http.StatusOK {
|
||||||
t.Fatalf("expected 200 login, got %d", login.StatusCode)
|
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) {
|
func TestConversationContactMessageHistoryAndEscalationLogs(t *testing.T) {
|
||||||
server, _ := setupTestServer(t)
|
server, _ := setupTestServer(t)
|
||||||
ownerID, ownerToken := mustSignup(t, server.URL, "주인")
|
ownerID, ownerToken := mustSignup(t, server.URL, "주인")
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,9 @@
|
||||||
|
|
||||||
기능 명세를 쓰기 전에 아래 표를 채운다. 답이 안 나온 항목은 "보류 사유"를 적어두고 다음 회의 안건으로 남긴다.
|
기능 명세를 쓰기 전에 아래 표를 채운다. 답이 안 나온 항목은 "보류 사유"를 적어두고 다음 회의 안건으로 남긴다.
|
||||||
현재 답은 [`decision-log.md`](./decision-log.md)에 있으며, **Q1~Q7은 Phase 1 C에서 확정**(2026-07-30).
|
현재 답은 [`decision-log.md`](./decision-log.md)에 있으며, **Q1~Q7은 Phase 1 C에서 확정**(2026-07-30).
|
||||||
PoC 의존 하위 질문(자율성 기본값 등)과 **Q8/Q9(계정·설정 IA / L2 의미, 2026-08-03 제안)** 가
|
PoC 의존 하위 질문(자율성 기본값 등)과 **Q9(L2 의미, 제안)** 가 열려 있다.
|
||||||
열려 있다 — decision-log를 단일 기준으로 따른다. 계정 UX 상세는
|
**Q8(계정·설정 IA)은 2026-08-03 확정·구현** — decision-log를 단일 기준으로 따른다.
|
||||||
[`account-settings-ia.md`](./account-settings-ia.md).
|
계정 UX 상세는 [`account-settings-ia.md`](./account-settings-ia.md).
|
||||||
|
|
||||||
| # | 질문 | 결정 | 상태 |
|
| # | 질문 | 결정 | 상태 |
|
||||||
|---|------|------|------|
|
|---|------|------|------|
|
||||||
|
|
@ -107,7 +107,7 @@ PoC 의존 하위 질문(자율성 기본값 등)과 **Q8/Q9(계정·설정 IA /
|
||||||
3. [`tech-design.md`](./tech-design.md) — 온디바이스/서버 경계, 데이터 흐름, 에스컬레이션 로직
|
3. [`tech-design.md`](./tech-design.md) — 온디바이스/서버 경계, 데이터 흐름, 에스컬레이션 로직
|
||||||
4. [`risk-log.md`](./risk-log.md) — 회의 자료 §2-6, §oslayer §4의 리스크를 완화 상태와 함께 추적
|
4. [`risk-log.md`](./risk-log.md) — 회의 자료 §2-6, §oslayer §4의 리스크를 완화 상태와 함께 추적
|
||||||
5. [`roadmap.md`](./roadmap.md) — L0~L4 단계, 자체 앱→OS 레이어 확장 시점 반영
|
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 갭 명세
|
6a. [`account-settings-ia.md`](./account-settings-ia.md) — 초대 가입·로그아웃·설정 IA·L2 갭 명세
|
||||||
7. [`poc-plan.md`](./poc-plan.md) — PoC #1(말투 학습)·#3(사칭/신뢰 수용성) 실행 계획과 Go/No-Go 기준
|
7. [`poc-plan.md`](./poc-plan.md) — PoC #1(말투 학습)·#3(사칭/신뢰 수용성) 실행 계획과 Go/No-Go 기준
|
||||||
8. [`poc-materials.md`](./poc-materials.md) — 모집 문구, 동의 안내, 역할극 스크립트, 인터뷰 질문지 초안
|
8. [`poc-materials.md`](./poc-materials.md) — 모집 문구, 동의 안내, 역할극 스크립트, 인터뷰 질문지 초안
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,12 @@
|
||||||
### 2.0 계정 · 입장 (클로즈드 베타)
|
### 2.0 계정 · 입장 (클로즈드 베타)
|
||||||
|
|
||||||
Phase 1은 **이메일/비밀번호 계정이 아니라 초대 코드 입장**이다
|
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. **새로 가입** — 발급된 초대 코드 + 표시 이름 → 세션 토큰 발급 → 말투 온보딩
|
1. **새로 가입** — 발급된 초대 코드 + 표시 이름 → 세션 토큰 발급 → 말투 온보딩
|
||||||
2. **이미 가입** — 같은 초대 코드로 로그인(토큰 재발급) → 메인
|
2. **이미 가입** — 초대 코드 + 표시 이름으로 로그인(토큰 재발급) → 메인
|
||||||
|
(`DEMO-YKAVU`는 표시 이름으로 사용자 구분)
|
||||||
3. **로그아웃** — 설정에서 명시적 로그아웃 → 서버 세션 종료 + 기기 토큰 삭제 → 입장 화면
|
3. **로그아웃** — 설정에서 명시적 로그아웃 → 서버 세션 종료 + 기기 토큰 삭제 → 입장 화면
|
||||||
(2026-08-03 기준 앱 UI 미완 — P0 갭)
|
|
||||||
4. **탈퇴** — Phase 1 앱 UI 비범위 (운영 수동)
|
4. **탈퇴** — Phase 1 앱 UI 비범위 (운영 수동)
|
||||||
|
|
||||||
### 2.1 온보딩 (말투 학습)
|
### 2.1 온보딩 (말투 학습)
|
||||||
|
|
@ -56,7 +56,7 @@ Phase 1은 **이메일/비밀번호 계정이 아니라 초대 코드 입장**
|
||||||
| 기능 | 설명 |
|
| 기능 | 설명 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 초대 코드 가입·재로그인 | 클로즈드 베타 입장. 이메일/비번 없음 (`account-settings-ia.md`) |
|
| 초대 코드 가입·재로그인 | 클로즈드 베타 입장. 이메일/비번 없음 (`account-settings-ia.md`) |
|
||||||
| 로그아웃 | 설정에서 세션 종료 + 로컬 토큰 클리어 (P0 갭 — UI 미완) |
|
| 로그아웃 | 설정에서 세션 종료 + 로컬 토큰 클리어 |
|
||||||
| 설정 IA | 말투·자율성·세션·데이터흐름·로그아웃을 한 진입점에서 발견 가능 |
|
| 설정 IA | 말투·자율성·세션·데이터흐름·로그아웃을 한 진입점에서 발견 가능 |
|
||||||
| 와카뷰 뱃지 | 와카뷰가 작성한 말풍선은 사람 말풍선과 시각적으로 구분(점선 테두리 + 뱃지 라벨) |
|
| 와카뷰 뱃지 | 와카뷰가 작성한 말풍선은 사람 말풍선과 시각적으로 구분(점선 테두리 + 뱃지 라벨) |
|
||||||
| 자율성 설정(L0~L2) | 전역 기본값 + 상대별 예외 설정 |
|
| 자율성 설정(L0~L2) | 전역 기본값 + 상대별 예외 설정 |
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
# 계정 · 인증 · 설정 IA (Phase 1 갭 명세)
|
# 계정 · 인증 · 설정 IA (Phase 1 갭 명세)
|
||||||
|
|
||||||
권위: [`decision-log.md`](./decision-log.md) Q8·Q9 (제안) · [`PRD.md`](./PRD.md) · [`invite-ops.md`](./invite-ops.md).
|
권위: [`decision-log.md`](./decision-log.md) Q8(확정)·Q9(제안) · [`PRD.md`](./PRD.md) · [`invite-ops.md`](./invite-ops.md).
|
||||||
이 문서는 **구현 전에** 제품 경계를 고정하기 위한 명세다. Q8/Q9가 `확정`되기 전에는
|
Q8은 2026-08-03 확정·구현. Q9(수신 트리거 L2)는 제안 상태로 코드 범위를 넓히지 않는다.
|
||||||
코드를 크게 바꾸지 않는다.
|
|
||||||
|
|
||||||
## 1. 현재 상태 (as-is)
|
## 1. 현재 상태 (as-is)
|
||||||
|
|
||||||
|
|
@ -15,7 +14,7 @@
|
||||||
| 설정 | ⋮ → 말투 / 자율성(안에 세션·데이터흐름) | 단일 「설정」진입점·로그아웃 묶음 |
|
| 설정 | ⋮ → 말투 / 자율성(안에 세션·데이터흐름) | 단일 「설정」진입점·로그아웃 묶음 |
|
||||||
| L2 | 서버 발송 게이트 + 화이트리스트 키워드 | PRD §2.2식 **수신 트리거 자동응대** |
|
| L2 | 서버 발송 게이트 + 화이트리스트 키워드 | PRD §2.2식 **수신 트리거 자동응대** |
|
||||||
|
|
||||||
## 2. Phase 1 목표 모델 (to-be, Q8 제안)
|
## 2. Phase 1 목표 모델 (to-be, Q8 확정)
|
||||||
|
|
||||||
### 2.1 계정
|
### 2.1 계정
|
||||||
|
|
||||||
|
|
@ -30,10 +29,12 @@
|
||||||
→ 토큰 유효? → 메인(대화 목록)
|
→ 토큰 유효? → 메인(대화 목록)
|
||||||
→ 없음 → [입장 화면]
|
→ 없음 → [입장 화면]
|
||||||
├─ 새로 가입 (초대 코드 + 표시 이름) → 말투 온보딩 → 메인
|
├─ 새로 가입 (초대 코드 + 표시 이름) → 말투 온보딩 → 메인
|
||||||
└─ 이미 가입 (초대 코드로 로그인) → 메인
|
└─ 이미 가입 (초대 코드 + 표시 이름) → 메인
|
||||||
```
|
```
|
||||||
|
|
||||||
- 「이미 가입」은 같은 초대 코드로 `POST /auth/login`을 호출해 새 세션 토큰을 받는다.
|
- 「이미 가입」은 `POST /auth/login`으로 새 세션 토큰을 받는다.
|
||||||
|
- 일반 초대: 사용된 초대 코드 → 사용자. 표시 이름이 있으면 일치해야 함.
|
||||||
|
- `DEMO-YKAVU`: 표시 이름 **필수** (공유 코드라 이름으로 사용자 구분).
|
||||||
- 비밀번호 없음. 초대 코드 유출 = 계정 탈취 가능 → 베타 한정 리스크로 `invite-ops`에 명시.
|
- 비밀번호 없음. 초대 코드 유출 = 계정 탈취 가능 → 베타 한정 리스크로 `invite-ops`에 명시.
|
||||||
|
|
||||||
### 2.3 로그아웃 (P0 갭)
|
### 2.3 로그아웃 (P0 갭)
|
||||||
|
|
@ -51,7 +52,7 @@
|
||||||
|
|
||||||
| 항목 | 화면 | 비고 |
|
| 항목 | 화면 | 비고 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 말투 샘플 | `OnboardingToneScreen` | 기존 |
|
| 말투 · 페르소나 | `OnboardingToneScreen` | 설정 허브에서 진입 |
|
||||||
| 자율성 · 화이트리스트 · 관계 기본 | `AutonomySettingsScreen` | 기존 |
|
| 자율성 · 화이트리스트 · 관계 기본 | `AutonomySettingsScreen` | 기존 |
|
||||||
| 로그인 세션 | `SessionsScreen` | 기존, 로그아웃과 구분 |
|
| 로그인 세션 | `SessionsScreen` | 기존, 로그아웃과 구분 |
|
||||||
| 데이터 흐름 | `DataFlowScreen` | 기존 |
|
| 데이터 흐름 | `DataFlowScreen` | 기존 |
|
||||||
|
|
@ -92,12 +93,12 @@
|
||||||
|
|
||||||
## 4. 수락 기준 (구현 착수 시)
|
## 4. 수락 기준 (구현 착수 시)
|
||||||
|
|
||||||
**계정/설정 (Q8 확정 후)**
|
**계정/설정 (Q8 확정)**
|
||||||
|
|
||||||
- [ ] 입장 화면에 가입 / 이미 가입(로그인) 구분
|
- [x] 입장 화면에 가입 / 이미 가입(로그인) 구분
|
||||||
- [ ] 로그아웃 시 서버 세션 무효 + 로컬 토큰 삭제 + 입장 화면 복귀
|
- [x] 로그아웃 시 서버 세션 무효 + 로컬 토큰 삭제 + 입장 화면 복귀
|
||||||
- [ ] 설정 진입에서 로그아웃이 한 번의 탭 경로로 발견 가능
|
- [x] 설정 진입에서 로그아웃이 한 번의 탭 경로로 발견 가능
|
||||||
- [ ] 이메일/비번 UI를 추가하지 않음
|
- [x] 이메일/비번 UI를 추가하지 않음
|
||||||
|
|
||||||
**L2 정렬 (Q9 확정 후)**
|
**L2 정렬 (Q9 확정 후)**
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,21 +34,17 @@
|
||||||
**순서:** ① 자체 앱 클로즈드 베타로 핵심 가설 검증 → ② 검증되면 OS 레이어 읽기 전용 허브 →
|
**순서:** ① 자체 앱 클로즈드 베타로 핵심 가설 검증 → ② 검증되면 OS 레이어 읽기 전용 허브 →
|
||||||
③ 자체 앱은 L3·L4 등 완전한 기능의 최종 목적지로 유지.
|
③ 자체 앱은 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`. 인증 표면을 키우면 가설 검증보다 계정 인프라에 시간이 간다 | 제안 |
|
| Q8a | Phase 1 계정 모델? | **초대 코드 클로즈드 베타 유지** (이메일/비번·소셜 로그인 **비범위**) | Q7 + `invite-ops.md` | 확정 |
|
||||||
| Q8b | 재접속(로그인) UX? | **초대 코드 + 표시 이름 재입력**으로 토큰 재발급. 가입 화면에 「이미 가입」경로 명시 | 서버에 `POST /auth/login`(초대 코드)은 있음. 앱 UI가 없음 | 제안 |
|
| Q8b | 재접속(로그인) UX? | **초대 코드 + 표시 이름**으로 토큰 재발급. 입장 화면에 「이미 가입」탭. (`DEMO-YKAVU`는 표시 이름으로 사용자 구분) | 서버 login 확장 + 앱 UI | 확정 |
|
||||||
| Q8c | 로그아웃? | **필수 P0 갭**. 메뉴에서 로그아웃 → 서버 현재 세션 revoke + 로컬 토큰/유저 상태 클리어 → 가입/로그인 화면 | 지금 `SessionsScreen` 종료는 로컬 클리어가 없어 불완전 | 제안 |
|
| Q8c | 로그아웃? | **P0**. 설정에서 로그아웃 → 현재 세션 revoke + 로컬 토큰/유저 클리어 → 입장 화면 | `SessionsScreen`만으로는 불완전했음 | 확정 |
|
||||||
| Q8d | 설정 IA? | 대화 목록 **⋮ 또는 설정 진입점** 하나로 묶음: 말투 · 자율성 · 로그인 세션 · 데이터 흐름 · **로그아웃** | 자율성만 깊숙이 있어 발견성이 떨어짐 | 제안 |
|
| Q8d | 설정 IA? | 대화 목록 **설정** 화면: 말투 · 자율성 · 로그인 세션 · 데이터 흐름 · **로그아웃** | 발견성 | 확정 |
|
||||||
| Q8e | 계정 삭제/탈퇴? | Phase 1에서는 **운영자 수동/후속**. 베타 UX에 「탈퇴」를 넣지 않음(초대 코드 1회용 정책과 맞춤). 필요 시 C 이후 별도 Q | 탈퇴·GDPR급 삭제는 베타 가설 검증보다 큼 | 제안 |
|
| Q8e | 계정 삭제/탈퇴? | Phase 1 앱 UI **비범위** (운영 수동) | 베타 범위 | 확정 |
|
||||||
|
|
||||||
**Master 승인 시** 위 행 상태를 `확정`으로 바꾸고 `PRD.md` / `tech-design.md` / `roadmap.md`의
|
|
||||||
대응 항목을 같이 갱신한 뒤 구현한다.
|
|
||||||
|
|
||||||
상세 명세: [`account-settings-ia.md`](./account-settings-ia.md).
|
상세 명세: [`account-settings-ia.md`](./account-settings-ia.md).
|
||||||
|
|
||||||
|
|
@ -64,5 +60,5 @@
|
||||||
- 자율성 기본 시작 레벨 L1 vs L2 (Q3 인터뷰)
|
- 자율성 기본 시작 레벨 L1 vs L2 (Q3 인터뷰)
|
||||||
- 상표 정식 등록 여부 (Q6은 "와카뷰"로 명칭 확정, 등록 절차는 베타 반응 이후)
|
- 상표 정식 등록 여부 (Q6은 "와카뷰"로 명칭 확정, 등록 절차는 베타 반응 이후)
|
||||||
- 베타 참가자 모집 규모와 방식
|
- 베타 참가자 모집 규모와 방식
|
||||||
- Q8 / Q9 제안 → Master 확정
|
- Q9 제안 → Master 확정 (Q8은 2026-08-03 확정)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,9 +40,9 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다.
|
||||||
- 진행 중: **N4 FCM 코드 경로** → Master 시크릿 대기 → Android UI QA
|
- 진행 중: **N4 FCM 코드 경로** → Master 시크릿 대기 → Android UI QA
|
||||||
- UI: **iMessage-inspired light default** + soft charcoal dark 프로덕션 반영
|
- UI: **iMessage-inspired light default** + soft charcoal dark 프로덕션 반영
|
||||||
- **Track C 콘텐츠 갭 A~F**: 프로덕션 반영 완료 (`95422bb`대)
|
- **Track C 콘텐츠 갭 A~F**: 프로덕션 반영 완료 (`95422bb`대)
|
||||||
- **문서 갭 (2026-08-03):** 로그아웃·설정 IA·L2 의미 —
|
- **Q8 계정·설정 IA (2026-08-03 확정·구현):** 로그인 탭 · 설정 허브 · 로그아웃
|
||||||
[`decision-log.md`](./decision-log.md) Q8/Q9 **제안**,
|
- **문서 갭 잔여:** L2 의미 [`decision-log.md`](./decision-log.md) Q9 **제안** —
|
||||||
[`account-settings-ia.md`](./account-settings-ia.md). **Master 확정 전 구현 착수하지 않음**
|
[`account-settings-ia.md`](./account-settings-ia.md) §3. Master 확정 전 Q9 구현 금지
|
||||||
- 실 FCM 기기 수신 · Android 실기기 탭 · 사람 PoC 실행은 남음
|
- 실 FCM 기기 수신 · Android 실기기 탭 · 사람 PoC 실행은 남음
|
||||||
|
|
||||||
### NEXT 순서
|
### NEXT 순서
|
||||||
|
|
|
||||||
|
|
@ -333,11 +333,11 @@ Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase
|
||||||
- [x] 로그인 세션/토큰 (`Session`, signup/login 시 Bearer 발급)
|
- [x] 로그인 세션/토큰 (`Session`, signup/login 시 Bearer 발급)
|
||||||
- [x] `/invites`, `/admin/metrics` 접근 제어 (`ADMIN_API_TOKEN`)
|
- [x] `/invites`, `/admin/metrics` 접근 제어 (`ADMIN_API_TOKEN`)
|
||||||
- [x] 프로덕션 DB 마이그레이션 명령 (`go run . migrate`)
|
- [x] 프로덕션 DB 마이그레이션 명령 (`go run . migrate`)
|
||||||
- [ ] **계정·설정 IA 갭** (`decision-log` Q8 제안, [`account-settings-ia.md`](./account-settings-ia.md))
|
- [x] **계정·설정 IA** (`decision-log` Q8 확정, [`account-settings-ia.md`](./account-settings-ia.md))
|
||||||
— Master 확정 후 착수. 이메일/비번 도입 금지(Phase 1)
|
— 이메일/비번 도입 금지(Phase 1)
|
||||||
- [ ] 입장 화면: 새로 가입 / 이미 가입(`POST /auth/login`) 구분
|
- [x] 입장 화면: 새로 가입 / 이미 가입(`POST /auth/login`, DEMO는 display_name 필수)
|
||||||
- [ ] 로그아웃: 세션 revoke + 로컬 토큰 클리어 + 입장 화면 복귀
|
- [x] 로그아웃: 세션 revoke + 로컬 토큰 클리어 + 입장 화면 복귀
|
||||||
- [ ] 설정 진입점에 로그아웃·세션·자율성·말투·데이터흐름 묶기
|
- [x] 설정 진입점에 로그아웃·세션·자율성·말투·데이터흐름 묶기
|
||||||
|
|
||||||
**A3. Flutter — 메신저답게 다듬기** (A1/A2 이후)
|
**A3. Flutter — 메신저답게 다듬기** (A1/A2 이후)
|
||||||
- [x] 대화 목록/연락처 UI를 서버 API에 연결
|
- [x] 대화 목록/연락처 UI를 서버 API에 연결
|
||||||
|
|
|
||||||
|
|
@ -62,9 +62,9 @@ v1에서는 커스텀 모델을 새로 학습하지 않는다. 대신 **검색
|
||||||
| 경로 | 역할 |
|
| 경로 | 역할 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `POST /auth/signup` | 미사용 초대 코드 + `display_name` → User + Session(Bearer) |
|
| `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...` | 멀티 디바이스 목록·종료 |
|
| `GET/DELETE /users/:id/sessions...` | 멀티 디바이스 목록·종료 |
|
||||||
| 클라이언트 | `shared_preferences`에 토큰 저장; **로그아웃 시 revoke + 로컬 삭제** (갭) |
|
| 클라이언트 | `shared_preferences`에 토큰 저장; 로그아웃 시 revoke + 로컬 삭제 → 입장 화면 |
|
||||||
|
|
||||||
- Phase 1은 **비밀번호·OAuth 없음.** 초대 코드가 비밀에 해당한다.
|
- Phase 1은 **비밀번호·OAuth 없음.** 초대 코드가 비밀에 해당한다.
|
||||||
- 로그아웃은 서버 세션 무효화만으로는 부족하고, 클라이언트가 토큰을 지워야 입장 화면으로 돌아간다.
|
- 로그아웃은 서버 세션 무효화만으로는 부족하고, 클라이언트가 토큰을 지워야 입장 화면으로 돌아간다.
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,10 @@ import '../services/snooze_service.dart';
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
import '../widgets/gradient_text.dart';
|
import '../widgets/gradient_text.dart';
|
||||||
import '../widgets/my_user_id_chip.dart';
|
import '../widgets/my_user_id_chip.dart';
|
||||||
import 'autonomy_settings_screen.dart';
|
|
||||||
import 'chat_screen.dart';
|
import 'chat_screen.dart';
|
||||||
import 'contacts_screen.dart';
|
import 'contacts_screen.dart';
|
||||||
import 'inbox_screen.dart';
|
import 'inbox_screen.dart';
|
||||||
import 'onboarding_tone_screen.dart';
|
import 'settings_screen.dart';
|
||||||
|
|
||||||
class ConversationListScreen extends StatefulWidget {
|
class ConversationListScreen extends StatefulWidget {
|
||||||
const ConversationListScreen({super.key});
|
const ConversationListScreen({super.key});
|
||||||
|
|
@ -262,30 +261,14 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.contacts_outlined),
|
icon: const Icon(Icons.contacts_outlined),
|
||||||
),
|
),
|
||||||
PopupMenuButton<VoidCallback>(
|
IconButton(
|
||||||
tooltip: '더보기',
|
tooltip: '설정',
|
||||||
icon: const Icon(Icons.more_vert),
|
onPressed: () {
|
||||||
onSelected: (action) => action(),
|
Navigator.of(context).push(
|
||||||
itemBuilder: (context) => [
|
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
||||||
PopupMenuItem(
|
);
|
||||||
value: () => Navigator.of(context)
|
},
|
||||||
.push(MaterialPageRoute(builder: (_) => const OnboardingToneScreen())),
|
icon: const Icon(Icons.settings_outlined),
|
||||||
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('자율성 설정'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -57,13 +57,16 @@ class _SessionsScreenState extends State<SessionsScreen> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
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 {
|
try {
|
||||||
await session.api.revokeSession(session.user!.id, id.toInt());
|
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();
|
await _load();
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
setState(() => _error = '세션 종료 실패 (${e.statusCode})');
|
setState(() => _error = '세션 종료 실패 (${e.statusCode})');
|
||||||
|
|
|
||||||
|
|
@ -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<void> _confirmLogout(BuildContext context) async {
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
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<SessionState>();
|
||||||
|
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<SessionState>();
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import '../widgets/brand_mark.dart';
|
||||||
import '../widgets/gradient_text.dart';
|
import '../widgets/gradient_text.dart';
|
||||||
import '../widgets/primary_gradient_button.dart';
|
import '../widgets/primary_gradient_button.dart';
|
||||||
|
|
||||||
|
/// Closed-beta entry: signup (new invite) or login (already registered).
|
||||||
class SignupScreen extends StatefulWidget {
|
class SignupScreen extends StatefulWidget {
|
||||||
const SignupScreen({super.key});
|
const SignupScreen({super.key});
|
||||||
|
|
||||||
|
|
@ -16,12 +17,26 @@ class SignupScreen extends StatefulWidget {
|
||||||
State<SignupScreen> createState() => _SignupScreenState();
|
State<SignupScreen> createState() => _SignupScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SignupScreenState extends State<SignupScreen> {
|
class _SignupScreenState extends State<SignupScreen>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
final _invite = TextEditingController();
|
final _invite = TextEditingController();
|
||||||
final _name = 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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_tabs.dispose();
|
||||||
_invite.dispose();
|
_invite.dispose();
|
||||||
_name.dispose();
|
_name.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
|
|
@ -33,6 +48,16 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final session = context.watch<SessionState>();
|
final session = context.watch<SessionState>();
|
||||||
|
|
@ -40,8 +65,13 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||||
final scheme = theme.colorScheme;
|
final scheme = theme.colorScheme;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Padding(
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||||
|
child: IntrinsicHeight(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -60,12 +90,27 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||||
style: theme.textTheme.bodyLarge?.copyWith(color: scheme.onSurfaceVariant),
|
style: theme.textTheme.bodyLarge?.copyWith(color: scheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
const Spacer(flex: 1),
|
const Spacer(flex: 1),
|
||||||
Text('초대 코드로 클로즈드 베타에 참여합니다', style: theme.textTheme.labelLarge),
|
TabBar(
|
||||||
|
controller: _tabs,
|
||||||
|
tabs: const [
|
||||||
|
Tab(text: '새로 가입'),
|
||||||
|
Tab(text: '이미 가입'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
_isLogin
|
||||||
|
? '초대 코드와 표시 이름으로 다시 로그인합니다'
|
||||||
|
: '초대 코드로 클로즈드 베타에 참여합니다',
|
||||||
|
style: theme.textTheme.labelLarge,
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_DemoTestPanel(
|
_DemoTestPanel(
|
||||||
onFill: _fillDemoCredentials,
|
onFill: _fillDemoCredentials,
|
||||||
onCopy: () async {
|
onCopy: () async {
|
||||||
await Clipboard.setData(const ClipboardData(text: AppConfig.demoInviteCode));
|
await Clipboard.setData(
|
||||||
|
const ClipboardData(text: AppConfig.demoInviteCode),
|
||||||
|
);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('테스트 초대 코드를 복사했습니다')),
|
const SnackBar(content: Text('테스트 초대 코드를 복사했습니다')),
|
||||||
|
|
@ -85,15 +130,14 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _name,
|
controller: _name,
|
||||||
decoration: const InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: '표시 이름',
|
labelText: '표시 이름',
|
||||||
prefixIcon: Icon(Icons.person_outline),
|
hintText: _isLogin ? '가입 때 쓴 이름 (DEMO는 필수)' : null,
|
||||||
|
prefixIcon: const Icon(Icons.person_outline),
|
||||||
),
|
),
|
||||||
textInputAction: TextInputAction.done,
|
textInputAction: TextInputAction.done,
|
||||||
onSubmitted: (_) {
|
onSubmitted: (_) {
|
||||||
if (!session.loading) {
|
if (!session.loading) _submit(session);
|
||||||
session.signup(_invite.text.trim(), _name.text.trim());
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (session.error != null) ...[
|
if (session.error != null) ...[
|
||||||
|
|
@ -103,11 +147,9 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_PressScale(
|
_PressScale(
|
||||||
child: PrimaryGradientButton(
|
child: PrimaryGradientButton(
|
||||||
label: '시작하기',
|
label: _isLogin ? '로그인' : '시작하기',
|
||||||
loading: session.loading,
|
loading: session.loading,
|
||||||
onPressed: session.loading
|
onPressed: session.loading ? null : () => _submit(session),
|
||||||
? null
|
|
||||||
: () => session.signup(_invite.text.trim(), _name.text.trim()),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
@ -116,6 +158,10 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<List<ConversationSummary>> listConversations() async {
|
Future<List<ConversationSummary>> listConversations() async {
|
||||||
final obj = await _getObject('/conversations');
|
final obj = await _getObject('/conversations');
|
||||||
final list = (obj['conversations'] as List<dynamic>? ?? const []);
|
final list = (obj['conversations'] as List<dynamic>? ?? const []);
|
||||||
|
|
|
||||||
|
|
@ -109,15 +109,7 @@ class SessionState extends ChangeNotifier {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
try {
|
try {
|
||||||
final result = await _api.signup(inviteCode: inviteCode, displayName: displayName);
|
final result = await _api.signup(inviteCode: inviteCode, displayName: displayName);
|
||||||
user = result.user;
|
await _persistAuth(result.user, result.token, resetToneOnboarding: true);
|
||||||
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 _registerDeviceTokenBestEffort();
|
await _registerDeviceTokenBestEffort();
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
error = '가입 실패 (${e.statusCode}): ${e.body}';
|
error = '가입 실패 (${e.statusCode}): ${e.body}';
|
||||||
|
|
@ -129,6 +121,71 @@ class SessionState extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Q8b — already registered: invite code + display name → new session.
|
||||||
|
Future<void> 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<void> _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<void> 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<void> saveToneSamples(List<String> samples, {bool markDone = true}) async {
|
Future<void> saveToneSamples(List<String> samples, {bool markDone = true}) async {
|
||||||
final cleaned = samples.map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
|
final cleaned = samples.map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
|
||||||
styleExamples = cleaned;
|
styleExamples = cleaned;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue