1
0
forked from baron/baron-sso

43 Commits

Author SHA1 Message Date
Lectom C Han
8df95c8a13 i18n 대대적 변경 2026-02-10 19:13:00 +09:00
kyy
798d37bed9 Merge pull request 'Http 환경 CopyButton 오류 수정' (#236) from feature/devfront-content into dev
Reviewed-on: ai-team/baron-sso#236
2026-02-10 12:43:10 +09:00
kyy
28477aafc6 Http 환경 CopyButton 오류 수정 2026-02-10 12:40:19 +09:00
eec583b0a1 Merge pull request 'dev-stagingcd-fix' (#235) from dev-stagingcd-fix into dev
Reviewed-on: ai-team/baron-sso#235
2026-02-10 12:31:48 +09:00
90fcd99b2f Merge branch 'feature/stagingcd' into dev 2026-02-10 11:29:41 +09:00
kyy
3a019f8e23 Merge pull request '초기 coverage 값 수정' (#227) from feature/hydra-content into dev
Reviewed-on: ai-team/baron-sso#227
2026-02-10 10:53:55 +09:00
kyy
7003c0ada7 초기 coverage 값 수정 2026-02-10 10:53:21 +09:00
kyy
93699bb1b3 Merge pull request 'feature/hydra-content' (#226) from feature/hydra-content into dev
Reviewed-on: ai-team/baron-sso#226
2026-02-10 10:34:00 +09:00
3a05ba94e0 1 del 2026-02-10 10:32:07 +09:00
b3371f48bb CLICKHOUSE_HOST add 2026-02-10 10:30:44 +09:00
a1ece93a5c REDIS_ADDR 문법 오류 수정 2026-02-10 10:17:46 +09:00
kyy
07aae642a7 golangci lint 적용 2026-02-10 10:15:44 +09:00
f0cd60bf56 문법 오류 수정 2026-02-10 10:03:39 +09:00
kyy
6569faee76 App 현황 표시 UI개선 2026-02-10 09:58:29 +09:00
0d3aac0a58 $기호 정제 2026-02-10 09:56:48 +09:00
93f6182cce $빠진거 추가 2026-02-10 09:56:28 +09:00
27248cfa53 $ 제거 2026-02-10 09:34:41 +09:00
kyy
0741baf60b df 클라이언트 상세 조회 테스트 추가 2026-02-10 09:07:47 +09:00
kyy
68459151c3 백엔드 Hydra 테스트 가이드 문서 업데이트 2026-02-10 09:07:47 +09:00
kyy
dd9e6394ad Hydra 연동 관련 핸들러 테스트 케이스 추가 2026-02-10 09:07:47 +09:00
kyy
15a27a6620 핸들러 및 서비스 테스트 리팩토링 2026-02-10 09:07:47 +09:00
kyy
f16cb9a344 Actions 백엔드 테스트 커버리지 워크플로우 추가 2026-02-10 09:07:47 +09:00
Lectom C Han
9cdd89c1c1 환경변수 descope관련 값 제거, 비밀번호 변경기능 규격 변경 2026-02-09 18:01:59 +09:00
abba85af75 "" 제거 2026-02-09 17:40:51 +09:00
c5dc531f1b 안쓰는 변수 비활 2026-02-09 17:26:31 +09:00
440785dfe8 환경변수 추가 2026-02-09 16:58:36 +09:00
2ca612da7f oathkeeper url r고정 2026-02-09 14:32:52 +09:00
0e88bc3986 중복 "github.com/coreos/go-oidc/v3/oidc"
제거
2026-02-09 14:32:21 +09:00
c5f9445d67 nginx 5000 fix 2026-02-09 14:27:28 +09:00
c7c4eb1b82 Oathkeeper 명시적 URL 2026-02-09 14:27:05 +09:00
880287088f oathkeeper 이름 통일 2026-02-09 13:56:48 +09:00
561ae56cbb depends_on 조정 2026-02-09 13:52:05 +09:00
5d66e983cd 환경변수 추가 2026-02-09 13:08:18 +09:00
kyy
8e45422606 Merge pull request 'feature/hydra-content' (#221) from feature/hydra-content into dev
Reviewed-on: ai-team/baron-sso#221
2026-02-09 12:32:30 +09:00
eb34d387ad -e add 2026-02-09 11:26:15 +09:00
kyy
9b7b2da497 golangci 린트 적용 2026-02-09 11:23:13 +09:00
7f0a44e4ee sql command 수정 2026-02-09 11:21:15 +09:00
kyy
05b6ff6d9e 앱 현황 섹션 통합 및 Linked API 기반 UI 개편 2026-02-09 11:19:10 +09:00
kyy
bb78c8e572 Linked API에 Audit Log 스캔을 통한 과거 이력 보강 로직 추가 2026-02-09 11:18:32 +09:00
3d3e6a0c9c 디버그용 문장 추가 2026-02-09 11:16:30 +09:00
b49a39f9b2 healthcheck 시간 추가 2026-02-09 11:04:48 +09:00
658113d779 설정 파일 오류 수정 2026-02-09 10:59:05 +09:00
kyy
4b7264217d Apply stashed changes after rebase 2026-02-09 09:56:10 +09:00
59 changed files with 6003 additions and 906 deletions

View File

@@ -38,11 +38,6 @@ AUDIT_QUEUE_SIZE=2000 # 감사 로그 대기열(채널) 버퍼 크기
# Redis Cache Configuration # Redis Cache Configuration
PROFILE_CACHE_TTL=30m # User Profile Redis 캐시 만료 시간 PROFILE_CACHE_TTL=30m # User Profile Redis 캐시 만료 시간
# Descope Project ID (Required for Auth)
DESCOPE_PROJECT_ID=P2t...your_descope_project_id
DESCOPE_MANAGEMENT_KEY=your_descope_management_key_here
DESCOPE_TEST_ACCOUNT=tester@baroncs.co.kr
# --- Naver Cloud Services --- # --- Naver Cloud Services ---
NAVER_CLOUD_ACCESS_KEY=ncp_iam_... NAVER_CLOUD_ACCESS_KEY=ncp_iam_...
NAVER_CLOUD_SECRET_KEY=ncp_iam_... NAVER_CLOUD_SECRET_KEY=ncp_iam_...

7
.gitea/coverage.json Normal file
View File

@@ -0,0 +1,7 @@
{
"Path": "./backend/coverage.out",
"Thresholds": {
"baron-sso-backend/internal/handler": 10,
"baron-sso-backend/internal/service": 10
}
}

View File

@@ -0,0 +1,29 @@
name: Backend Test Coverage Check
on:
push:
branches:
- dev
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.25"
cache-dependency-path: backend/go.sum
- name: Run tests with coverage
working-directory: ./backend
run: |
go test -v -coverprofile=coverage.out -covermode=atomic ./internal/handler/... ./internal/service/...
- name: Check coverage
uses: vladopajic/go-test-coverage@v2
with:
config: ./.gitea/coverage.json

View File

@@ -106,11 +106,6 @@ jobs:
provenance: false provenance: false
sbom: false sbom: false
- name: Temporarily update userfront nginx port
run: |
sed -i 's/listen 5000;/listen 80;/g' userfront/nginx.conf
sed -i 's/proxy_pass http:\/\/baron_backend:3000;/proxy_pass http:\/\/baron_backend:3010;/g' userfront/nginx.conf
- name: Build and push userfront RC image - name: Build and push userfront RC image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:

View File

@@ -109,8 +109,6 @@ jobs:
"COOKIE_SECRET=${{ secrets.PROD_COOKIE_SECRET }}" \ "COOKIE_SECRET=${{ secrets.PROD_COOKIE_SECRET }}" \
"JWT_SECRET=${{ secrets.PROD_JWT_SECRET }}" \ "JWT_SECRET=${{ secrets.PROD_JWT_SECRET }}" \
"REDIS_ADDR=${{ vars.PROD_REDIS_ADDR }}" \ "REDIS_ADDR=${{ vars.PROD_REDIS_ADDR }}" \
"DESCOPE_PROJECT_ID=${{ vars.DESCOPE_PROJECT_ID }}" \
"DESCOPE_MANAGEMENT_KEY=${{ secrets.DESCOPE_MANAGEMENT_KEY }}" \
"NAVER_CLOUD_ACCESS_KEY=${{ vars.NAVER_CLOUD_ACCESS_KEY }}" \ "NAVER_CLOUD_ACCESS_KEY=${{ vars.NAVER_CLOUD_ACCESS_KEY }}" \
"NAVER_CLOUD_SECRET_KEY=${{ secrets.NAVER_CLOUD_SECRET_KEY }}" \ "NAVER_CLOUD_SECRET_KEY=${{ secrets.NAVER_CLOUD_SECRET_KEY }}" \
"NAVER_CLOUD_SERVICE_ID=${{ vars.NAVER_CLOUD_SERVICE_ID }}" \ "NAVER_CLOUD_SERVICE_ID=${{ vars.NAVER_CLOUD_SERVICE_ID }}" \

View File

@@ -45,26 +45,35 @@ jobs:
# Sanity check # Sanity check
if [ -z "${STAGE_USER}" ] || [ -z "${STAGE_HOST}" ] || [ -z "${DEPLOY_PATH}" ]; then if [ -z "${STAGE_USER}" ] || [ -z "${STAGE_HOST}" ] || [ -z "${DEPLOY_PATH}" ]; then
echo "::error::Missing required vars (STAGE_USER/STAGE_HOST/DEPLOY_PATH). Check Gitea repo variables." echo "::error::Missing required vars (STAGE_USER/STAGE_HOST/DEPLOY_PATH)."
exit 1 exit 1
fi fi
ssh-keyscan -H "${STAGE_HOST}" >> ~/.ssh/known_hosts ssh-keyscan -H "${STAGE_HOST}" >> ~/.ssh/known_hosts
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p '${DEPLOY_PATH}'" ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p '${DEPLOY_PATH}'"
# Create .env for Staging using a HEREDOC to prevent shell expansion issues # .env 파일 생성
cat <<'EOF' > .env cat <<'EOF' > .env
APP_ENV=stage APP_ENV=${{ vars.APP_ENV }}
TZ=Asia/Seoul TZ=Asia/Seoul
IDP_PROVIDER=ory IDP_PROVIDER=ory
# DB & Clickhouse
DB_PORT=${{ vars.DB_PORT }} DB_PORT=${{ vars.DB_PORT }}
CLICKHOUSE_PORT_HTTP=${{ vars.CLICKHOUSE_PORT_HTTP }} CLICKHOUSE_PORT_HTTP=${{ vars.CLICKHOUSE_PORT_HTTP }}
CLICKHOUSE_PORT_NATIVE=${{ vars.CLICKHOUSE_PORT_NATIVE }} CLICKHOUSE_PORT_NATIVE=${{ vars.CLICKHOUSE_PORT_NATIVE }}
CLICKHOUSE_HOST=${{ vars.CLICKHOUSE_HOST }}
CLICKHOUSE_USER=${{ vars.CLICKHOUSE_USER }}
CLICKHOUSE_PASSWORD=${{ vars.CLICKHOUSE_PASSWORD }}
BACKEND_PORT=${{ vars.BACKEND_PORT }} BACKEND_PORT=${{ vars.BACKEND_PORT }}
ADMINFRONT_PORT=${{ vars.ADMINFRONT_PORT }} ADMINFRONT_PORT=${{ vars.ADMINFRONT_PORT }}
DEVFRONT_PORT=${{ vars.DEVFRONT_PORT }} DEVFRONT_PORT=${{ vars.DEVFRONT_PORT }}
USERFRONT_PORT=${{ vars.USERFRONT_PORT }} USERFRONT_PORT=${{ vars.USERFRONT_PORT }}
OATHKEEPER_API_URL=${{ vars.OATHKEEPER_API_URL }}
DB_USER=${{ vars.DB_USER }} DB_USER=${{ vars.DB_USER }}
DB_PASSWORD=${{ secrets.STG_DB_PASSWORD }} DB_PASSWORD=${{ secrets.STG_DB_PASSWORD }}
DB_NAME=${{ vars.DB_NAME }} DB_NAME=${{ vars.DB_NAME }}
@@ -75,8 +84,6 @@ jobs:
AUDIT_WORKER_COUNT=5 AUDIT_WORKER_COUNT=5
AUDIT_QUEUE_SIZE=2000 AUDIT_QUEUE_SIZE=2000
PROFILE_CACHE_TTL=${{ vars.PROFILE_CACHE_TTL }} PROFILE_CACHE_TTL=${{ vars.PROFILE_CACHE_TTL }}
DESCOPE_PROJECT_ID=${{ vars.DESCOPE_PROJECT_ID }}
DESCOPE_MANAGEMENT_KEY=${{ secrets.DESCOPE_MANAGEMENT_KEY }}
DESCOPE_TEST_ACCOUNT=${{ vars.DESCOPE_TEST_ACCOUNT }} DESCOPE_TEST_ACCOUNT=${{ vars.DESCOPE_TEST_ACCOUNT }}
NAVER_CLOUD_ACCESS_KEY=${{ vars.NAVER_CLOUD_ACCESS_KEY }} NAVER_CLOUD_ACCESS_KEY=${{ vars.NAVER_CLOUD_ACCESS_KEY }}
NAVER_CLOUD_SECRET_KEY=${{ secrets.NAVER_CLOUD_SECRET_KEY }} NAVER_CLOUD_SECRET_KEY=${{ secrets.NAVER_CLOUD_SECRET_KEY }}
@@ -119,20 +126,29 @@ jobs:
OATHKEEPER_HEALTH_ENABLED=${{ vars.OATHKEEPER_HEALTH_ENABLED }} OATHKEEPER_HEALTH_ENABLED=${{ vars.OATHKEEPER_HEALTH_ENABLED }}
CSRF_COOKIE_NAME=${{ vars.CSRF_COOKIE_NAME }} CSRF_COOKIE_NAME=${{ vars.CSRF_COOKIE_NAME }}
CSRF_COOKIE_SECRET=${{ secrets.STG_CSRF_COOKIE_SECRET }} CSRF_COOKIE_SECRET=${{ secrets.STG_CSRF_COOKIE_SECRET }}
OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }} # OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }}
OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }} # OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
EOF EOF
# Copy artifacts to remote # 파일 복사
# Using compose.infra.yaml as base for staging (assuming simplified structure compared to prod) ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p ${DEPLOY_PATH}/docker"
# OR use docker-compose.template.yaml if staging follows prod structure strictly
# [중요] docker/ory 폴더 복사 (여기에 init-db/1-createdb.sql이 있어야 함)
scp -r docker/ory "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/docker/"
if [ -d "docker/init-metadata" ]; then
scp -r docker/init-metadata "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/docker/"
fi
if [ -d "gateway" ]; then
scp -r gateway "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/"
fi
scp docker/docker-compose.staging.template.yaml .env "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/" scp docker/docker-compose.staging.template.yaml .env "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/"
scp docker/compose.infra.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.infra.yml" scp docker/compose.infra.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.infra.yml"
# Ory compose files might be needed too
scp docker/compose.ory.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.ory.yml" scp docker/compose.ory.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.ory.yml"
scp -r docker/ory "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/docker/"
# Deploy # 배포 실행
echo "${HARBOR_ROBOT_KEY}" | ssh "${STAGE_USER}@${STAGE_HOST}" \ echo "${HARBOR_ROBOT_KEY}" | ssh "${STAGE_USER}@${STAGE_HOST}" \
"export DEPLOY_PATH='${DEPLOY_PATH}'; \ "export DEPLOY_PATH='${DEPLOY_PATH}'; \
export BACKEND_IMAGE_NAME='${BACKEND_IMAGE_NAME}'; \ export BACKEND_IMAGE_NAME='${BACKEND_IMAGE_NAME}'; \
@@ -142,18 +158,33 @@ jobs:
export IMAGE_TAG='${IMAGE_TAG}'; \ export IMAGE_TAG='${IMAGE_TAG}'; \
export HARBOR_ENDPOINT='${HARBOR_ENDPOINT}'; \ export HARBOR_ENDPOINT='${HARBOR_ENDPOINT}'; \
export HARBOR_ROBOT_ACCOUNT='${HARBOR_ROBOT_ACCOUNT}'; \ export HARBOR_ROBOT_ACCOUNT='${HARBOR_ROBOT_ACCOUNT}'; \
set -e; \
cd \"\${DEPLOY_PATH}\"; \ cd \"\${DEPLOY_PATH}\"; \
docker login \"\${HARBOR_ENDPOINT}\" -u \"\${HARBOR_ROBOT_ACCOUNT}\" --password-stdin; \ docker login \"\${HARBOR_ENDPOINT}\" -u \"\${HARBOR_ROBOT_ACCOUNT}\" --password-stdin; \
set -a; \ set -a; . ./.env; set +a; \
. ./.env; \
set +a; \ # 네트워크 생성
for net in baron_net public_net ory-net hydranet kratosnet; do for net in baron_net public_net ory-net hydranet kratosnet; do
docker network inspect "\$net" >/dev/null 2>&1 || docker network create "\$net" docker network inspect \"\$net\" >/dev/null 2>&1 || docker network create \"\$net\"
done done
# Assuming template usage similar to prod
envsubst < docker-compose.staging.template.yaml > docker-compose.yml; \ envsubst < docker-compose.staging.template.yaml > docker-compose.yml; \
# Pull & Up
# Assuming staging runs both infra, ory, and app stack # [중요] 설정 파일 권한 문제 해결 (Ory 이미지는 root가 아닌 사용자로 실행됨)
chmod -R 777 docker/ory
docker compose -f compose.infra.yml -f compose.ory.yml -f docker-compose.yml pull; \ docker compose -f compose.infra.yml -f compose.ory.yml -f docker-compose.yml pull; \
docker compose -f compose.infra.yml -f compose.ory.yml -f docker-compose.yml up -d"
# [주의] DB 초기화 스크립트는 '새로운 볼륨'에서만 실행됨.
# DB 초기화 문제를 확실히 해결하기 위해 기존 볼륨을 날리고 다시 띄움 (데이터 삭제됨 주의)
# 스테이징이므로 초기화 진행. 데이터 보존이 필요하면 이 줄 제거하고 수동으로 DB 만들어야 함.
docker compose -f compose.infra.yml -f compose.ory.yml -f docker-compose.yml down -v || true
docker compose -f compose.infra.yml -f compose.ory.yml -f docker-compose.yml up -d --remove-orphans; \
# 배포 후 상태 확인 (실패 시 로그 출력을 위함)
sleep 10; \
if [ \$(docker inspect -f '{{.State.ExitCode}}' baron-sso-staging-kratos-migrate-1) -ne 0 ]; then \
echo 'Kratos Migrate Failed. Logs:'; \
docker logs baron-sso-staging-kratos-migrate-1; \
exit 1; \
fi"

View File

@@ -155,13 +155,9 @@ Kratos가 사용자 SoT이며 Hydra는 순수 OIDC 토큰 엔진입니다. 비
```bash ```bash
cp .env.sample .env cp .env.sample .env
``` ```
2. **중요**: `.env` 파일을 열어 **Descope Project ID**를 입력해야 합니다. 2. **IDP 우선순위와 Ory 엔드포인트를 지정**합니다. 기본값은 Ory 입니다
```env
DESCOPE_PROJECT_ID=P2t...
``` ```
3. **IDP 우선순위와 Ory 엔드포인트를 지정**합니다. 기본값은 Ory 우선 + Descope 폴백입니다. IDP_PROVIDER=ory
```env
IDP_PROVIDER=ory,descope
KRATOS_ADMIN_URL=http://kratos:4434 KRATOS_ADMIN_URL=http://kratos:4434
HYDRA_ADMIN_URL=http://hydra:4445 HYDRA_ADMIN_URL=http://hydra:4445
HYDRA_PUBLIC_URL=http://hydra:4444 HYDRA_PUBLIC_URL=http://hydra:4444

View File

@@ -40,11 +40,8 @@ It leverages **Descope** for secure, passwordless authentication (Enchanted Link
```bash ```bash
cp .env.sample .env cp .env.sample .env
``` ```
2. **Crucial**: Edit `.env` and provide your **Descope Project ID**.
```env 2. Set the **IDP priority and Ory admin endpoints**. The default is Ory first with Descope as fallback.
DESCOPE_PROJECT_ID=P2t...
```
3. Set the **IDP priority and Ory admin endpoints**. The default is Ory first with Descope as fallback.
```env ```env
IDP_PROVIDER=ory,descope IDP_PROVIDER=ory,descope
KRATOS_ADMIN_URL=http://kratos:4434 KRATOS_ADMIN_URL=http://kratos:4434

View File

@@ -32,3 +32,13 @@ type AuditCursor struct {
Timestamp time.Time Timestamp time.Time
EventID string EventID string
} }
// RedisRepository defines interface for KV storage (Redis)
type RedisRepository interface {
Set(key string, value string, expiration time.Duration) error
Get(key string) (string, error)
Delete(key string) error
StoreVerificationCode(phone, code string) error
GetVerificationCode(phone string) (string, error)
DeleteVerificationCode(phone string) error
}

View File

@@ -80,7 +80,7 @@ const (
type AuthHandler struct { type AuthHandler struct {
SmsService domain.SmsService SmsService domain.SmsService
EmailService domain.EmailService EmailService domain.EmailService
RedisService *service.RedisService RedisService domain.RedisRepository
KratosAdmin *service.KratosAdminService KratosAdmin *service.KratosAdminService
IdpProvider domain.IdentityProvider IdpProvider domain.IdentityProvider
AuditRepo domain.AuditRepository AuditRepo domain.AuditRepository
@@ -132,7 +132,7 @@ func GenerateUserCode() string {
) )
} }
func checkPollInterval(redis *service.RedisService, key string, interval time.Duration) (bool, int) { func checkPollInterval(redis domain.RedisRepository, key string, interval time.Duration) (bool, int) {
now := time.Now().UnixMilli() now := time.Now().UnixMilli()
val, err := redis.Get(key) val, err := redis.Get(key)
if err == nil && val != "" { if err == nil && val != "" {
@@ -147,7 +147,7 @@ func checkPollInterval(redis *service.RedisService, key string, interval time.Du
return false, int(interval.Seconds()) return false, int(interval.Seconds())
} }
func NewAuthHandler(redisService *service.RedisService, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, oathkeeperRepo domain.OathkeeperLogRepository, tenantService service.TenantService, ketoService service.KetoService, userRepo repository.UserRepository, consentRepo repository.ClientConsentRepository) *AuthHandler { func NewAuthHandler(redisService domain.RedisRepository, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, oathkeeperRepo domain.OathkeeperLogRepository, tenantService service.TenantService, ketoService service.KetoService, userRepo repository.UserRepository, consentRepo repository.ClientConsentRepository) *AuthHandler {
return &AuthHandler{ return &AuthHandler{
SmsService: service.NewSmsService(), SmsService: service.NewSmsService(),
EmailService: service.NewEmailService(), EmailService: service.NewEmailService(),
@@ -3304,7 +3304,7 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
Name: name, Name: name,
Logo: extractHydraClientLogo(client.Metadata), Logo: extractHydraClientLogo(client.Metadata),
URL: clientURL, URL: clientURL,
Status: hydraClientStatus(client.Metadata), Status: "active", // Hydra 세션이 있으면 활성
Scopes: scopes, Scopes: scopes,
}, },
lastAuth: lastAuth, lastAuth: lastAuth,
@@ -3327,6 +3327,143 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
} }
} }
// [New] DB에서 과거 동의 내역 가져와 병합 (비활성 RP 포함)
if h.ConsentRepo != nil {
for _, subject := range subjects {
dbConsents, err := h.ConsentRepo.ListBySubject(c.Context(), subject)
if err != nil {
slog.Error("failed to list db consents for subject", "subject", subject, "error", err)
continue
}
for _, dc := range dbConsents {
if _, exists := records[dc.ClientID]; exists {
// 이미 Hydra 세션으로 존재하면 skip (active 우선)
continue
}
// Hydra에서 클라이언트 정보 조회 (메타데이터용)
client, err := h.Hydra.GetClient(c.Context(), dc.ClientID)
if err != nil {
slog.Error("failed to get client info from hydra for inactive rp", "client_id", dc.ClientID, "error", err)
// Hydra에 정보가 없더라도 기본 정보로 추가
records[dc.ClientID] = &linkedRpRecord{
linkedRpSummary: linkedRpSummary{
ID: dc.ClientID,
Name: dc.ClientID,
Status: "inactive",
Scopes: dc.GrantedScopes,
},
lastAuth: dc.UpdatedAt,
}
continue
}
name := strings.TrimSpace(client.ClientName)
if name == "" {
name = client.ClientID
}
clientURL := strings.TrimSpace(client.ClientURI)
if clientURL == "" && len(client.RedirectURIs) > 0 {
if parsed, err := url.Parse(client.RedirectURIs[0]); err == nil {
clientURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
}
}
records[dc.ClientID] = &linkedRpRecord{
linkedRpSummary: linkedRpSummary{
ID: dc.ClientID,
Name: name,
Logo: extractHydraClientLogo(client.Metadata),
URL: clientURL,
Status: "inactive",
Scopes: dc.GrantedScopes,
},
lastAuth: dc.UpdatedAt,
}
}
}
}
// [New] Audit Log Scan for recent history fallback (timeline 200 items)
// Hydra 세션이나 로컬 DB(ConsentRepo)에 없지만 최근 활동 이력이 있는 앱을 보강
if h.AuditRepo != nil {
for _, subject := range subjects {
auditLogs, err := h.AuditRepo.FindByUserAndEvents(c.Context(), subject, []string{"consent.granted", "consent.revoked"}, 200)
if err != nil {
slog.Error("failed to scan audit logs for linked rps", "error", err, "subject", subject)
continue
}
for _, log := range auditLogs {
var details struct {
ClientID string `json:"client_id"`
ClientName string `json:"client_name"`
Scopes interface{} `json:"scopes"`
}
// 로그 Details 파싱
if err := json.Unmarshal([]byte(log.Details), &details); err != nil {
continue
}
if details.ClientID == "" {
continue
}
// 이미 records에 있으면(Active or ConsentRepo) 패스
if _, exists := records[details.ClientID]; exists {
continue
}
// 스코프 추출 (consent.granted인 경우)
scopes := []string{}
if sList, ok := details.Scopes.([]interface{}); ok {
for _, s := range sList {
if str, ok := s.(string); ok {
scopes = append(scopes, str)
}
}
}
// 기본 레코드 생성
record := &linkedRpRecord{
linkedRpSummary: linkedRpSummary{
ID: details.ClientID,
Name: details.ClientName, // revoked 로그일 경우 비어있을 수 있음
Status: "inactive",
Scopes: scopes,
},
lastAuth: log.Timestamp,
}
// Hydra에서 최신 메타데이터 조회 시도
client, err := h.Hydra.GetClient(c.Context(), details.ClientID)
if err == nil {
name := strings.TrimSpace(client.ClientName)
if name == "" {
name = client.ClientID
}
record.Name = name
record.Logo = extractHydraClientLogo(client.Metadata)
clientURL := strings.TrimSpace(client.ClientURI)
if clientURL == "" && len(client.RedirectURIs) > 0 {
if parsed, err := url.Parse(client.RedirectURIs[0]); err == nil {
clientURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
}
}
record.URL = clientURL
} else {
// Hydra 정보 없음 (삭제됨 등) -> Audit 정보나 ID로 대체
if record.Name == "" {
record.Name = details.ClientID
}
}
records[details.ClientID] = record
}
}
}
ordered := make([]*linkedRpRecord, 0, len(records)) ordered := make([]*linkedRpRecord, 0, len(records))
for _, record := range records { for _, record := range records {
ordered = append(ordered, record) ordered = append(ordered, record)
@@ -3336,7 +3473,10 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
}) })
items := make([]linkedRpSummary, 0, len(ordered)) items := make([]linkedRpSummary, 0, len(ordered))
for _, record := range ordered { for i, record := range ordered {
if i >= 100 {
break
}
if !record.lastAuth.IsZero() { if !record.lastAuth.IsZero() {
record.LastAuthenticatedAt = record.lastAuth.Format(time.RFC3339) record.LastAuthenticatedAt = record.lastAuth.Format(time.RFC3339)
} }

View File

@@ -0,0 +1,106 @@
package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
)
func TestRevokeLinkedRp_Success(t *testing.T) {
// Mock Hydra transport for revocation
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
// 1. Kratos whoami
if r.URL.Path == "/sessions/whoami" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"identity": map[string]interface{}{"id": "user-123"},
}), nil
}
// 2. Hydra Revoke
if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" {
return httpResponse(r, http.StatusNoContent, ""), nil
}
return httpResponse(r, http.StatusNotFound, "not found"), nil
})
client := &http.Client{Transport: transport}
origDefault := http.DefaultClient
http.DefaultClient = client
defer func() { http.DefaultClient = origDefault }()
auditRepo := &mockAuditRepo{}
h := &AuthHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: client,
},
AuditRepo: auditRepo,
}
app := fiber.New()
app.Delete("/api/v1/user/rp/linked/:id", h.RevokeLinkedRp)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/rp/linked/app-1", nil)
req.Header.Set("Cookie", "valid")
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, 1, len(auditRepo.logs))
}
func TestListRpHistory_Aggregation(t *testing.T) {
now := time.Now()
auditRepo := &mockAuditRepo{
logs: []domain.AuditLog{
{
UserID: "user-123",
EventType: "consent.revoked", // Newest
Timestamp: now,
Details: `{"client_id":"app-1"}`,
},
{
UserID: "user-123",
EventType: "consent.granted", // Oldest
Timestamp: now.Add(-1 * time.Hour),
Details: `{"client_id":"app-1", "client_name":"App One"}`,
},
},
}
h := &AuthHandler{
AuditRepo: auditRepo,
}
app := fiber.New()
app.Get("/api/v1/user/rp/history", h.ListRpHistory)
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"identity": map[string]interface{}{"id": "user-123"},
}), nil
})
http.DefaultClient = &http.Client{Transport: transport}
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/rp/history", nil)
req.Header.Set("Cookie", "valid")
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var res struct {
Items []struct {
ClientID string `json:"client_id"`
Status string `json:"status"`
} `json:"items"`
}
json.NewDecoder(resp.Body).Decode(&res)
assert.Equal(t, 1, len(res.Items))
assert.Equal(t, "app-1", res.Items[0].ClientID)
// Newest event (revoked) should win
assert.Equal(t, "revoked", res.Items[0].Status)
}

View File

@@ -0,0 +1,197 @@
package handler
import (
"baron-sso-backend/internal/service"
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
)
// --- Test Helpers ---
func newConsentTestApp(h *AuthHandler) *fiber.App {
app := fiber.New()
app.Get("/api/v1/auth/consent", h.GetConsentRequest)
app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest)
return app
}
// --- Tests ---
func TestGetConsentRequest_Normal(t *testing.T) {
// Mock Hydra transport
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-123" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"challenge": "challenge-123",
"requested_scope": []string{"openid", "profile"},
"skip": false,
"subject": "user-123",
"client": map[string]interface{}{
"client_id": "client-app",
"client_name": "Test App",
},
}), nil
}
return httpResponse(r, http.StatusNotFound, "not found"), nil
})
client := &http.Client{Transport: transport}
origDefault := http.DefaultClient
http.DefaultClient = client
defer func() { http.DefaultClient = origDefault }()
h := &AuthHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: client,
},
}
app := newConsentTestApp(h)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/consent?consent_challenge=challenge-123", nil)
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
assert.Equal(t, "challenge-123", body["challenge"])
assert.Equal(t, false, body["skip"])
}
func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
// Hydra: Get Consent Request
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-skip" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"challenge": "challenge-skip",
"requested_scope": []string{"openid"},
"skip": true,
"subject": "user-123",
"client": map[string]interface{}{
"client_id": "client-app",
},
}), nil
}
// Kratos: Get Identity
if r.URL.Path == "/admin/identities/user-123" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"id": "user-123",
"traits": map[string]interface{}{
"email": "user@test.com",
},
}), nil
}
// Hydra: Accept Consent Request
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-skip" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"redirect_to": "http://rp/cb",
}), nil
}
return httpResponse(r, http.StatusNotFound, "not found"), nil
})
client := &http.Client{Transport: transport}
origDefault := http.DefaultClient
http.DefaultClient = client
defer func() { http.DefaultClient = origDefault }()
consentRepo := &mockConsentRepo{}
h := &AuthHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: client,
},
KratosAdmin: &service.KratosAdminService{
AdminURL: "http://kratos.test",
HTTPClient: client,
},
ConsentRepo: consentRepo,
}
app := newConsentTestApp(h)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/consent?consent_challenge=challenge-skip", nil)
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
assert.Equal(t, "http://rp/cb", body["redirectTo"])
}
func TestAcceptConsentRequest_Normal(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-accept" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"challenge": "challenge-accept",
"requested_scope": []string{"openid", "profile"},
"subject": "user-123",
"client": map[string]interface{}{
"client_id": "client-app",
},
}), nil
}
if r.URL.Path == "/admin/identities/user-123" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"id": "user-123",
"traits": map[string]interface{}{
"email": "user@test.com",
},
}), nil
}
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-accept" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"redirect_to": "http://rp/cb",
}), nil
}
return httpResponse(r, http.StatusNotFound, "not found"), nil
})
client := &http.Client{Transport: transport}
origDefault := http.DefaultClient
http.DefaultClient = client
defer func() { http.DefaultClient = origDefault }()
auditRepo := &mockAuditRepo{}
consentRepo := &mockConsentRepo{}
h := &AuthHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: client,
},
KratosAdmin: &service.KratosAdminService{
AdminURL: "http://kratos.test",
HTTPClient: client,
},
AuditRepo: auditRepo,
ConsentRepo: consentRepo,
}
app := newConsentTestApp(h)
body, _ := json.Marshal(map[string]interface{}{
"consent_challenge": "challenge-accept",
"grant_scope": []string{"openid"},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, 1, len(auditRepo.logs))
}

View File

@@ -0,0 +1,123 @@
package handler
import (
"baron-sso-backend/internal/domain"
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
)
// Mock services
type mockEmailService struct{}
func (m *mockEmailService) SendEmail(to, subject, body string) error { return nil }
type mockSmsService struct{}
func (m *mockSmsService) SendSms(to, content string) error { return nil }
func TestEnchantedLinkFlow_Email_Success(t *testing.T) {
redis := &mockRedisRepo{data: make(map[string]string)}
// Force "Not Supported" for InitiateLinkLogin only to trigger custom Enchanted Link logic
idp := &mockIdpProvider{
userExists: true,
initiateLinkErr: domain.ErrNotSupported,
}
h := &AuthHandler{
RedisService: redis,
IdpProvider: idp,
EmailService: &mockEmailService{},
SmsService: &mockSmsService{},
}
app := fiber.New()
app.Post("/api/v1/auth/enchanted-link/init", h.InitEnchantedLink)
app.Post("/api/v1/auth/enchanted-link/poll", h.PollEnchantedLink)
app.Post("/api/v1/auth/magic-link/verify", h.VerifyMagicLink)
t.Setenv("USERFRONT_URL", "http://userfront.test")
// 1. Init Enchanted Link (Email)
body, _ := json.Marshal(map[string]string{
"loginId": "user@example.com",
"method": "email",
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/enchanted-link/init", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var initResp map[string]interface{}
json.NewDecoder(resp.Body).Decode(&initResp)
pendingRef := initResp["pendingRef"].(string)
assert.NotEmpty(t, pendingRef)
// Find the token key "enchanted_token:..." in mock redis
var token string
for k := range redis.data {
if len(k) > 16 && k[:16] == "enchanted_token:" {
token = k[16:]
break
}
}
assert.NotEmpty(t, token)
// 2. Verify Magic Link
verifyBody, _ := json.Marshal(map[string]interface{}{
"token": token,
"verifyOnly": true,
})
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/magic-link/verify", bytes.NewReader(verifyBody))
req.Header.Set("Content-Type", "application/json")
resp, _ = app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
// 3. Poll (Success)
pollBody, _ := json.Marshal(map[string]string{"pendingRef": pendingRef})
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/enchanted-link/poll", bytes.NewReader(pollBody))
req.Header.Set("Content-Type", "application/json")
resp, _ = app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var pollResp map[string]interface{}
json.NewDecoder(resp.Body).Decode(&pollResp)
assert.Equal(t, "ok", pollResp["status"])
assert.Equal(t, "valid-jwt", pollResp["sessionJwt"])
}
func TestEnchantedLinkFlow_Sms_Success(t *testing.T) {
redis := &mockRedisRepo{data: make(map[string]string)}
idp := &mockIdpProvider{
userExists: true,
initiateLinkErr: domain.ErrNotSupported,
}
h := &AuthHandler{
RedisService: redis,
IdpProvider: idp,
SmsService: &mockSmsService{},
}
app := fiber.New()
app.Post("/api/v1/auth/enchanted-link/init", h.InitEnchantedLink)
// 1. Init Enchanted Link (SMS)
body, _ := json.Marshal(map[string]string{
"loginId": "010-1234-5678",
"method": "sms",
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/enchanted-link/init", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var initResp map[string]interface{}
json.NewDecoder(resp.Body).Decode(&initResp)
assert.NotEmpty(t, initResp["userCode"])
}

View File

@@ -0,0 +1,144 @@
package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
)
// --- Helper ---
func newLinkedRpTestApp(h *AuthHandler) *fiber.App {
app := fiber.New()
app.Get("/api/v1/user/rp/linked", h.ListLinkedRps)
return app
}
// --- Tests ---
func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch r.URL.Host {
case "kratos.test":
if r.URL.Path == "/sessions/whoami" {
if r.Header.Get("X-Session-Token") == "" && r.Header.Get("Cookie") == "" {
return httpResponse(r, http.StatusUnauthorized, "unauthorized"), nil
}
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"identity": map[string]interface{}{
"id": "user-123",
"traits": map[string]interface{}{
"email": "user@test.com",
},
},
}), nil
}
case "hydra.test":
if r.URL.Path == "/oauth2/auth/sessions/consent" {
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
{
"client": map[string]interface{}{
"client_id": "client-active",
"client_name": "Active App",
},
"granted_scope": []string{"openid"},
"handled_at": time.Now().Format(time.RFC3339),
},
}), nil
}
if r.URL.Path == "/admin/clients/client-audit" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"client_id": "client-audit",
"client_name": "Audit App",
}), nil
}
if r.URL.Path == "/admin/clients/client-consent" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"client_id": "client-consent",
"client_name": "Consent App",
}), nil
}
}
return httpResponse(r, http.StatusNotFound, "not found"), nil
})
client := &http.Client{Transport: transport}
origDefault := http.DefaultClient
http.DefaultClient = client
defer func() {
http.DefaultClient = origDefault
}()
auditRepo := &mockAuditRepo{
logs: []domain.AuditLog{
{
UserID: "user-123",
EventType: "consent.granted",
Timestamp: time.Now().Add(-10 * time.Hour),
Details: `{"client_id":"client-audit", "scopes":["audit_scope"]}`,
},
},
}
consentRepo := &mockConsentRepo{
consents: []domain.ClientConsent{
{
Subject: "user-123",
ClientID: "client-consent",
GrantedScopes: []string{"consent_scope"},
UpdatedAt: time.Now().Add(-2 * time.Hour),
},
},
}
h := &AuthHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: client,
},
AuditRepo: auditRepo,
ConsentRepo: consentRepo,
KratosAdmin: &service.KratosAdminService{},
}
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
t.Setenv("KRATOS_ADMIN_URL", "http://kratos.test")
app := newLinkedRpTestApp(h)
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/rp/linked", nil)
req.Header.Set("Cookie", "ory_kratos_session=valid")
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var res struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Scopes []string `json:"scopes"`
} `json:"items"`
}
json.NewDecoder(resp.Body).Decode(&res)
assert.Equal(t, 3, len(res.Items))
statusMap := make(map[string]string)
for _, item := range res.Items {
statusMap[item.ID] = item.Status
}
assert.Equal(t, "active", statusMap["client-active"])
assert.Equal(t, "inactive", statusMap["client-consent"])
assert.Equal(t, "inactive", statusMap["client-audit"])
}

View File

@@ -33,7 +33,7 @@ func TestAcceptOidcLoginRequest_CookieOnly(t *testing.T) {
if r.Header.Get("Cookie") == "" { if r.Header.Get("Cookie") == "" {
return httpResponse(r, http.StatusUnauthorized, "missing cookie"), nil return httpResponse(r, http.StatusUnauthorized, "missing cookie"), nil
} }
return httpJSON(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"identity": map[string]interface{}{ "identity": map[string]interface{}{
"id": "kratos-123", "id": "kratos-123",
"traits": map[string]interface{}{}, "traits": map[string]interface{}{},
@@ -117,7 +117,7 @@ func TestAcceptOidcLoginRequest_TokenFallbackToCookie(t *testing.T) {
if r.Header.Get("Cookie") == "" { if r.Header.Get("Cookie") == "" {
return httpResponse(r, http.StatusUnauthorized, "missing cookie"), nil return httpResponse(r, http.StatusUnauthorized, "missing cookie"), nil
} }
return httpJSON(r, http.StatusOK, map[string]interface{}{ return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"identity": map[string]interface{}{ "identity": map[string]interface{}{
"id": "kratos-456", "id": "kratos-456",
"traits": map[string]interface{}{}, "traits": map[string]interface{}{},
@@ -176,25 +176,3 @@ func TestAcceptOidcLoginRequest_TokenFallbackToCookie(t *testing.T) {
t.Fatalf("unexpected subject: %v", gotSubject) t.Fatalf("unexpected subject: %v", gotSubject)
} }
} }
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func httpResponse(req *http.Request, status int, body string) *http.Response {
return &http.Response{
StatusCode: status,
Header: make(http.Header),
Body: io.NopCloser(bytes.NewBufferString(body)),
Request: req,
}
}
func httpJSON(req *http.Request, status int, payload map[string]interface{}) *http.Response {
data, _ := json.Marshal(payload)
resp := httpResponse(req, status, string(data))
resp.Header.Set("Content-Type", "application/json")
return resp
}

View File

@@ -0,0 +1,110 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
)
func TestHandleKratosCourierRelay_Email(t *testing.T) {
redis := &mockRedisRepo{data: make(map[string]string)}
emailSvc := &mockEmailService{}
h := &AuthHandler{
RedisService: redis,
EmailService: emailSvc,
}
app := fiber.New()
app.Post("/api/v1/auth/kratos/courier", h.HandleKratosCourierRelay)
// Simulate Kratos Courier Request for Email
reqBody := map[string]interface{}{
"recipient": "user@example.com",
"template_type": "verification_code",
"template_data": map[string]interface{}{
"verification_code": "123456",
},
"subject": "Verify your email",
"body": "Your code is 123456",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/kratos/courier", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
func TestVerifySignupCode_Success(t *testing.T) {
redis := &mockRedisRepo{data: make(map[string]string)}
h := &AuthHandler{
RedisService: redis,
}
app := fiber.New()
app.Post("/api/v1/auth/signup/verify", h.VerifySignupCode)
// Mock stored code in redis
// signup:email:user@test.com -> {"code":"654321", "verified":false, "expires_at":...}
state := map[string]interface{}{
"code": "654321",
"verified": false,
"expires_at": 9999999999, // far future
}
stateJSON, _ := json.Marshal(state)
redis.data["signup:email:user@test.com"] = string(stateJSON)
// Verify Code
verifyBody := map[string]string{
"type": "email",
"target": "user@test.com",
"code": "654321",
}
body, _ := json.Marshal(verifyBody)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/signup/verify", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var res map[string]interface{}
json.NewDecoder(resp.Body).Decode(&res)
assert.True(t, res["success"].(bool))
// Check redis state updated to verified
val, _ := redis.Get("signup:email:user@test.com")
var updatedState map[string]interface{}
json.Unmarshal([]byte(val), &updatedState)
assert.True(t, updatedState["verified"].(bool))
}
func TestVerifySignupCode_Invalid(t *testing.T) {
redis := &mockRedisRepo{data: make(map[string]string)}
h := &AuthHandler{
RedisService: redis,
}
app := fiber.New()
app.Post("/api/v1/auth/signup/verify", h.VerifySignupCode)
stateJSON, _ := json.Marshal(map[string]interface{}{
"code": "111111",
"expires_at": 9999999999,
})
redis.data["signup:email:user@test.com"] = string(stateJSON)
verifyBody := map[string]string{
"type": "email",
"target": "user@test.com",
"code": "000000", // wrong code
}
body, _ := json.Marshal(verifyBody)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/signup/verify", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}

View File

@@ -0,0 +1,205 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
)
// --- Mock Redis ---
type mockRedisRepo struct {
data map[string]string
}
func (m *mockRedisRepo) Set(key, value string, ttl time.Duration) error {
if m.data == nil {
m.data = make(map[string]string)
}
m.data[key] = value
return nil
}
func (m *mockRedisRepo) Get(key string) (string, error) {
// Bypass rate limiting for tests
if strings.HasPrefix(key, "poll_meta:") {
return "", nil
}
return m.data[key], nil
}
func (m *mockRedisRepo) Delete(key string) error {
delete(m.data, key)
return nil
}
func (m *mockRedisRepo) StoreVerificationCode(phone, code string) error {
return m.Set("sms:"+phone, code, time.Minute)
}
func (m *mockRedisRepo) GetVerificationCode(phone string) (string, error) {
return m.Get("sms:" + phone)
}
func (m *mockRedisRepo) DeleteVerificationCode(phone string) error {
return m.Delete("sms:" + phone)
}
// --- Tests ---
func TestQRLoginFlow_Success(t *testing.T) {
redis := &mockRedisRepo{data: make(map[string]string)}
h := &AuthHandler{
RedisService: redis,
}
app := fiber.New()
app.Post("/api/v1/auth/qr/init", h.InitQRLogin)
app.Post("/api/v1/auth/qr/poll", h.PollQRLogin)
// 1. Init QR Login
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/qr/init", nil)
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var initResp map[string]interface{}
json.NewDecoder(resp.Body).Decode(&initResp)
pendingRef := initResp["pendingRef"].(string)
// 2. Poll (Pending)
body, _ := json.Marshal(map[string]string{"pendingRef": pendingRef})
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/qr/poll", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ = app.Test(req, -1)
// Expect authorization_pending (400)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
var pollResp map[string]interface{}
json.NewDecoder(resp.Body).Decode(&pollResp)
assert.Equal(t, "authorization_pending", pollResp["error"])
// 3. Mock Approval
sessionData, _ := json.Marshal(map[string]string{
"status": "success",
"jwt": "mock-session-jwt",
})
redis.data["enchanted_session:"+pendingRef] = string(sessionData)
// 4. Poll (Success)
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/qr/poll", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ = app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var successResp map[string]interface{}
json.NewDecoder(resp.Body).Decode(&successResp)
assert.Equal(t, "ok", successResp["status"])
assert.Equal(t, "mock-session-jwt", successResp["sessionJwt"])
}
func TestScanQRLogin_Success(t *testing.T) {
redis := &mockRedisRepo{data: make(map[string]string)}
idp := &mockIdpProvider{userExists: true}
h := &AuthHandler{
RedisService: redis,
IdpProvider: idp,
}
app := fiber.New()
app.Post("/api/v1/auth/qr/approve", h.ScanQRLogin)
pendingRef := "test-ref"
redis.data["enchanted_session:"+pendingRef] = `{"status":"pending"}`
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/sessions/whoami" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"identity": map[string]interface{}{
"id": "user-123",
"traits": map[string]interface{}{
"email": "user@example.com",
},
},
}), nil
}
return httpResponse(r, http.StatusNotFound, "not found"), nil
})
origDefault := http.DefaultClient
http.DefaultClient = &http.Client{Transport: transport}
defer func() { http.DefaultClient = origDefault }()
body, _ := json.Marshal(map[string]string{
"pendingRef": pendingRef,
"token": "valid-token",
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/qr/approve", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
func TestResolveConsentSubjects_TokenAndCookie(t *testing.T) {
h := &AuthHandler{}
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Header.Get("X-Session-Token") == "token-123" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"identity": map[string]interface{}{
"id": "user-token",
"traits": map[string]interface{}{
"email": "token@test.com",
},
},
}), nil
}
if r.Header.Get("Cookie") == "ory_kratos_session=cookie-123" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"identity": map[string]interface{}{
"id": "user-cookie",
"traits": map[string]interface{}{
"email": "cookie@test.com",
"phone": "010-1234-5678",
},
},
}), nil
}
return httpResponse(r, http.StatusUnauthorized, "unauthorized"), nil
})
origDefault := http.DefaultClient
http.DefaultClient = &http.Client{Transport: transport}
defer func() { http.DefaultClient = origDefault }()
app := fiber.New()
// Token case
app.Get("/test-token", func(c *fiber.Ctx) error {
subjects, err := h.resolveConsentSubjects(c)
assert.NoError(t, err)
assert.Contains(t, subjects, "user-token")
return c.SendStatus(200)
})
req := httptest.NewRequest("GET", "/test-token", nil)
req.Header.Set("Authorization", "Bearer token-123")
app.Test(req, -1)
// Cookie case
app.Get("/test-cookie", func(c *fiber.Ctx) error {
subjects, err := h.resolveConsentSubjects(c)
assert.NoError(t, err)
assert.Contains(t, subjects, "user-cookie")
return c.SendStatus(200)
})
req = httptest.NewRequest("GET", "/test-cookie", nil)
req.Header.Set("Cookie", "ory_kratos_session=cookie-123")
app.Test(req, -1)
}

View File

@@ -0,0 +1,179 @@
package handler
import (
"baron-sso-backend/internal/domain"
"bytes"
"context"
"encoding/json"
"io"
"net/http"
)
// --- Mock IDP Provider ---
type mockIdpProvider struct {
userExists bool
name string
signInInfo *domain.AuthInfo
issueSession *domain.AuthInfo
verifyCodeInfo *domain.AuthInfo
err error
initiateLinkErr error
}
func (m *mockIdpProvider) Name() string {
if m.name != "" {
return m.name
}
return "mock-idp"
}
func (m *mockIdpProvider) GetMetadata() (*domain.IDPMetadata, error) { return nil, m.err }
func (m *mockIdpProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) {
return "mock-user-id", m.err
}
func (m *mockIdpProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) {
return m.signInInfo, m.err
}
func (m *mockIdpProvider) UserExists(loginID string) (bool, error) { return m.userExists, m.err }
func (m *mockIdpProvider) IssueSession(loginID string) (*domain.AuthInfo, error) {
if m.issueSession != nil {
return m.issueSession, m.err
}
return &domain.AuthInfo{
SessionToken: &domain.Token{JWT: "valid-jwt", SessionID: "valid-sid"},
}, m.err
}
func (m *mockIdpProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) {
if m.initiateLinkErr != nil {
return nil, m.initiateLinkErr
}
return &domain.LinkLoginInit{FlowID: "mock-flow-id", Mode: "code"}, m.err
}
func (m *mockIdpProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) {
return m.verifyCodeInfo, m.err
}
func (m *mockIdpProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) { return nil, m.err }
func (m *mockIdpProvider) InitiatePasswordReset(loginID, redirectUrl string) error { return m.err }
func (m *mockIdpProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) {
return nil, m.err
}
func (m *mockIdpProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
return m.err
}
// --- Mock Audit Repository ---
type mockAuditRepo struct {
logs []domain.AuditLog
}
func (m *mockAuditRepo) Create(log *domain.AuditLog) error {
m.logs = append(m.logs, *log)
return nil
}
func (m *mockAuditRepo) FindPage(ctx context.Context, limit int, cursor *domain.AuditCursor) ([]domain.AuditLog, error) {
return m.logs, nil
}
func (m *mockAuditRepo) FindByUserAndEvents(ctx context.Context, userID string, eventTypes []string, limit int) ([]domain.AuditLog, error) {
var results []domain.AuditLog
for _, log := range m.logs {
if log.UserID == userID {
for _, et := range eventTypes {
if log.EventType == et {
results = append(results, log)
break
}
}
}
}
return results, nil
}
func (m *mockAuditRepo) Ping(ctx context.Context) error { return nil }
// --- Mock Consent Repository ---
type mockConsentRepo struct {
consents []domain.ClientConsent
}
func (m *mockConsentRepo) Upsert(ctx context.Context, consent *domain.ClientConsent) error {
m.consents = append(m.consents, *consent)
return nil
}
func (m *mockConsentRepo) ListBySubject(ctx context.Context, subject string) ([]domain.ClientConsent, error) {
var results []domain.ClientConsent
for _, c := range m.consents {
if c.Subject == subject {
results = append(results, c)
}
}
return results, nil
}
func (m *mockConsentRepo) Delete(ctx context.Context, clientID, subject string) error { return nil }
func (m *mockConsentRepo) List(ctx context.Context, clientID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error) {
return nil, 0, nil
}
func (m *mockConsentRepo) ListByTenant(ctx context.Context, clientID, tenantID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error) {
return nil, 0, nil
}
// --- Mock Secret Repository ---
type mockSecretRepo struct {
secrets map[string]string
}
func (m *mockSecretRepo) Upsert(ctx context.Context, clientID, secret string) error {
if m.secrets == nil {
m.secrets = make(map[string]string)
}
m.secrets[clientID] = secret
return nil
}
func (m *mockSecretRepo) GetByID(ctx context.Context, clientID string) (string, error) {
return m.secrets[clientID], nil
}
func (m *mockSecretRepo) Delete(ctx context.Context, clientID string) error {
delete(m.secrets, clientID)
return nil
}
// --- HTTP Mock Helpers ---
type roundTripFunc func(req *http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func httpResponse(r *http.Request, code int, body string) *http.Response {
return &http.Response{
StatusCode: code,
Header: make(http.Header),
Body: io.NopCloser(bytes.NewBufferString(body)),
Request: r,
}
}
func httpJSONAny(r *http.Request, code int, data any) *http.Response {
body, _ := json.Marshal(data)
return &http.Response{
StatusCode: code,
Header: http.Header{
"Content-Type": []string{"application/json"},
},
Body: io.NopCloser(bytes.NewBuffer(body)),
Request: r,
}
}

View File

@@ -18,13 +18,13 @@ import (
type DevHandler struct { type DevHandler struct {
Hydra *service.HydraAdminService Hydra *service.HydraAdminService
Redis *service.RedisService Redis domain.RedisRepository
SecretRepo domain.ClientSecretRepository SecretRepo domain.ClientSecretRepository
KratosAdmin *service.KratosAdminService KratosAdmin *service.KratosAdminService
ConsentRepo repository.ClientConsentRepository ConsentRepo repository.ClientConsentRepository
} }
func NewDevHandler(redis *service.RedisService, secretRepo domain.ClientSecretRepository, consentRepo repository.ClientConsentRepository) *DevHandler { func NewDevHandler(redis domain.RedisRepository, secretRepo domain.ClientSecretRepository, consentRepo repository.ClientConsentRepository) *DevHandler {
return &DevHandler{ return &DevHandler{
Hydra: service.NewHydraAdminService(), Hydra: service.NewHydraAdminService(),
Redis: redis, Redis: redis,

View File

@@ -0,0 +1,142 @@
package handler
import (
"baron-sso-backend/internal/service"
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
)
func TestListClients_Success(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/clients" {
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
{"client_id": "client-1", "client_name": "App One", "metadata": map[string]interface{}{"status": "active"}},
{"client_id": "client-2", "client_name": "App Two", "metadata": map[string]interface{}{"status": "inactive"}},
}), nil
}
return httpJSONAny(r, http.StatusNotFound, map[string]string{"error": "not found"}), nil
})
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
}
app := fiber.New()
app.Get("/api/v1/dev/clients", h.ListClients)
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var res struct {
Items []clientSummary `json:"items"`
}
json.NewDecoder(resp.Body).Decode(&res)
assert.Equal(t, 2, len(res.Items))
assert.Equal(t, "client-1", res.Items[0].ID)
assert.Equal(t, "App One", res.Items[0].Name)
}
func TestGetClient_Success(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/clients/client-123" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"client_id": "client-123",
"client_name": "Test App",
"metadata": map[string]interface{}{"status": "active"},
}), nil
}
return httpJSONAny(r, http.StatusNotFound, map[string]string{"error": "not found"}), nil
})
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
PublicURL: "http://hydra-public.test", // PublicURL 추가
HTTPClient: &http.Client{Transport: transport},
},
}
app := fiber.New()
app.Get("/api/v1/dev/clients/:id", h.GetClient)
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-123", nil)
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var res clientDetailResponse
json.NewDecoder(resp.Body).Decode(&res)
assert.Equal(t, "client-123", res.Client.ID)
assert.Equal(t, "Test App", res.Client.Name)
assert.Equal(t, "http://hydra-public.test/oauth2/auth", res.Endpoints.Authorization)
}
func TestGetClient_NotFound(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
return httpJSONAny(r, http.StatusNotFound, map[string]string{"error": "not found"}), nil
})
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
}
app := fiber.New()
app.Get("/api/v1/dev/clients/:id", h.GetClient)
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/non-existent", nil)
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
}
func TestCreateClient_Success(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
return httpJSONAny(r, http.StatusCreated, map[string]interface{}{
"client_id": "new-client-123",
"client_name": "New App",
"client_secret": "secret-123",
}), nil
}
return httpJSONAny(r, http.StatusInternalServerError, map[string]string{"error": "hydra error"}), nil
})
secretRepo := &mockSecretRepo{secrets: make(map[string]string)}
redisRepo := &mockRedisRepo{data: make(map[string]string)}
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
SecretRepo: secretRepo,
Redis: redisRepo,
}
app := fiber.New()
app.Post("/api/v1/dev/clients", h.CreateClient)
body, _ := json.Marshal(map[string]interface{}{
"client_name": "New App",
"type": "confidential",
"redirectUris": []string{"http://localhost/cb"},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusCreated, resp.StatusCode)
secret, _ := secretRepo.GetByID(nil, "new-client-123")
assert.Equal(t, "secret-123", secret)
}

View File

@@ -108,6 +108,7 @@ func (h *FederationHandler) CreateIdpConfigForClient(c *fiber.Ctx) error {
return c.Status(fiber.StatusCreated).JSON(req) return c.Status(fiber.StatusCreated).JSON(req)
} }
// --- Deprecated Tenant-based IdP Config Methods --- // --- Deprecated Tenant-based IdP Config Methods ---
// ListIdpConfigsForTenant handles listing all IdP configurations for a tenant. // ListIdpConfigsForTenant handles listing all IdP configurations for a tenant.

View File

@@ -12,6 +12,7 @@ type ClientConsentRepository interface {
Delete(ctx context.Context, subject, clientID string) error Delete(ctx context.Context, subject, clientID string) error
List(ctx context.Context, clientID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error) List(ctx context.Context, clientID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error)
ListByTenant(ctx context.Context, clientID, tenantID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error) ListByTenant(ctx context.Context, clientID, tenantID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error)
ListBySubject(ctx context.Context, subject string) ([]domain.ClientConsent, error)
} }
type clientConsentRepo struct { type clientConsentRepo struct {
@@ -90,3 +91,12 @@ func (r *clientConsentRepo) ListByTenant(ctx context.Context, clientID, tenantID
return consents, total, err return consents, total, err
} }
func (r *clientConsentRepo) ListBySubject(ctx context.Context, subject string) ([]domain.ClientConsent, error) {
var consents []domain.ClientConsent
err := r.db.WithContext(ctx).
Where("subject = ?", subject).
Order("updated_at DESC").
Find(&consents).Error
return consents, err
}

View File

@@ -8,7 +8,6 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/coreos/go-oidc/v3/oidc" "github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2" "golang.org/x/oauth2"
) )

View File

@@ -163,22 +163,20 @@ func (s *KratosAdminService) UpdateIdentity(ctx context.Context, identityID stri
} }
func (s *KratosAdminService) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error { func (s *KratosAdminService) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error {
payload := map[string]interface{}{ patchOps := []map[string]interface{}{
"credentials": map[string]interface{}{ {
"password": map[string]interface{}{ "op": "add",
"config": map[string]string{ "path": "/credentials/password/config/password",
"password": newPassword, "value": newPassword,
},
},
}, },
} }
body, _ := json.Marshal(payload) body, _ := json.Marshal(patchOps)
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID) endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, endpoint, bytes.NewReader(body)) req, err := http.NewRequestWithContext(ctx, http.MethodPatch, endpoint, bytes.NewReader(body))
if err != nil { if err != nil {
return err return err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json-patch+json")
resp, err := s.httpClient().Do(req) resp, err := s.httpClient().Do(req)
if err != nil { if err != nil {

View File

@@ -697,22 +697,20 @@ func (o *OryProvider) UpdateUserPassword(loginID, newPassword string, r *http.Re
return fmt.Errorf("ory provider: identity not found for loginID=%s", loginID) return fmt.Errorf("ory provider: identity not found for loginID=%s", loginID)
} }
payload := map[string]interface{}{ patchOps := []map[string]interface{}{
"credentials": map[string]interface{}{ {
"password": map[string]interface{}{ "op": "add",
"config": map[string]string{ "path": "/credentials/password/config/password",
"password": newPassword, "value": newPassword,
},
},
}, },
} }
body, _ := json.Marshal(payload) body, _ := json.Marshal(patchOps)
req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body)) req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body))
if err != nil { if err != nil {
return fmt.Errorf("ory provider: build request failed: %w", err) return fmt.Errorf("ory provider: build request failed: %w", err)
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json-patch+json")
resp, err := o.httpClient().Do(req) resp, err := o.httpClient().Do(req)
if err != nil { if err != nil {

View File

@@ -27,7 +27,28 @@ export function CopyButton({
const copyToClipboard = async () => { const copyToClipboard = async () => {
try { try {
await navigator.clipboard.writeText(value); if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(value);
} else {
// Fallback for non-secure contexts (HTTP) or missing navigator.clipboard
const textArea = document.createElement("textarea");
textArea.value = value;
textArea.style.position = "fixed";
textArea.style.left = "-9999px";
textArea.style.top = "0";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
if (!successful) throw new Error('execCommand copy failed');
} catch (err) {
console.error('Fallback: Oops, unable to copy', err);
throw err;
} finally {
document.body.removeChild(textArea);
}
}
setHasCopied(true); setHasCopied(true);
if (onCopy) onCopy(); if (onCopy) onCopy();
} catch (err) { } catch (err) {

View File

@@ -31,7 +31,7 @@ services:
- KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS='["${KRATOS_UI_URL:-http://localhost:5000}","${USERFRONT_URL:-http://localhost:5000}"]' - KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS='["${KRATOS_UI_URL:-http://localhost:5000}","${USERFRONT_URL:-http://localhost:5000}"]'
volumes: volumes:
- ./docker/ory/kratos:/etc/config/kratos - ./docker/ory/kratos:/etc/config/kratos
command: migrate sql -c /etc/config/kratos/kratos.yml --yes command: migrate sql up -e -c /etc/config/kratos/kratos.yml --yes
depends_on: depends_on:
postgres_ory: postgres_ory:
condition: service_healthy condition: service_healthy
@@ -62,7 +62,7 @@ services:
image: oryd/hydra:${HYDRA_VERSION:-v25.4.0} image: oryd/hydra:${HYDRA_VERSION:-v25.4.0}
environment: environment:
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20 - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20
command: migrate sql -e --yes command: migrate sql up -e --yes
depends_on: depends_on:
postgres_ory: postgres_ory:
condition: service_healthy condition: service_healthy
@@ -74,10 +74,10 @@ services:
container_name: ory_hydra container_name: ory_hydra
environment: environment:
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20 - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20
- URLS_SELF_ISSUER="${USERFRONT_URL:-http://localhost:5000}/oidc" - URLS_SELF_ISSUER=${USERFRONT_URL:-http://localhost:5000}/oidc
- URLS_LOGIN="${USERFRONT_URL:-http://localhost:5000}/login" - URLS_LOGIN=${USERFRONT_URL:-http://localhost:5000}/login
- URLS_CONSENT="${USERFRONT_URL:-http://localhost:5000}/consent" - URLS_CONSENT=${USERFRONT_URL:-http://localhost:5000}/consent
- SECRETS_SYSTEM="${ORY_POSTGRES_PASSWORD}" - SECRETS_SYSTEM=${ORY_POSTGRES_PASSWORD}
volumes: volumes:
- ./docker/ory/hydra:/etc/config/hydra - ./docker/ory/hydra:/etc/config/hydra
command: serve -c /etc/config/hydra/hydra.yml all --dev command: serve -c /etc/config/hydra/hydra.yml all --dev
@@ -88,6 +88,31 @@ services:
- ory-net - ory-net
- hydranet - hydranet
# [수정됨] Oathkeeper 서비스 추가 (Backend 연결 문제 해결)
oathkeeper:
image: oryd/oathkeeper:${OATHKEEPER_VERSION:-v0.40.6}
container_name: oathkeeper
restart: unless-stopped
depends_on:
kratos:
condition: service_started
environment:
- LOG_LEVEL=debug
command: serve proxy --config /etc/config/oathkeeper/oathkeeper.yml
volumes:
- ./docker/ory/oathkeeper:/etc/config/oathkeeper
networks:
- ory-net
- baron_net # Backend가 통신하기 위해 필수
- public_net
ports:
- "4455:4455" # Proxy
- "4456:4456" # API (Backend 헬스체크용)
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4456/health/ready"]
interval: 5s
timeout: 5s
retries: 5
volumes: volumes:
ory_postgres_data: ory_postgres_data:
@@ -104,3 +129,7 @@ networks:
public_net: public_net:
external: true external: true
name: public_net name: public_net
# [수정됨] Baron Net 추가 정의 (Oathkeeper 연결용)
baron_net:
external: true
name: baron_net

View File

@@ -2,41 +2,32 @@ name: baron-sso-staging
services: services:
backend: backend:
image: ${BACKEND_IMAGE_NAME}:${IMAGE_TAG} image: ${BACKEND_IMAGE_NAME}:${IMAGE_TAG}
container_name: baron_backend container_name: baron_backend
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- .env - .env
environment: environment:
- APP_ENV=stage - DB_HOST=baron_postgres
- GO_ENV=stage - IDP_PROVIDER=ory
- COOKIE_SECRET="${COOKIE_SECRET}" - OATHKEEPER_API_URL=http://oathkeeper:4456
- DB_HOST=postgres - PROFILE_CACHE_TTL="${PROFILE_CACHE_TTL:-30m}"
- CLICKHOUSE_HOST=clickhouse ports:
- CLICKHOUSE_PORT="${CLICKHOUSE_PORT_NATIVE:-9000}" - "${BACKEND_PORT:-3000}:3000"
- CLICKHOUSE_USER="${CLICKHOUSE_USER:-baron}" depends_on:
- CLICKHOUSE_PASSWORD="${CLICKHOUSE_PASSWORD:-password}" oathkeeper:
- USERFRONT_URL="${USERFRONT_URL:-https://sso.hmac.kr}" condition: service_healthy
- REDIS_ADDR="${REDIS_ADDR:-redis:6389}" infra_check:
- IDP_PROVIDER=ory condition: service_started
- KRATOS_ADMIN_URL="${KRATOS_ADMIN_URL:-http://ory_kratos:4434}" networks:
- HYDRA_ADMIN_URL="${HYDRA_ADMIN_URL:-http://ory_hydra:4445}" - baron_net
- HYDRA_PUBLIC_URL="${HYDRA_PUBLIC_URL:-http://ory_hydra:4444}" - ory-net
- PROFILE_CACHE_TTL="${PROFILE_CACHE_TTL:-30m}" healthcheck:
ports: test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
- "${BACKEND_PORT:-3000}:3000" interval: 10s
depends_on: timeout: 5s
infra_check: retries: 10
condition: service_started start_period: 60s
networks:
- baron_net
- ory-net
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 10s
adminfront: adminfront:
image: ${ADMINFRONT_IMAGE_NAME}:${IMAGE_TAG} image: ${ADMINFRONT_IMAGE_NAME}:${IMAGE_TAG}
@@ -84,8 +75,8 @@ services:
condition: service_healthy condition: service_healthy
command: > command: >
/bin/sh -c "mkdir -p /usr/share/nginx/html/assets && /bin/sh -c "mkdir -p /usr/share/nginx/html/assets &&
echo \"BACKEND_URL=$${BACKEND_URL}\" >> /usr/share/nginx/html/assets/.env && echo \"BACKEND_URL=${BACKEND_URL}\" >> /usr/share/nginx/html/assets/.env &&
echo \"USERFRONT_URL=$${USERFRONT_URL}\" >> /usr/share/nginx/html/assets/.env && echo \"USERFRONT_URL=${USERFRONT_URL}\" >> /usr/share/nginx/html/assets/.env &&
echo \"APP_ENV=stage\" >> /usr/share/nginx/html/assets/.env && echo \"APP_ENV=stage\" >> /usr/share/nginx/html/assets/.env &&
cp /usr/share/nginx/html/assets/.env /usr/share/nginx/html/.env && cp /usr/share/nginx/html/assets/.env /usr/share/nginx/html/.env &&
nginx -g 'daemon off;'" nginx -g 'daemon off;'"
@@ -93,8 +84,8 @@ services:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5000/"] test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5000/"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 3 retries: 5
start_period: 10s start_period: 30s
infra_check: infra_check:
image: alpine image: alpine
@@ -111,4 +102,4 @@ networks:
name: ory-net name: ory-net
public_net: public_net:
external: true external: true
name: public_net name: public_net

View File

@@ -45,8 +45,6 @@ Root: `/home/lectom/.gemini/antigravity/scratch/baron_sso`
## Reference Analysis (Descope Sample App) ## Reference Analysis (Descope Sample App)
- **Source**: `descope-sample-apps/flutter_sample_app_auth_func` - **Source**: `descope-sample-apps/flutter_sample_app_auth_func`
- **Findings**: - **Findings**:
- **Setup**: Uses `.env` for `DESCOPE_PROJECT_ID`.
- **Initialization**: `Descope.projectId = ...` and `Descope.sessionManager.loadSession()` in `main.dart`.
- **Auth Check**: Checks `Descope.sessionManager.session?.refreshToken.isExpired`. - **Auth Check**: Checks `Descope.sessionManager.session?.refreshToken.isExpired`.
- **Note**: Sample focuses on OAuth/OTP. Baron SSO requires **Enchanted Link**, which will use `Descope.auth.enchantedLink.signUpOrIn(...)` (inference based on SDK capability). - **Note**: Sample focuses on OAuth/OTP. Baron SSO requires **Enchanted Link**, which will use `Descope.auth.enchantedLink.signUpOrIn(...)` (inference based on SDK capability).
- **Architecture**: Simple Provider/State management recommended (Riverpod chosen for Baron SSO). - **Architecture**: Simple Provider/State management recommended (Riverpod chosen for Baron SSO).

View File

@@ -0,0 +1,45 @@
# Frontend 기능과 백엔드 테스트 매핑 가이드
이 문서는 `devfront``userfront`의 Hydra 관련 기능이 백엔드의 어떤 API를 호출하고, 해당 API가 어떤 테스트 코드로 검증되는지 설명합니다. 모든 기능은 백엔드에 이미 구현되어 있으며, '테스트' 열은 해당 기능을 검증하는 자동화 테스트의 존재 여부를 나타냅니다.
## 1. `devfront` (개발자/관리자 포털)
`devfront`는 OAuth2 클라이언트(RP)를 생성하고 관리하는 데 사용됩니다.
| `devfront` 기능 | 백엔드 API | 검증 테스트 파일 | 테스트 상태 |
| :--- | :--- | :--- | :--- |
| **클라이언트 목록 조회** | `GET /api/v1/dev/clients` | `dev_handler_test.go` | `TestListClients_Success` |
| **클라이언트 생성** | `POST /api/v1/dev/clients` | `dev_handler_test.go` | `TestCreateClient_Success` |
| **클라이언트 상세 조회** | `GET /api/v1/dev/clients/:id` | `dev_handler_test.go` | `TestGetClient_Success`, `TestGetClient_NotFound` |
| **클라이언트 정보 수정** | `PUT /api/v1/dev/clients/:id` | - | (테스트 미작성) |
| **클라이언트 상태 변경** | `PATCH /api/v1/dev/clients/:id/status`| - | (테스트 미작성) |
| **클라이언트 삭제** | `DELETE /api/v1/dev/clients/:id` | - | (테스트 미작성) |
| **시크릿 재발급** | `POST /api/v1/dev/clients/:id/rotate-secret`| - | (테스트 미작성) |
| **동의한 사용자 목록 조회**| `GET /api/v1/dev/consents` | - | (테스트 미작성) |
| **사용자 동의 철회** | `DELETE /api/v1/dev/consents` | - | (테스트 미작성) |
*참고: `dev_handler.go` 내의 기능들은 백엔드에 구현되어 있으나, 이번 커버리지 90% 달성 목표(핵심 인증 로직 중심)에서 관리자 기능으로 분류되어 우선순위가 조정되었습니다.*
---
## 2. `userfront` (사용자 포털)
`userfront`는 최종 사용자가 애플리케이션(RP)의 정보 접근 요청을 승인하거나 거부하는 OIDC 동의 화면 및 연동 관리를 처리합니다.
### 2.1. OIDC 동의 (Consent) 및 연동 관리
| `userfront` 기능 | 백엔드 API | 검증 테스트 파일 | 테스트 상태 |
| :--- | :--- | :--- | :--- |
| **동의 정보 조회** | `GET /api/v1/auth/consent` | `auth_handler_consent_test.go` | `TestGetConsentRequest_Normal` |
| **동의 승인** | `POST /api/v1/auth/consent/accept` | `auth_handler_consent_test.go` | `TestAcceptConsentRequest_Normal` |
| **동의 거부** | `POST /api/v1/auth/consent/reject` | - | (테스트 미작성) |
| **연동된 앱 목록 조회** | `GET /api/v1/user/rp/linked` | `auth_handler_linked_test.go` | `TestListLinkedRps_PriorityAndAggregation` |
| **연동 해제 (Revoke)** | `DELETE /api/v1/user/rp/linked/:id`| `auth_handler_client_test.go` | `TestRevokeLinkedRp_Success` |
| **연동 이력 조회** | `GET /api/v1/user/rp/history` | `auth_handler_client_test.go` | `TestListRpHistory_Aggregation` |
### 2.2. 인증 플로우 (Login Flows)
| `userfront` 기능 | 백엔드 API | 검증 테스트 파일 | 테스트 상태 |
| :--- | :--- | :--- | :--- |
| **QR 로그인 초기화** | `POST /api/v1/auth/qr/init` | `auth_handler_qr_test.go` | `TestQRLoginFlow_Success` |
| **QR 로그인 승인 (Scan)** | `POST /api/v1/auth/qr/approve` | `auth_handler_qr_test.go` | `TestScanQRLogin_Success` |
| **매직 링크 초기화** | `POST /api/v1/auth/enchanted-link/init`| `auth_handler_link_test.go` | `TestEnchantedLinkFlow_Email_Success` |
| **매직 링크 검증** | `POST /api/v1/auth/magic-link/verify` | `auth_handler_link_test.go` | `TestEnchantedLinkFlow_Email_Success` |

144
docs/hydra_be_test_guide.md Normal file
View File

@@ -0,0 +1,144 @@
# Backend Hydra Test Guide
이 문서는 Baron SSO 백엔드 내에서 **Ory Hydra Admin API**와 연동되는 기능(`HydraAdminService`)을 테스트하는 방법과 커버리지 측정 방법을 설명합니다.
## 1. 테스트 개요
백엔드는 OAuth2 클라이언트 관리, 인증/동의(Consent) 요청 승인 등을 위해 Ory Hydra의 Admin API를 호출합니다.
본 테스트 가이드는 `httptest` 패키지와 Mocking을 활용하여 실제 Hydra 서버 없이 백엔드의 연동 로직을 빠르고 독립적으로 검증하는 방법을 다룹니다.
## 2. 테스트 환경 준비
테스트는 Go 언어의 표준 테스팅 프레임워크를 사용하므로 별도의 설치가 필요 없으나, 커버리지 확인을 위해 `backend/` 디렉토리에서 작업을 수행해야 합니다.
```bash
cd backend
```
## 3. 테스트 파일 목록 및 실행 방법
Hydra와 직접적으로 연관된 백엔드 로직 테스트는 `internal/handler``internal/service` 패키지에 집중되어 있습니다.
### 3.1. 핸들러 레벨 통합 테스트 (Handler Level)
사용자 요청(HTTP)부터 Hydra 연동까지의 전체 흐름을 검증합니다.
* **주요 파일:**
* `backend/internal/handler/auth_handler_consent_test.go`
* `backend/internal/handler/auth_handler_link_test.go`
* `backend/internal/handler/auth_handler_login_test.go`
* `backend/internal/handler/auth_handler_qr_test.go`
* `backend/internal/handler/auth_handler_client_test.go`
* **실행 (패키지 전체):**
```bash
cd backend
go test -v ./internal/handler/...
```
### 3.2. 서비스 레벨 단위 테스트 (Service Level)
백엔드 내부에서 Ory Hydra Admin API와 직접 통신하는 서비스의 단위 기능을 검증합니다.
* **주요 파일:** `backend/internal/service/hydra_admin_service_test.go`
* **실행:**
```bash
cd backend
go test -v ./internal/service -run TestHydraAdminService
```
### 3.3. Relying Party Service 테스트
`HydraAdminService`와 로컬 DB(RelyingParty) 간의 통합 및 롤백 로직을 검증합니다.
* **위치:** `backend/internal/service/relying_party_service_test.go`
* **실행:**
```bash
go test -v ./internal/service -run TestRelyingPartyService
```
### 3.4. 전체 테스트 실행 (권장)
모든 Hydra 관련 연동 테스트를 한 번에 실행하려면 다음 명령어를 사용합니다.
```bash
go test -v ./internal/service ./internal/handler
```
## 4. 테스트 커버리지 측정
`internal/handler` 패키지에 대한 커버리지를 측정하고 90% 임계값을 확인합니다.
```bash
# 1. 커버리지 측정 및 coverage.out 파일 생성
cd backend
go test -coverprofile=coverage.out ./internal/handler
# 2. 함수별 커버리지 확인 (CLI)
go tool cover -func=coverage.out
# 3. 상세 리포트 확인 (HTML)
go tool cover -html=coverage.out
```
## 5. 주요 테스트 항목 (Checklist)
| 분류 | 핸들러/메서드 | 테스트 내용 | 파일 위치 |
| :--- | :--- | :--- | :--- |
| **핸들러: 인증 흐름** | `GetConsentRequest` | Consent Challenge 검증 및 자동 승인(`skip=true`) 처리 | `auth_handler_consent_test.go` |
| | `AcceptConsentRequest` | 사용자가 동의한 Scope 기반으로 Consent 승인 | `auth_handler_consent_test.go` |
| | `PasswordLogin` | OIDC 로그인 성공 및 비활성 클라이언트 차단 검증 | `auth_handler_login_test.go` |
| | `AcceptOidcLoginRequest` | 쿠키/토큰 기반 OIDC 로그인 요청 승인 | `auth_handler_oidc_test.go` |
| | `Init/Poll/ScanQRLogin` | QR 코드 생성, 폴링, 승인으로 이어지는 전체 흐름 | `auth_handler_qr_test.go` |
| | `Init/Verify/PollEnchantedLink` | Magic Link 생성, 검증, 세션 발행으로 이어지는 전체 흐름 | `auth_handler_link_test.go`|
| | `HandleKratosCourierRelay` | Kratos의 OTP 발송 요청(Email/SMS) 수신 및 처리 | `auth_handler_otp_test.go` |
| **핸들러: 세션/RP 관리** | `ListLinkedRps` | Hydra 세션, 로컬 DB, Audit Log 3-way 병합 로직 | `auth_handler_linked_test.go` |
| | `RevokeLinkedRp` | 특정 RP(클라이언트) 연동 해제 및 세션 종료 | `auth_handler_client_test.go` |
| | `ListRpHistory` | Audit Log 기반의 RP 연동 이력 조회 | `auth_handler_client_test.go` |
| | `resolveConsentSubjects` | 토큰/쿠키에서 다중 사용자 식별자(Subject) 추출 | `auth_handler_qr_test.go` |
| **서비스: 클라이언트 관리** | `ListClients` | 클라이언트 목록 페이징 조회 | `hydra_admin_service_test.go` |
| | `GetClient` | 특정 클라이언트 상세 조회 (성공/실패) | `hydra_admin_service_test.go` |
| | `CreateClient` | 신규 클라이언트 생성 및 메타데이터 검증 | `hydra_admin_service_test.go` |
| | `UpdateClient` | 클라이언트 정보 수정 (PUT) | `hydra_admin_service_test.go` |
| | `PatchClientStatus` | 클라이언트 상태 변경 (JSON Patch) | `hydra_admin_service_test.go` |
| | `DeleteClient` | 클라이언트 삭제 | `hydra_admin_service_test.go` |
| **서비스: 인증/동의** | `GetConsentRequest` | Consent Challenge 검증 및 요청 정보 조회 | `hydra_admin_service_test.go` |
| | `AcceptConsentRequest` | 동의 승인 및 리다이렉트 URL 반환 | `hydra_admin_service_test.go` |
| | `RejectConsentRequest` | 동의 거부 처리 | `hydra_admin_service_test.go` |
| | `GetLoginRequest` | Login Challenge 검증 | `hydra_admin_service_test.go` |
| | `AcceptLoginRequest` | 로그인 승인 및 리다이렉트 URL 반환 | `hydra_admin_service_test.go` |
| | `RejectLoginRequest` | 로그인 거부 처리 | `hydra_admin_service_test.go` |
| **서비스: 세션 관리** | `ListConsentSessions` | 특정 사용자의 활성 세션 목록 조회 | `hydra_admin_service_test.go` |
| | `RevokeConsentSessions` | 특정 사용자/클라이언트의 세션 만료 처리 | `hydra_admin_service_test.go` |
| **서비스: 통합** | `Create` (RP) | Hydra 생성 -> DB 생성 -> Keto 권한 부여 | `relying_party_service_test.go` |
| | `Create` (Rollback) | DB 실패 시 Hydra 롤백(삭제) 검증 | `relying_party_service_test.go` |
## 6. 테스트 코드 작성 가이드
새로운 기능을 추가하거나 커버리지를 높일 때 다음 패턴을 참고하세요.
```go
func TestHydraAdminService_NewFeature(t *testing.T) {
// 1. Mock 핸들러 정의 (예상되는 요청 검증 및 가짜 응답 반환)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Assert: 요청 메서드, URL, 바디 검증
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
// Response: 가짜 응답 작성
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(expectedResponse)
})
// 2. 서비스 초기화 (Mock Client 주입)
svc := &HydraAdminService{
AdminURL: "http://hydra:4445",
HTTPClient: mockHydraClient(handler), // ory_service_test.go의 헬퍼 사용
}
// 3. 테스트 실행 및 검증
result, err := svc.NewFeature(context.Background(), args)
if err != nil {
t.Fatalf("failed: %v", err)
}
// Assert: 결과값 검증
}
```

View File

@@ -7,7 +7,7 @@ COPY . .
# Get dependencies and build for web # Get dependencies and build for web
RUN flutter pub get RUN flutter pub get
RUN touch .env RUN touch .env
RUN flutter build web --release --no-tree-shake-icons RUN flutter build web --release --no-tree-shake-icons --wasm
# Stage 2: Serve with Nginx # Stage 2: Serve with Nginx
FROM nginx:alpine FROM nginx:alpine

View File

@@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
@@ -33,12 +34,14 @@ class AuditService {
); );
if (response.statusCode >= 200 && response.statusCode < 300) { if (response.statusCode >= 200 && response.statusCode < 300) {
print("Audit log sent successfully"); debugPrint('Audit log sent successfully');
} else { } else {
print("Failed to send audit log: ${response.statusCode} ${response.body}"); debugPrint(
'Failed to send audit log: ${response.statusCode} ${response.body}',
);
} }
} catch (e) { } catch (e) {
print("Error sending audit log: $e"); debugPrint('Error sending audit log: $e');
} }
} }
} }

View File

@@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:userfront/i18n.dart';
import 'http_client.dart'; import 'http_client.dart';
import 'web_window.dart'; import 'web_window.dart';
import 'auth_token_store.dart'; import 'auth_token_store.dart';
@@ -13,7 +14,11 @@ class AuthProxyService {
return dotenv.env[key] ?? fallback; return dotenv.env[key] ?? fallback;
} }
static String get _baseUrl => _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr'); static String get _baseUrl {
final rawUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
// 배포 환경에서 $ 기호나 공백이 섞여 들어오는 경우를 방지하기 위해 정제합니다.
return rawUrl.replaceAll(r'$', '').trim().replaceAll(RegExp(r'/$'), '');
}
static bool get _isProd { static bool get _isProd {
final env = _envOrDefault('APP_ENV', 'dev').toLowerCase(); final env = _envOrDefault('APP_ENV', 'dev').toLowerCase();
return env == 'prod' || env == 'production'; return env == 'prod' || env == 'production';
@@ -26,13 +31,26 @@ class AuthProxyService {
return drySend == true; return drySend == true;
} }
static Exception _error(String key, String fallback, {String? detail}) {
return Exception(
tr(
key,
fallback: fallback,
params: detail != null ? {'error': detail} : null,
),
);
}
static Future<Map<String, dynamic>> fetchPasswordPolicy() async { static Future<Map<String, dynamic>> fetchPasswordPolicy() async {
final url = Uri.parse('$_baseUrl/api/v1/auth/password/policy'); final url = Uri.parse('$_baseUrl/api/v1/auth/password/policy');
final response = await http.get(url); final response = await http.get(url);
if (response.statusCode == 200) { if (response.statusCode == 200) {
return jsonDecode(response.body); return jsonDecode(response.body);
} else { } else {
throw Exception('Failed to fetch password policy'); throw _error(
'err.userfront.auth_proxy.password_policy_fetch',
'비밀번호 정책을 불러오지 못했습니다.',
);
} }
} }
@@ -48,7 +66,11 @@ class AuthProxyService {
if (response.statusCode == 200) { if (response.statusCode == 200) {
return jsonDecode(response.body); return jsonDecode(response.body);
} }
throw Exception('Failed to load profile: ${response.body}'); throw _error(
'err.userfront.auth_proxy.profile_load',
'프로필을 불러오지 못했습니다: {{error}}',
detail: response.body,
);
} finally { } finally {
client.close(); client.close();
} }
@@ -103,7 +125,11 @@ class AuthProxyService {
if (response.statusCode == 200) { if (response.statusCode == 200) {
return jsonDecode(response.body); return jsonDecode(response.body);
} else { } else {
throw Exception('Failed to init login: ${response.body}'); throw _error(
'err.userfront.auth_proxy.login_init',
'로그인 초기화에 실패했습니다: {{error}}',
detail: response.body,
);
} }
} }
@@ -124,7 +150,11 @@ class AuthProxyService {
if (response.statusCode == 400) { if (response.statusCode == 400) {
return jsonDecode(response.body); return jsonDecode(response.body);
} }
throw Exception('Polling failed: ${response.body}'); throw _error(
'err.userfront.auth_proxy.login_poll',
'로그인 상태 확인에 실패했습니다: {{error}}',
detail: response.body,
);
} }
static Future<Map<String, dynamic>> verifyMagicLink(String token, {bool verifyOnly = false}) async { static Future<Map<String, dynamic>> verifyMagicLink(String token, {bool verifyOnly = false}) async {
@@ -142,7 +172,11 @@ class AuthProxyService {
if (response.statusCode == 200) { if (response.statusCode == 200) {
return jsonDecode(response.body); return jsonDecode(response.body);
} else { } else {
throw Exception('Verification failed: ${response.body}'); throw _error(
'err.userfront.auth_proxy.verify_failed',
'검증에 실패했습니다: {{error}}',
detail: response.body,
);
} }
} }
@@ -172,7 +206,11 @@ class AuthProxyService {
if (response.statusCode == 200) { if (response.statusCode == 200) {
return jsonDecode(response.body); return jsonDecode(response.body);
} else { } else {
throw Exception('Verification failed: ${response.body}'); throw _error(
'err.userfront.auth_proxy.verify_failed',
'검증에 실패했습니다: {{error}}',
detail: response.body,
);
} }
} }
@@ -194,7 +232,11 @@ class AuthProxyService {
if (response.statusCode == 200) { if (response.statusCode == 200) {
return jsonDecode(response.body); return jsonDecode(response.body);
} else { } else {
throw Exception('Verification failed: ${response.body}'); throw _error(
'err.userfront.auth_proxy.verify_failed',
'검증에 실패했습니다: {{error}}',
detail: response.body,
);
} }
} }
@@ -221,7 +263,13 @@ class AuthProxyService {
return data; return data;
} else { } else {
final errorBody = jsonDecode(response.body); final errorBody = jsonDecode(response.body);
throw Exception(errorBody['error'] ?? 'Failed to login'); throw Exception(
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.login_failed',
fallback: '로그인에 실패했습니다.',
),
);
} }
} }
static Future<Map<String, dynamic>> getConsentInfo(String consentChallenge) async { static Future<Map<String, dynamic>> getConsentInfo(String consentChallenge) async {
@@ -235,7 +283,13 @@ class AuthProxyService {
return jsonDecode(response.body); return jsonDecode(response.body);
} else { } else {
final errorBody = jsonDecode(response.body); final errorBody = jsonDecode(response.body);
throw Exception(errorBody['error'] ?? 'Failed to get consent info'); throw Exception(
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.consent_fetch',
fallback: '동의 정보를 가져오지 못했습니다.',
),
);
} }
} }
@@ -258,7 +312,13 @@ class AuthProxyService {
return jsonDecode(response.body); return jsonDecode(response.body);
} else { } else {
final errorBody = jsonDecode(response.body); final errorBody = jsonDecode(response.body);
throw Exception(errorBody['error'] ?? 'Failed to accept consent'); throw Exception(
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.consent_accept',
fallback: '동의 처리에 실패했습니다.',
),
);
} }
} }
@@ -278,7 +338,13 @@ class AuthProxyService {
return jsonDecode(response.body); return jsonDecode(response.body);
} else { } else {
final errorBody = jsonDecode(response.body); final errorBody = jsonDecode(response.body);
throw Exception(errorBody['error'] ?? 'Failed to reject consent'); throw Exception(
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.consent_reject',
fallback: '동의 거부에 실패했습니다.',
),
);
} }
} }
@@ -307,7 +373,13 @@ class AuthProxyService {
return jsonDecode(response.body); return jsonDecode(response.body);
} else { } else {
final errorBody = jsonDecode(response.body); final errorBody = jsonDecode(response.body);
throw Exception(errorBody['error'] ?? 'Failed to accept OIDC login'); throw Exception(
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.oidc_accept',
fallback: 'OIDC 로그인 승인에 실패했습니다.',
),
);
} }
} finally { } finally {
client.close(); client.close();
@@ -330,7 +402,13 @@ class AuthProxyService {
return jsonDecode(response.body); return jsonDecode(response.body);
} else { } else {
final errorBody = jsonDecode(response.body); final errorBody = jsonDecode(response.body);
throw Exception(errorBody['error'] ?? 'Failed to initiate password reset'); throw Exception(
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.password_reset_init',
fallback: '비밀번호 재설정을 시작하지 못했습니다.',
),
);
} }
} }
@@ -357,7 +435,13 @@ class AuthProxyService {
return jsonDecode(response.body); return jsonDecode(response.body);
} else { } else {
final errorBody = jsonDecode(response.body); final errorBody = jsonDecode(response.body);
throw Exception(errorBody['error'] ?? 'Failed to complete password reset'); throw Exception(
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.password_reset_complete',
fallback: '비밀번호 재설정에 실패했습니다.',
),
);
} }
} }
@@ -373,7 +457,11 @@ class AuthProxyService {
); );
if (response.statusCode != 200) { if (response.statusCode != 200) {
throw Exception('Failed to send SMS: ${response.body}'); throw _error(
'err.userfront.auth_proxy.sms_send',
'SMS 전송에 실패했습니다: {{error}}',
detail: response.body,
);
} }
} }
@@ -392,7 +480,11 @@ class AuthProxyService {
if (response.statusCode == 200) { if (response.statusCode == 200) {
return jsonDecode(response.body); return jsonDecode(response.body);
} else { } else {
throw Exception('Failed to verify code: ${response.body}'); throw _error(
'err.userfront.auth_proxy.code_verify',
'인증 코드 확인에 실패했습니다: {{error}}',
detail: response.body,
);
} }
} }
@@ -406,7 +498,11 @@ class AuthProxyService {
if (response.statusCode == 200) { if (response.statusCode == 200) {
return jsonDecode(response.body); return jsonDecode(response.body);
} else { } else {
throw Exception('Failed to init QR login: ${response.body}'); throw _error(
'err.userfront.auth_proxy.qr_init',
'QR 로그인을 시작하지 못했습니다: {{error}}',
detail: response.body,
);
} }
} }
@@ -424,7 +520,11 @@ class AuthProxyService {
if (response.statusCode == 400) { if (response.statusCode == 400) {
return jsonDecode(response.body); return jsonDecode(response.body);
} }
throw Exception('QR Polling failed: ${response.body}'); throw _error(
'err.userfront.auth_proxy.qr_poll',
'QR 상태 확인에 실패했습니다: {{error}}',
detail: response.body,
);
} }
static Future<void> approveQrLogin( static Future<void> approveQrLogin(
@@ -458,7 +558,11 @@ class AuthProxyService {
)); ));
if (response.statusCode != 200) { if (response.statusCode != 200) {
throw Exception('QR Approval failed: ${response.body}'); throw _error(
'err.userfront.auth_proxy.qr_approve',
'QR 승인에 실패했습니다: {{error}}',
detail: response.body,
);
} }
} finally { } finally {
client?.close(); client?.close();
@@ -505,7 +609,11 @@ class AuthProxyService {
); );
if (response.statusCode != 200) { if (response.statusCode != 200) {
throw Exception('Failed to create user: ${response.body}'); throw _error(
'err.userfront.auth_proxy.user_create',
'사용자 생성에 실패했습니다: {{error}}',
detail: response.body,
);
} }
} }
@@ -527,7 +635,11 @@ class AuthProxyService {
final data = jsonDecode(response.body); final data = jsonDecode(response.body);
return data['users'] ?? []; return data['users'] ?? [];
} else { } else {
throw Exception('Failed to list users: ${response.body}'); throw _error(
'err.userfront.auth_proxy.user_list',
'사용자 목록 조회에 실패했습니다: {{error}}',
detail: response.body,
);
} }
} }
@@ -544,7 +656,11 @@ class AuthProxyService {
); );
if (response.statusCode != 200) { if (response.statusCode != 200) {
throw Exception('Failed to delete user: ${response.body}'); throw _error(
'err.userfront.auth_proxy.user_delete',
'사용자 삭제에 실패했습니다: {{error}}',
detail: response.body,
);
} }
} }
@@ -562,7 +678,11 @@ class AuthProxyService {
); );
if (response.statusCode != 200) { if (response.statusCode != 200) {
throw Exception('Failed to update status: ${response.body}'); throw _error(
'err.userfront.auth_proxy.user_status_update',
'상태 업데이트에 실패했습니다: {{error}}',
detail: response.body,
);
} }
} }
@@ -591,7 +711,11 @@ class AuthProxyService {
); );
if (response.statusCode != 200) { if (response.statusCode != 200) {
throw Exception('Failed to update user: ${response.body}'); throw _error(
'err.userfront.auth_proxy.user_update',
'사용자 수정에 실패했습니다: {{error}}',
detail: response.body,
);
} }
} }
@@ -618,7 +742,10 @@ class AuthProxyService {
final data = jsonDecode(response.body); final data = jsonDecode(response.body);
return data['items'] ?? []; return data['items'] ?? [];
} else { } else {
throw Exception('연동된 앱 목록을 불러오지 못했습니다.'); throw _error(
'err.userfront.auth_proxy.linked_apps_load',
'연동된 앱 목록을 불러오지 못했습니다.',
);
} }
} finally { } finally {
client.close(); client.close();
@@ -646,7 +773,13 @@ class AuthProxyService {
if (response.statusCode != 200) { if (response.statusCode != 200) {
final errorBody = jsonDecode(response.body); final errorBody = jsonDecode(response.body);
throw Exception(errorBody['error'] ?? '연동 해지에 실패했습니다.'); throw Exception(
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.linked_app_revoke',
fallback: '연동 해지에 실패했습니다.',
),
);
} }
} finally { } finally {
client.close(); client.close();
@@ -684,7 +817,6 @@ class AuthProxyService {
} }
static int _clientLogFailureCount = 0; static int _clientLogFailureCount = 0;
static DateTime? _clientLogLastFailureAt;
static DateTime? _clientLogOpenUntil; static DateTime? _clientLogOpenUntil;
static bool _canSendClientLog() { static bool _canSendClientLog() {
@@ -698,7 +830,6 @@ class AuthProxyService {
static void _recordClientLogFailure() { static void _recordClientLogFailure() {
_clientLogFailureCount += 1; _clientLogFailureCount += 1;
_clientLogLastFailureAt = DateTime.now();
if (_clientLogFailureCount >= 3) { if (_clientLogFailureCount >= 3) {
_clientLogOpenUntil = DateTime.now().add(const Duration(minutes: 1)); _clientLogOpenUntil = DateTime.now().add(const Duration(minutes: 1));
_clientLogFailureCount = 0; _clientLogFailureCount = 0;
@@ -707,7 +838,6 @@ class AuthProxyService {
static void _recordClientLogSuccess() { static void _recordClientLogSuccess() {
_clientLogFailureCount = 0; _clientLogFailureCount = 0;
_clientLogLastFailureAt = null;
_clientLogOpenUntil = null; _clientLogOpenUntil = null;
} }
@@ -739,7 +869,11 @@ class AuthProxyService {
); );
if (response.statusCode != 200) { if (response.statusCode != 200) {
throw Exception('Failed to send code: ${response.body}'); throw _error(
'err.userfront.auth_proxy.phone_code_send',
'인증 코드 전송에 실패했습니다: {{error}}',
detail: response.body,
);
} }
} }

View File

@@ -1,3 +1,5 @@
// ignore_for_file: avoid_web_libraries_in_flutter, deprecated_member_use
import 'dart:html' as html; import 'dart:html' as html;
class AuthTokenStore { class AuthTokenStore {

View File

@@ -1,6 +1,8 @@
import 'package:flutter/foundation.dart';
void implSendLoginSuccess(String token) { void implSendLoginSuccess(String token) {
// No-op on non-web platforms // No-op on non-web platforms
print("Not on web: Login Success with token: $token"); debugPrint('Not on web: Login Success with token: $token');
} }
bool implIsPopup() { bool implIsPopup() {

View File

@@ -1,5 +1,8 @@
import 'dart:html' as html; // ignore_for_file: avoid_web_libraries_in_flutter, deprecated_member_use
import 'dart:async'; import 'dart:async';
import 'dart:html' as html;
import 'package:flutter/foundation.dart';
void implSendLoginSuccess(String token) { void implSendLoginSuccess(String token) {
final message = {'type': 'LOGIN_SUCCESS', 'token': token}; final message = {'type': 'LOGIN_SUCCESS', 'token': token};
@@ -7,9 +10,9 @@ void implSendLoginSuccess(String token) {
if (html.window.opener != null) { if (html.window.opener != null) {
try { try {
html.window.opener!.postMessage(message, '*'); html.window.opener!.postMessage(message, '*');
print("Sent login success message to opener"); debugPrint('Sent login success message to opener');
} catch (e) { } catch (e) {
print("Failed to postMessage: $e"); debugPrint('Failed to postMessage: $e');
} }
// Close the popup after a short delay to ensure message sending // Close the popup after a short delay to ensure message sending
@@ -18,7 +21,7 @@ void implSendLoginSuccess(String token) {
}); });
} else { } else {
// Should not happen given isPopup check, but as fallback: // Should not happen given isPopup check, but as fallback:
print("No opener found during popup flow."); debugPrint('No opener found during popup flow.');
} }
} }

View File

@@ -1,3 +1,5 @@
// ignore_for_file: avoid_web_libraries_in_flutter, deprecated_member_use
import 'dart:html' as html; import 'dart:html' as html;
class WebWindow { class WebWindow {

View File

@@ -345,10 +345,9 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
? const Center(child: Text("No users found.")) ? const Center(child: Text("No users found."))
: ListView.separated( : ListView.separated(
itemCount: _users.length, itemCount: _users.length,
separatorBuilder: (_, __) => const Divider(), separatorBuilder: (context, index) => const Divider(),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final user = _users[index]; final user = _users[index];
final userObj = user['user'] ?? {}; // 응답 구조가 케이스마다 다를 수 있음
// 일부 응답은 최상위 또는 user 하위에 필드를 포함합니다. // 일부 응답은 최상위 또는 user 하위에 필드를 포함합니다.
final loginIDs = (user['loginIds'] as List?) ?? []; final loginIDs = (user['loginIds'] as List?) ?? [];

View File

@@ -13,12 +13,6 @@ class ConsentScreen extends StatefulWidget {
} }
class _ConsentScreenState extends State<ConsentScreen> { class _ConsentScreenState extends State<ConsentScreen> {
static const _ink = Color(0xFF1A1F2C);
static const _surface = Colors.white;
static const _border = Color(0xFFE5E7EB);
static const _subtle = Color(0xFFF7F8FA);
static const _accent = Color(0xFF2563EB);
Map<String, dynamic>? _consentInfo; Map<String, dynamic>? _consentInfo;
bool _isLoading = true; bool _isLoading = true;
bool _isSubmitting = false; bool _isSubmitting = false;
@@ -28,7 +22,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
final Set<String> _selectedScopes = {}; final Set<String> _selectedScopes = {};
// 권한별 설명 매핑 (동적으로 업데이트됨) // 권한별 설명 매핑 (동적으로 업데이트됨)
Map<String, String> _scopeDescriptions = { final Map<String, String> _scopeDescriptions = {
'openid': 'OpenID 인증 정보 (로그인 상태 확인)', 'openid': 'OpenID 인증 정보 (로그인 상태 확인)',
'profile': '기본 프로필 정보 (이름, 사용자 식별자)', 'profile': '기본 프로필 정보 (이름, 사용자 식별자)',
'email': '이메일 주소 (계정 식별 및 알림 용도)', 'email': '이메일 주소 (계정 식별 및 알림 용도)',
@@ -37,7 +31,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
}; };
// 필수 권한 목록 (동적으로 업데이트됨) // 필수 권한 목록 (동적으로 업데이트됨)
Set<String> _mandatoryScopes = {'openid'}; final Set<String> _mandatoryScopes = {'openid'};
@override @override
void initState() { void initState() {
@@ -333,7 +327,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
activeColor: Theme.of(context).primaryColor, activeColor: Theme.of(context).primaryColor,
); );
}).toList(), }),
const Divider(), const Divider(),
const SizedBox(height: 32), const SizedBox(height: 32),

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/constants/error_whitelist.dart'; import '../../../core/constants/error_whitelist.dart';
import '../../../core/services/auth_proxy_service.dart'; import '../../../core/services/auth_proxy_service.dart';
import 'package:userfront/i18n.dart';
class ErrorScreen extends StatelessWidget { class ErrorScreen extends StatelessWidget {
final String? errorId; final String? errorId;
@@ -23,19 +24,38 @@ class ErrorScreen extends StatelessWidget {
final isProd = isProdOverride ?? AuthProxyService.isProdEnv; final isProd = isProdOverride ?? AuthProxyService.isProdEnv;
final normalizedCode = (errorCode ?? '').trim(); final normalizedCode = (errorCode ?? '').trim();
final hasCode = normalizedCode.isNotEmpty; final hasCode = normalizedCode.isNotEmpty;
final whitelistMessage = errorWhitelistMessages[normalizedCode]; final whitelistFallback = errorWhitelistMessages[normalizedCode];
final isWhitelisted = whitelistMessage != null; final isWhitelisted = whitelistFallback != null;
final errorType = isProd final errorType = isProd
? (isWhitelisted && hasCode ? normalizedCode : 'unknown_error') ? (isWhitelisted && hasCode ? normalizedCode : 'unknown_error')
: (hasCode ? normalizedCode : 'unknown_error'); : (hasCode ? normalizedCode : 'unknown_error');
final title = isProd final title = isProd
? '인증 과정에서 오류가 발생했습니다' ? tr('msg.userfront.error.title', fallback: '인증 과정에서 오류가 발생했습니다')
: (hasCode ? '오류: $normalizedCode' : '오류가 발생했습니다'); : (hasCode
? tr(
'msg.userfront.error.title_with_code',
fallback: '오류: {{code}}',
params: {'code': normalizedCode},
)
: tr('msg.userfront.error.title_generic', fallback: '오류가 발생했습니다'));
final detail = isProd final detail = isProd
? (isWhitelisted ? whitelistMessage! : '에러가 계속되면 관리자에게 문의해주세요') ? (isWhitelisted
? tr(
'msg.userfront.error.whitelist.$normalizedCode',
fallback: whitelistFallback,
)
: tr(
'msg.userfront.error.detail_contact',
fallback: '에러가 계속되면 관리자에게 문의해주세요',
))
: ((description?.isNotEmpty == true) : ((description?.isNotEmpty == true)
? description! ? description!
: (hasCode ? '오류가 발생했습니다.' : '요청을 처리하는 중 문제가 발생했습니다.')); : (hasCode
? tr('msg.userfront.error.detail_generic', fallback: '오류가 발생했습니다.')
: tr(
'msg.userfront.error.detail_request',
fallback: '요청을 처리하는 중 문제가 발생했습니다.',
)));
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF7F8FA), backgroundColor: const Color(0xFFF7F8FA),
@@ -72,7 +92,11 @@ class ErrorScreen extends StatelessWidget {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
'오류 종류: $errorType', tr(
'msg.userfront.error.type',
fallback: '오류 종류: {{type}}',
params: {'type': errorType},
),
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: const Color(0xFF6B7280), color: const Color(0xFF6B7280),
), ),
@@ -80,7 +104,11 @@ class ErrorScreen extends StatelessWidget {
if (errorId != null && errorId!.isNotEmpty) ...[ if (errorId != null && errorId!.isNotEmpty) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
'오류 ID: $errorId', tr(
'msg.userfront.error.id',
fallback: '오류 ID: {{id}}',
params: {'id': errorId!},
),
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: const Color(0xFF6B7280), color: const Color(0xFF6B7280),
), ),
@@ -101,7 +129,9 @@ class ErrorScreen extends StatelessWidget {
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
), ),
child: const Text('로그인으로 이동'), child: Text(
tr('ui.userfront.error.go_login', fallback: '로그인으로 이동'),
),
), ),
OutlinedButton( OutlinedButton(
onPressed: () => context.go('/'), onPressed: () => context.go('/'),
@@ -113,7 +143,9 @@ class ErrorScreen extends StatelessWidget {
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
), ),
child: const Text('홈으로 이동'), child: Text(
tr('ui.userfront.error.go_home', fallback: '홈으로 이동'),
),
), ),
], ],
), ),

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../core/services/auth_proxy_service.dart'; import '../../../core/services/auth_proxy_service.dart';
import 'package:userfront/i18n.dart';
class ForgotPasswordScreen extends StatefulWidget { class ForgotPasswordScreen extends StatefulWidget {
const ForgotPasswordScreen({super.key}); const ForgotPasswordScreen({super.key});
@@ -22,7 +23,12 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
Future<void> _handlePasswordReset() async { Future<void> _handlePasswordReset() async {
final input = _loginIdController.text.trim(); final input = _loginIdController.text.trim();
if (input.isEmpty) { if (input.isEmpty) {
_showError("이메일 또는 휴대폰 번호를 입력해주세요."); _showError(
tr(
'msg.userfront.forgot.input_required',
fallback: '이메일 또는 휴대폰 번호를 입력해주세요.',
),
);
return; return;
} }
@@ -41,8 +47,13 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
await AuthProxyService.initiatePasswordReset(loginId, drySend: _drySendEnabled); await AuthProxyService.initiatePasswordReset(loginId, drySend: _drySendEnabled);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text("비밀번호 재설정 링크가 전송되었습니다. 이메일 또는 SMS를 확인해주세요."), content: Text(
tr(
'msg.userfront.forgot.sent',
fallback: '비밀번호 재설정 링크가 전송되었습니다. 이메일 또는 SMS를 확인해주세요.',
),
),
backgroundColor: Colors.green, backgroundColor: Colors.green,
), ),
); );
@@ -50,7 +61,13 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
_showError("전송에 실패했습니다: ${e.toString()}"); _showError(
tr(
'msg.userfront.forgot.error',
fallback: '전송에 실패했습니다: {{error}}',
params: {'error': e.toString()},
),
);
} }
} finally { } finally {
if (mounted) { if (mounted) {
@@ -77,7 +94,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text("비밀번호 재설정"), title: Text(tr('ui.userfront.forgot.title', fallback: '비밀번호 재설정')),
centerTitle: true, centerTitle: true,
), ),
body: Center( body: Center(
@@ -89,7 +106,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Text(
"비밀번호를 잊으셨나요?", tr('ui.userfront.forgot.heading', fallback: '비밀번호를 잊으셨나요?'),
style: TextStyle( style: TextStyle(
fontSize: 28, fontSize: 28,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -106,13 +123,16 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
border: Border.all(color: const Color(0xFFFFC107)), border: Border.all(color: const Color(0xFFFFC107)),
), ),
child: Row( child: Row(
children: const [ children: [
Icon(Icons.warning_amber_rounded, color: Color(0xFF8A6D3B)), const Icon(Icons.warning_amber_rounded, color: Color(0xFF8A6D3B)),
SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
"drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.", tr(
style: TextStyle(color: Color(0xFF8A6D3B), fontSize: 12), 'msg.userfront.forgot.dry_send',
fallback: 'drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.',
),
style: const TextStyle(color: Color(0xFF8A6D3B), fontSize: 12),
), ),
), ),
], ],
@@ -120,18 +140,25 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
), ),
], ],
const SizedBox(height: 16), const SizedBox(height: 16),
const Text( Text(
"계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다.", tr(
'msg.userfront.forgot.description',
fallback:
'계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다.',
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey), style: const TextStyle(color: Colors.grey),
), ),
const SizedBox(height: 40), const SizedBox(height: 40),
TextField( TextField(
controller: _loginIdController, controller: _loginIdController,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: "이메일 또는 휴대폰 번호", labelText: tr(
border: OutlineInputBorder(), 'ui.userfront.forgot.input_label',
prefixIcon: Icon(Icons.person_outline), fallback: '이메일 또는 휴대폰 번호',
),
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.person_outline),
), ),
onSubmitted: (_) => _handlePasswordReset(), onSubmitted: (_) => _handlePasswordReset(),
), ),
@@ -147,7 +174,12 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
width: 20, width: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
) )
: const Text("재설정 링크 전송"), : Text(
tr(
'ui.userfront.forgot.submit',
fallback: '재설정 링크 전송',
),
),
), ),
], ],
), ),

View File

@@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
import 'package:qr_flutter/qr_flutter.dart'; import 'package:qr_flutter/qr_flutter.dart';
import 'package:userfront/i18n.dart';
import '../../../core/services/web_auth_integration.dart'; import '../../../core/services/web_auth_integration.dart';
import '../../../core/services/auth_proxy_service.dart'; import '../../../core/services/auth_proxy_service.dart';
import '../../../core/services/auth_token_store.dart'; import '../../../core/services/auth_token_store.dart';
@@ -51,10 +52,18 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
bool _verificationOnly = false; bool _verificationOnly = false;
bool _verificationApproved = false; bool _verificationApproved = false;
String _verificationMessage = ''; String _verificationMessage = '';
String _verificationTitle = '승인 완료'; String _verificationTitle = tr(
String _verificationPageTitle = '로그인 승인'; 'ui.userfront.login.verification.title',
String _verificationActionLabel = '확인'; fallback: '승인 완료',
String _verificationActionPath = '/'; );
String _verificationPageTitle = tr(
'ui.userfront.login.verification.page_title',
fallback: '로그인 승인',
);
String _verificationActionLabel = tr(
'ui.userfront.login.verification.action_label',
fallback: '확인',
);
Timer? _verificationRedirectTimer; Timer? _verificationRedirectTimer;
bool _noticeHandled = false; bool _noticeHandled = false;
bool _drySendEnabled = false; bool _drySendEnabled = false;
@@ -92,7 +101,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (!_noticeHandled && notice == 'qr_login_required') { if (!_noticeHandled && notice == 'qr_login_required') {
_noticeHandled = true; _noticeHandled = true;
_showInfo('로그인 한 상태여야 QR 스캔으로 로그인 할 수 있습니다'); _showInfo(
tr(
'msg.userfront.login.qr_login_required',
fallback: '로그인 한 상태여야 QR 스캔으로 로그인 할 수 있습니다',
),
);
} }
if (!_verificationOnly) { if (!_verificationOnly) {
@@ -125,7 +139,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
} }
} catch (e) { } catch (e) {
if (!silent) { if (!silent) {
_showError("로그인 확인 실패: ${e.toString().replaceFirst("Exception: ", "")}"); _showError(
tr(
'msg.userfront.login.cookie_check_failed',
fallback: '로그인 확인 실패: {{error}}',
params: {
'error': e.toString().replaceFirst('Exception: ', ''),
},
),
);
} }
} }
} }
@@ -294,7 +316,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
_startCountdown(); _startCountdown();
} }
} catch (e) { } catch (e) {
_showError("Failed to init QR: $e"); _showError(
tr(
'msg.userfront.login.qr_init_failed',
fallback: 'QR 초기화에 실패했습니다: {{error}}',
params: {'error': e.toString()},
),
);
if (mounted) setState(() => _isQrLoading = false); if (mounted) setState(() => _isQrLoading = false);
} }
} }
@@ -346,7 +374,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (res['error'] == 'expired_token') { if (res['error'] == 'expired_token') {
timer.cancel(); timer.cancel();
_qrCountdownTimer?.cancel(); _qrCountdownTimer?.cancel();
_showError("QR 세션이 만료되었습니다."); _showError(
tr(
'msg.userfront.login.qr_expired',
fallback: 'QR 세션이 만료되었습니다.',
),
);
return; return;
} }
@@ -357,7 +390,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (token is String && token.isNotEmpty) { if (token is String && token.isNotEmpty) {
_completeLoginFromToken(token); _completeLoginFromToken(token);
} else { } else {
_showError("로그인 토큰을 확인할 수 없습니다."); _showError(
tr(
'msg.userfront.login.token_missing',
fallback: '로그인 토큰을 확인할 수 없습니다.',
),
);
} }
} }
} catch (e) { } catch (e) {
@@ -423,21 +461,35 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
void _markVerificationApproved( void _markVerificationApproved(
String message, { String message, {
String title = '승인 완료', String? title,
String pageTitle = '로그인 승인', String? pageTitle,
String actionLabel = '확인', String? actionLabel,
String actionPath = '/', String actionPath = '/',
bool autoRedirect = false, bool autoRedirect = false,
Duration redirectDelay = const Duration(seconds: 2), Duration redirectDelay = const Duration(seconds: 2),
}) { }) {
if (!mounted) return; if (!mounted) return;
final resolvedTitle = title ??
tr(
'ui.userfront.login.verification.title',
fallback: '승인 완료',
);
final resolvedPageTitle = pageTitle ??
tr(
'ui.userfront.login.verification.page_title',
fallback: '로그인 승인',
);
final resolvedActionLabel = actionLabel ??
tr(
'ui.userfront.login.verification.action_label',
fallback: '확인',
);
setState(() { setState(() {
_verificationApproved = true; _verificationApproved = true;
_verificationMessage = message; _verificationMessage = message;
_verificationTitle = title; _verificationTitle = resolvedTitle;
_verificationPageTitle = pageTitle; _verificationPageTitle = resolvedPageTitle;
_verificationActionLabel = actionLabel; _verificationActionLabel = resolvedActionLabel;
_verificationActionPath = actionPath;
}); });
_verificationRedirectTimer?.cancel(); _verificationRedirectTimer?.cancel();
if (autoRedirect) { if (autoRedirect) {
@@ -463,7 +515,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
_verificationMessage.isEmpty ? '로그인 승인에 성공했습니다.' : _verificationMessage, _verificationMessage.isEmpty
? tr(
'msg.userfront.login.verification.success',
fallback: '로그인 승인에 성공했습니다.',
)
: _verificationMessage,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle(color: Colors.black54), style: const TextStyle(color: Colors.black54),
), ),
@@ -490,6 +547,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
Future<void> _verifyToken(String token) async { Future<void> _verifyToken(String token) async {
debugPrint("[Auth] Starting verification for token: $token"); debugPrint("[Auth] Starting verification for token: $token");
final approvedMessage = tr(
'msg.userfront.login.verification.approved',
fallback: '승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.',
);
final localSessionMessage = tr(
'msg.userfront.login.verification.approved_local',
fallback: '승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다',
);
try { try {
// Use Backend to verify the token (Backend-Driven Flow) // Use Backend to verify the token (Backend-Driven Flow)
final res = await AuthProxyService.verifyMagicLink( final res = await AuthProxyService.verifyMagicLink(
@@ -505,7 +570,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (status == 'approved' || (jwt == null && _verificationOnly)) { if (status == 'approved' || (jwt == null && _verificationOnly)) {
if (mounted) { if (mounted) {
_markVerificationApproved( _markVerificationApproved(
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.", approvedMessage,
actionPath: actionPath, actionPath: actionPath,
); );
} }
@@ -515,13 +580,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (jwt is String && jwt.isNotEmpty) { if (jwt is String && jwt.isNotEmpty) {
if (hasLocalSession) { if (hasLocalSession) {
_markVerificationApproved( _markVerificationApproved(
"승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다", localSessionMessage,
actionPath: actionPath, actionPath: actionPath,
); );
return; return;
} }
_markVerificationApproved( _markVerificationApproved(
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.", approvedMessage,
actionPath: actionPath, actionPath: actionPath,
); );
return; return;
@@ -529,14 +594,20 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (mounted) { if (mounted) {
_markVerificationApproved( _markVerificationApproved(
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.", approvedMessage,
actionPath: actionPath, actionPath: actionPath,
); );
} }
} catch (e) { } catch (e) {
debugPrint("[Auth] Verification FAILED for token: $token. Error: $e"); debugPrint("[Auth] Verification FAILED for token: $token. Error: $e");
if (mounted) { if (mounted) {
_showError("Verification failed: $e"); _showError(
tr(
'msg.userfront.login.verification_failed',
fallback: '승인 처리에 실패했습니다: {{error}}',
params: {'error': e.toString()},
),
);
} }
} }
} }
@@ -544,6 +615,18 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
Future<void> _verifyLoginCode(String loginId, String code, {String? pendingRef}) async { Future<void> _verifyLoginCode(String loginId, String code, {String? pendingRef}) async {
final sanitizedLoginId = loginId.replaceAll(' ', '+'); final sanitizedLoginId = loginId.replaceAll(' ', '+');
debugPrint("[Auth] Starting code verification for loginId: $sanitizedLoginId"); debugPrint("[Auth] Starting code verification for loginId: $sanitizedLoginId");
final approvedMessage = tr(
'msg.userfront.login.verification.approved',
fallback: '승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.',
);
final localSessionMessage = tr(
'msg.userfront.login.verification.approved_local',
fallback: '승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다',
);
final linkLoginMessage = tr(
'msg.userfront.login.link.approved',
fallback: '링크로 로그인 되었습니다. 잠시 후 로그인 화면으로 이동합니다.',
);
try { try {
final res = await AuthProxyService.verifyLoginCode( final res = await AuthProxyService.verifyLoginCode(
sanitizedLoginId, sanitizedLoginId,
@@ -560,7 +643,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (jwt == null && status == 'approved') { if (jwt == null && status == 'approved') {
if (mounted) { if (mounted) {
_markVerificationApproved( _markVerificationApproved(
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.", approvedMessage,
actionPath: actionPath, actionPath: actionPath,
); );
} }
@@ -570,22 +653,32 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (jwt is String && jwt.isNotEmpty) { if (jwt is String && jwt.isNotEmpty) {
if (hasLocalSession) { if (hasLocalSession) {
_markVerificationApproved( _markVerificationApproved(
"승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다", localSessionMessage,
actionPath: actionPath, actionPath: actionPath,
); );
return; return;
} }
if (_verificationOnly) { if (_verificationOnly) {
_markVerificationApproved( _markVerificationApproved(
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.", approvedMessage,
actionPath: actionPath, actionPath: actionPath,
); );
return; return;
} }
_markVerificationApproved("링크로 로그인 되었습니다. 잠시 후 로그인 화면으로 이동합니다.", _markVerificationApproved(
title: '링크 로그인 완료', linkLoginMessage,
pageTitle: '링크 로그인', title: tr(
actionLabel: '로그인 화면으로 이동', 'ui.userfront.login.link.title',
fallback: '링크 로그인 완료',
),
pageTitle: tr(
'ui.userfront.login.link.page_title',
fallback: '링크 로그인',
),
actionLabel: tr(
'ui.userfront.login.link.action_label',
fallback: '로그인 화면으로 이동',
),
actionPath: '/signin', actionPath: '/signin',
autoRedirect: true, autoRedirect: true,
); );
@@ -594,14 +687,20 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (_verificationOnly && mounted) { if (_verificationOnly && mounted) {
_markVerificationApproved( _markVerificationApproved(
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.", approvedMessage,
actionPath: actionPath, actionPath: actionPath,
); );
} }
} catch (e) { } catch (e) {
debugPrint("[Auth] Code verification FAILED for loginId: $sanitizedLoginId. Error: $e"); debugPrint("[Auth] Code verification FAILED for loginId: $sanitizedLoginId. Error: $e");
if (mounted) { if (mounted) {
_showError("Verification failed: $e"); _showError(
tr(
'msg.userfront.login.verification_failed',
fallback: '승인 처리에 실패했습니다: {{error}}',
params: {'error': e.toString()},
),
);
} }
} }
} }
@@ -610,6 +709,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final sanitized = shortCode.trim().toUpperCase(); final sanitized = shortCode.trim().toUpperCase();
if (sanitized.isEmpty) return; if (sanitized.isEmpty) return;
debugPrint("[Auth] Starting short code verification for code: $sanitized"); debugPrint("[Auth] Starting short code verification for code: $sanitized");
final approvedMessage = tr(
'msg.userfront.login.verification.approved',
fallback: '승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.',
);
final localSessionMessage = tr(
'msg.userfront.login.verification.approved_local',
fallback: '승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다',
);
try { try {
final res = await AuthProxyService.verifyLoginShortCode( final res = await AuthProxyService.verifyLoginShortCode(
sanitized, sanitized,
@@ -624,7 +731,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (jwt == null && status == 'approved') { if (jwt == null && status == 'approved') {
if (mounted) { if (mounted) {
_markVerificationApproved( _markVerificationApproved(
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.", approvedMessage,
actionPath: actionPath, actionPath: actionPath,
); );
} }
@@ -634,14 +741,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (jwt is String && jwt.isNotEmpty) { if (jwt is String && jwt.isNotEmpty) {
if (hasLocalSession) { if (hasLocalSession) {
_markVerificationApproved( _markVerificationApproved(
"승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다", localSessionMessage,
actionPath: actionPath, actionPath: actionPath,
); );
return; return;
} }
if (_verificationOnly) { if (_verificationOnly) {
_markVerificationApproved( _markVerificationApproved(
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.", approvedMessage,
actionPath: actionPath, actionPath: actionPath,
); );
return; return;
@@ -652,14 +759,20 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (_verificationOnly && mounted) { if (_verificationOnly && mounted) {
_markVerificationApproved( _markVerificationApproved(
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.", approvedMessage,
actionPath: actionPath, actionPath: actionPath,
); );
} }
} catch (e) { } catch (e) {
debugPrint("[Auth] Short code verification FAILED. Error: $e"); debugPrint("[Auth] Short code verification FAILED. Error: $e");
if (mounted) { if (mounted) {
_showError("Verification failed: $e"); _showError(
tr(
'msg.userfront.login.verification_failed',
fallback: '승인 처리에 실패했습니다: {{error}}',
params: {'error': e.toString()},
),
);
} }
} }
} }
@@ -682,7 +795,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final input = _passwordLoginIdController.text.trim(); final input = _passwordLoginIdController.text.trim();
final password = _passwordController.text.trim(); final password = _passwordController.text.trim();
if (input.isEmpty || password.isEmpty) { if (input.isEmpty || password.isEmpty) {
_showError("이메일(또는 전화번호)와 비밀번호를 모두 입력해주세요."); _showError(
tr(
'msg.userfront.login.password.missing_credentials',
fallback: '이메일(또는 전화번호)와 비밀번호를 모두 입력해주세요.',
),
);
return; return;
} }
@@ -721,7 +839,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (e.toString().contains("User not registered")) { if (e.toString().contains("User not registered")) {
_showUnregisteredDialog(); _showUnregisteredDialog();
} else { } else {
_showError("로그인 실패: ${e.toString().replaceFirst("Exception: ", "")}"); _showError(
tr(
'msg.userfront.login.password.failed',
fallback: '로그인 실패: {{error}}',
params: {
'error': e.toString().replaceFirst('Exception: ', ''),
},
),
);
} }
} }
} }
@@ -746,7 +872,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (e.toString().contains("User not registered")) { if (e.toString().contains("User not registered")) {
_showUnregisteredDialog(); _showUnregisteredDialog();
} else { } else {
_showError("오류: $e"); _showError(
tr(
'msg.userfront.login.link_failed',
fallback: '오류: {{error}}',
params: {'error': e.toString()},
),
);
} }
} }
} }
@@ -782,9 +914,17 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}); });
Navigator.of(context).pop(); Navigator.of(context).pop();
_showInfo(isEmail _showInfo(
? "입력하신 이메일로 로그인 링크를 보냈습니다." isEmail
: "입력하신 번호로 로그인 링크를 보냈습니다."); ? tr(
'msg.userfront.login.link_sent_email',
fallback: '입력하신 이메일로 로그인 링크를 보냈습니다.',
)
: tr(
'msg.userfront.login.link_sent_phone',
fallback: '입력하신 번호로 로그인 링크를 보냈습니다.',
),
);
final initialInterval = (interval is int && interval > 0) final initialInterval = (interval is int && interval > 0)
? Duration(seconds: interval) ? Duration(seconds: interval)
@@ -806,7 +946,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (e.toString().contains("User not registered")) { if (e.toString().contains("User not registered")) {
_showUnregisteredDialog(); _showUnregisteredDialog();
} else { } else {
_showError("전송 실패: $e"); _showError(
tr(
'msg.userfront.login.link_send_failed',
fallback: '전송 실패: {{error}}',
params: {'error': e.toString()},
),
);
} }
} }
} }
@@ -842,7 +988,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (result['error'] == 'expired_token') { if (result['error'] == 'expired_token') {
if (mounted) { if (mounted) {
Navigator.of(context).pop(); Navigator.of(context).pop();
_showError("Login timed out."); _showError(
tr(
'msg.userfront.login.link_timeout',
fallback: '로그인 요청 시간이 초과되었습니다.',
),
);
} }
return; return;
} }
@@ -862,7 +1013,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (mounted && Navigator.canPop(context)) { if (mounted && Navigator.canPop(context)) {
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
_showError("로그인 토큰을 확인할 수 없습니다."); _showError(
tr(
'msg.userfront.login.token_missing',
fallback: '로그인 토큰을 확인할 수 없습니다.',
),
);
return; return;
} }
} catch (e) { } catch (e) {
@@ -873,7 +1029,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (mounted) { if (mounted) {
debugPrint("[Auth] Polling timed out for ref: $pendingRef"); debugPrint("[Auth] Polling timed out for ref: $pendingRef");
Navigator.of(context).pop(); Navigator.of(context).pop();
_showError("Login timed out."); _showError(
tr(
'msg.userfront.login.link_timeout',
fallback: '로그인 요청 시간이 초과되었습니다.',
),
);
} }
} }
@@ -950,7 +1111,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
return; return;
} }
} catch (e) { } catch (e) {
_showError("OIDC 로그인 처리에 실패했습니다. 다시 시도해 주세요."); _showError(
tr(
'msg.userfront.login.oidc_failed',
fallback: 'OIDC 로그인 처리에 실패했습니다. 다시 시도해 주세요.',
),
);
return; return;
} }
} }
@@ -978,12 +1144,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text("미등록 회원"), title: Text(
content: const Text("가입되지 않은 정보입니다.\n회원가입 후 이용해 주세요."), tr('ui.userfront.login.unregistered.title', fallback: '미등록 회원'),
),
content: Text(
tr(
'msg.userfront.login.unregistered.body',
fallback: '가입되지 않은 정보입니다.\n회원가입 후 이용해 주세요.',
),
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text("취소"), child: Text(tr('ui.common.cancel', fallback: '취소')),
), ),
FilledButton( FilledButton(
onPressed: () { onPressed: () {
@@ -991,7 +1164,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
_resetLinkLoginState(); _resetLinkLoginState();
context.push('/signup'); context.push('/signup');
}, },
child: const Text("회원가입 하기"), child: Text(
tr('ui.userfront.login.unregistered.action', fallback: '회원가입 하기'),
),
), ),
], ],
), ),
@@ -1028,8 +1203,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Text(
"Baron 로그인", tr('ui.userfront.app_title', fallback: 'Baron 로그인'),
style: TextStyle( style: const TextStyle(
fontSize: 32, fontSize: 32,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@@ -1045,13 +1220,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
border: Border.all(color: const Color(0xFFFFC107)), border: Border.all(color: const Color(0xFFFFC107)),
), ),
child: Row( child: Row(
children: const [ children: [
Icon(Icons.warning_amber_rounded, color: Color(0xFF8A6D3B)), const Icon(Icons.warning_amber_rounded, color: Color(0xFF8A6D3B)),
SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
"drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.", tr(
style: TextStyle(color: Color(0xFF8A6D3B), fontSize: 12), 'msg.userfront.login.dry_send',
fallback: 'drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.',
),
style: const TextStyle(
color: Color(0xFF8A6D3B),
fontSize: 12,
),
), ),
), ),
], ],
@@ -1062,10 +1243,25 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
TabBar( TabBar(
controller: _tabController, controller: _tabController,
tabs: const [ tabs: [
Tab(text: "비밀번호"), Tab(
Tab(text: "로그인 링크"), text: tr(
Tab(text: "QR 코드"), 'ui.userfront.login.tabs.password',
fallback: '비밀번호',
),
),
Tab(
text: tr(
'ui.userfront.login.tabs.link',
fallback: '로그인 링크',
),
),
Tab(
text: tr(
'ui.userfront.login.tabs.qr',
fallback: 'QR 코드',
),
),
], ],
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
@@ -1081,10 +1277,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
children: [ children: [
TextField( TextField(
controller: _passwordLoginIdController, controller: _passwordLoginIdController,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: "이메일 또는 휴대폰 번호", labelText: tr(
border: OutlineInputBorder(), 'ui.userfront.login.field.login_id',
prefixIcon: Icon(Icons.person_outline), fallback: '이메일 또는 휴대폰 번호',
),
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.person_outline),
), ),
onSubmitted: (_) => _handlePasswordLogin(), onSubmitted: (_) => _handlePasswordLogin(),
), ),
@@ -1092,10 +1291,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
TextField( TextField(
controller: _passwordController, controller: _passwordController,
obscureText: true, obscureText: true,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: "비밀번호", labelText: tr(
border: OutlineInputBorder(), 'ui.userfront.login.field.password',
prefixIcon: Icon(Icons.lock_outline), fallback: '비밀번호',
),
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock_outline),
), ),
onSubmitted: (_) => _handlePasswordLogin(), onSubmitted: (_) => _handlePasswordLogin(),
), ),
@@ -1105,7 +1307,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(50), minimumSize: const Size.fromHeight(50),
), ),
child: const Text("로그인"), child: Text(
tr('ui.userfront.login.action.submit', fallback: '로그인'),
),
), ),
], ],
), ),
@@ -1118,11 +1322,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (_linkPendingRef == null) ...[ if (_linkPendingRef == null) ...[
TextField( TextField(
controller: _linkIdController, controller: _linkIdController,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: "이메일 또는 휴대폰 번호", labelText: tr(
hintText: "", 'ui.userfront.login.field.login_id',
border: OutlineInputBorder(), fallback: '이메일 또는 휴대폰 번호',
prefixIcon: Icon(Icons.person_outline), ),
hintText: '',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.person_outline),
), ),
onSubmitted: (_) => _handleLinkLogin(), onSubmitted: (_) => _handleLinkLogin(),
), ),
@@ -1132,19 +1339,30 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(50), minimumSize: const Size.fromHeight(50),
), ),
child: const Text("로그인 링크 전송"), child: Text(
tr(
'ui.userfront.login.link.send',
fallback: '로그인 링크 전송',
),
),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
const Text( Text(
"입력하신 정보로 로그인 링크를 전송합니다.", tr(
style: TextStyle(color: Colors.grey, fontSize: 12), 'msg.userfront.login.link.helper',
fallback: '입력하신 정보로 로그인 링크를 전송합니다.',
),
style: const TextStyle(color: Colors.grey, fontSize: 12),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
], ],
if (_linkPendingRef != null) ...[ if (_linkPendingRef != null) ...[
const Text( Text(
"링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다.", tr(
style: TextStyle(color: Colors.grey, fontSize: 12), 'msg.userfront.login.link.short_code_help',
fallback: '링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다.',
),
style: const TextStyle(color: Colors.grey, fontSize: 12),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -1155,11 +1373,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
child: TextField( child: TextField(
controller: _shortCodePrefixController, controller: _shortCodePrefixController,
textCapitalization: TextCapitalization.characters, textCapitalization: TextCapitalization.characters,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: "영문 2자리", labelText: tr(
border: OutlineInputBorder(), 'ui.userfront.login.short_code.prefix',
hintText: "AB", fallback: '영문 2자리',
hintStyle: TextStyle(color: Colors.grey), ),
border: const OutlineInputBorder(),
hintText: 'AB',
hintStyle: const TextStyle(color: Colors.grey),
), ),
maxLength: 2, maxLength: 2,
), ),
@@ -1171,12 +1392,21 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
controller: _shortCodeDigitsController, controller: _shortCodeDigitsController,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
decoration: InputDecoration( decoration: InputDecoration(
labelText: "숫자 6자리", labelText: tr(
'ui.userfront.login.short_code.digits',
fallback: '숫자 6자리',
),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
hintText: "345678", hintText: '345678',
hintStyle: const TextStyle(color: Colors.grey), hintStyle: const TextStyle(color: Colors.grey),
suffixText: _linkExpireSeconds > 0 suffixText: _linkExpireSeconds > 0
? "유효시간 ${_formatTime(_linkExpireSeconds)}" ? tr(
'ui.userfront.login.short_code.expire_time',
fallback: '유효시간 {{time}}',
params: {
'time': _formatTime(_linkExpireSeconds),
},
)
: null, : null,
), ),
maxLength: 6, maxLength: 6,
@@ -1190,7 +1420,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final prefix = _shortCodePrefixController.text.trim().toUpperCase(); final prefix = _shortCodePrefixController.text.trim().toUpperCase();
final digits = _shortCodeDigitsController.text.trim(); final digits = _shortCodeDigitsController.text.trim();
if (prefix.length != 2 || digits.length != 6) { if (prefix.length != 2 || digits.length != 6) {
_showError("문자 2개와 숫자 6자리를 입력해 주세요."); _showError(
tr(
'msg.userfront.login.short_code.invalid',
fallback: '문자 2개와 숫자 6자리를 입력해 주세요.',
),
);
return; return;
} }
_verifyShortCode(prefix + digits); _verifyShortCode(prefix + digits);
@@ -1198,18 +1433,36 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(45), minimumSize: const Size.fromHeight(45),
), ),
child: const Text("코드로 로그인"), child: Text(
tr(
'ui.userfront.login.short_code.submit',
fallback: '코드로 로그인',
),
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextButton( TextButton(
onPressed: () { onPressed: () {
if (_linkResendSeconds > 0) { if (_linkResendSeconds > 0) {
_showInfo("재발송은 ${_formatTime(_linkResendSeconds)} 후 가능합니다."); _showInfo(
tr(
'msg.userfront.login.link.resend_wait',
fallback: '재발송은 {{time}} 후 가능합니다.',
params: {
'time': _formatTime(_linkResendSeconds),
},
),
);
return; return;
} }
final loginId = _lastLinkLoginId ?? _linkIdController.text.trim(); final loginId = _lastLinkLoginId ?? _linkIdController.text.trim();
if (loginId.isEmpty) { if (loginId.isEmpty) {
_showError("이메일 또는 휴대폰 번호를 입력해 주세요."); _showError(
tr(
'msg.userfront.login.link.missing_login_id',
fallback: '이메일 또는 휴대폰 번호를 입력해 주세요.',
),
);
return; return;
} }
_startEnchantedFlow( _startEnchantedFlow(
@@ -1220,8 +1473,17 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}, },
child: Text( child: Text(
_linkResendSeconds > 0 _linkResendSeconds > 0
? "재발송 (${_formatTime(_linkResendSeconds)})" ? tr(
: "재발송", 'ui.userfront.login.link.resend_with_time',
fallback: '재발송 ({{time}})',
params: {
'time': _formatTime(_linkResendSeconds),
},
)
: tr(
'ui.common.resend',
fallback: '재발송',
),
), ),
), ),
if (!_lastLinkIsEmail) ...[ if (!_lastLinkIsEmail) ...[
@@ -1229,12 +1491,25 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
TextButton( TextButton(
onPressed: () { onPressed: () {
if (_linkResendSeconds > 0) { if (_linkResendSeconds > 0) {
_showInfo("재발송은 ${_formatTime(_linkResendSeconds)} 후 가능합니다."); _showInfo(
tr(
'msg.userfront.login.link.resend_wait',
fallback: '재발송은 {{time}} 후 가능합니다.',
params: {
'time': _formatTime(_linkResendSeconds),
},
),
);
return; return;
} }
final loginId = _lastLinkLoginId ?? _linkIdController.text.trim(); final loginId = _lastLinkLoginId ?? _linkIdController.text.trim();
if (loginId.isEmpty) { if (loginId.isEmpty) {
_showError("휴대폰 번호를 입력해 주세요."); _showError(
tr(
'msg.userfront.login.link.missing_phone',
fallback: '휴대폰 번호를 입력해 주세요.',
),
);
return; return;
} }
_startEnchantedFlow( _startEnchantedFlow(
@@ -1243,7 +1518,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
codeOnly: true, codeOnly: true,
); );
}, },
child: Text("코드만 받기(${_formatTime(_linkResendSeconds)})"), child: Text(
tr(
'ui.userfront.login.link.code_only',
fallback: '코드만 받기({{time}})',
params: {
'time': _formatTime(_linkResendSeconds),
},
),
),
), ),
], ],
], ],
@@ -1276,8 +1559,17 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
_qrRemainingSeconds > 0 _qrRemainingSeconds > 0
? "남은 시간: ${_formatTime(_qrRemainingSeconds)}" ? tr(
: "QR 코드 만료됨", 'ui.userfront.login.qr.remaining',
fallback: '남은 시간: {{time}}',
params: {
'time': _formatTime(_qrRemainingSeconds),
},
)
: tr(
'ui.userfront.login.qr.expired',
fallback: 'QR 코드 만료됨',
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
color: _qrRemainingSeconds > 30 ? Colors.blue : Colors.red, color: _qrRemainingSeconds > 30 ? Colors.blue : Colors.red,
@@ -1285,19 +1577,33 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
const Text( Text(
"모바일 앱으로 스캔하세요", tr(
'msg.userfront.login.qr.scan_hint',
fallback: '모바일 앱으로 스캔하세요',
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey, fontSize: 12), style: const TextStyle(color: Colors.grey, fontSize: 12),
), ),
TextButton( TextButton(
onPressed: _startQrFlow, onPressed: _startQrFlow,
child: const Text("QR 코드 새로고침") child: Text(
tr(
'ui.userfront.login.qr.refresh',
fallback: 'QR 코드 새로고침',
),
),
), ),
], ],
) )
else else
const Text("QR 코드를 불러오지 못했습니다.", textAlign: TextAlign.center), Text(
tr(
'msg.userfront.login.qr.load_failed',
fallback: 'QR 코드를 불러오지 못했습니다.',
),
textAlign: TextAlign.center,
),
], ],
), ),
], ],
@@ -1308,15 +1614,31 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
children: [ children: [
TextButton( TextButton(
onPressed: () => context.push('/forgot-password'), onPressed: () => context.push('/forgot-password'),
child: const Text("비밀번호를 잊으셨나요?"), child: Text(
tr(
'ui.userfront.login.forgot_password',
fallback: '비밀번호를 잊으셨나요?',
),
),
), ),
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Text("계정이 없으신가요?", style: TextStyle(color: Colors.grey, fontSize: 14)), Text(
tr(
'msg.userfront.login.no_account',
fallback: '계정이 없으신가요?',
),
style: const TextStyle(color: Colors.grey, fontSize: 14),
),
TextButton( TextButton(
onPressed: () => context.push('/signup'), onPressed: () => context.push('/signup'),
child: const Text("회원가입"), child: Text(
tr(
'ui.userfront.login.signup',
fallback: '회원가입',
),
),
), ),
], ],
), ),

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:userfront/i18n.dart';
class LoginSuccessScreen extends StatelessWidget { class LoginSuccessScreen extends StatelessWidget {
const LoginSuccessScreen({super.key}); const LoginSuccessScreen({super.key});
@@ -16,17 +17,17 @@ class LoginSuccessScreen extends StatelessWidget {
const Icon(Icons.check_circle_outline, size: 80, color: Colors.green), const Icon(Icons.check_circle_outline, size: 80, color: Colors.green),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
"로그인 완료", tr('ui.userfront.login_success.title', fallback: '로그인 완료'),
style: TextStyle( style: TextStyle(
fontSize: 32, fontSize: 32,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text( Text(
"성공적으로 로그인되었습니다.", tr('msg.userfront.login_success.subtitle', fallback: '성공적으로 로그인되었습니다.'),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey, fontSize: 16), style: const TextStyle(color: Colors.grey, fontSize: 16),
), ),
const SizedBox(height: 48), const SizedBox(height: 48),
@@ -36,7 +37,9 @@ class LoginSuccessScreen extends StatelessWidget {
context.push('/scan'); context.push('/scan');
}, },
icon: const Icon(Icons.camera_alt, size: 28), icon: const Icon(Icons.camera_alt, size: 28),
label: const Text("QR 인증 (카메라 켜기)"), label: Text(
tr('ui.userfront.login_success.qr', fallback: 'QR 인증 (카메라 켜기)'),
),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(80), // 버튼 높이를 더 크게 minimumSize: const Size.fromHeight(80), // 버튼 높이를 더 크게
backgroundColor: Colors.blue.shade700, backgroundColor: Colors.blue.shade700,
@@ -51,7 +54,13 @@ class LoginSuccessScreen extends StatelessWidget {
onPressed: () { onPressed: () {
context.go('/'); context.go('/');
}, },
child: const Text("나중에 하기 (대시보드로 이동)", style: TextStyle(color: Colors.grey)), child: Text(
tr(
'ui.userfront.login_success.later',
fallback: '나중에 하기 (대시보드로 이동)',
),
style: const TextStyle(color: Colors.grey),
),
), ),
], ],
), ),

View File

@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import '../../../core/services/auth_proxy_service.dart'; import '../../../core/services/auth_proxy_service.dart';
import '../../../core/services/auth_token_store.dart'; import '../../../core/services/auth_token_store.dart';
import 'package:userfront/i18n.dart';
class QRScanScreen extends StatefulWidget { class QRScanScreen extends StatefulWidget {
const QRScanScreen({super.key}); const QRScanScreen({super.key});
@@ -143,7 +144,10 @@ class _QRScanScreenState extends State<QRScanScreen> {
if (mounted) { if (mounted) {
setState(() { setState(() {
_isSuccess = true; _isSuccess = true;
_resultMessage = 'QR 승인 완료! PC 화면에서 로그인이 진행됩니다.'; _resultMessage = tr(
'msg.userfront.qr.approve_success',
fallback: 'QR 승인 완료! PC 화면에서 로그인이 진행됩니다.',
);
_isProcessing = false; _isProcessing = false;
}); });
} }
@@ -152,7 +156,11 @@ class _QRScanScreenState extends State<QRScanScreen> {
if (mounted) { if (mounted) {
setState(() { setState(() {
_isSuccess = false; _isSuccess = false;
_resultMessage = 'QR 승인 실패: $e'; _resultMessage = tr(
'msg.userfront.qr.approve_error',
fallback: 'QR 승인 실패: {{error}}',
params: {'error': '$e'},
);
_isProcessing = false; _isProcessing = false;
}); });
} }
@@ -181,8 +189,13 @@ class _QRScanScreenState extends State<QRScanScreen> {
_log.warning('Camera permission request failed: $e'); _log.warning('Camera permission request failed: $e');
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요.'), content: Text(
tr(
'msg.userfront.qr.permission_error',
fallback: '카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요.',
),
),
backgroundColor: Colors.red, backgroundColor: Colors.red,
), ),
); );
@@ -198,7 +211,9 @@ class _QRScanScreenState extends State<QRScanScreen> {
final success = _isSuccess == true; final success = _isSuccess == true;
final icon = success ? Icons.check_circle_outline : Icons.error_outline; final icon = success ? Icons.check_circle_outline : Icons.error_outline;
final color = success ? Colors.green : Colors.red; final color = success ? Colors.green : Colors.red;
final title = success ? '승인 완료' : '승인 실패'; final title = success
? tr('ui.userfront.qr.result_success', fallback: '승인 완료')
: tr('ui.userfront.qr.result_failure', fallback: '승인 실패');
final message = _resultMessage ?? ''; final message = _resultMessage ?? '';
return Center( return Center(
@@ -223,12 +238,12 @@ class _QRScanScreenState extends State<QRScanScreen> {
if (!success) if (!success)
FilledButton( FilledButton(
onPressed: _resetScan, onPressed: _resetScan,
child: const Text('다시 스캔'), child: Text(tr('ui.userfront.qr.rescan', fallback: '다시 스캔')),
), ),
if (success) if (success)
FilledButton( FilledButton(
onPressed: () => context.pop(), onPressed: () => context.pop(),
child: const Text('닫기'), child: Text(tr('ui.common.close', fallback: '닫기')),
), ),
], ],
), ),
@@ -240,7 +255,7 @@ class _QRScanScreenState extends State<QRScanScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Scan QR Code'), title: Text(tr('ui.userfront.qr.title', fallback: 'Scan QR Code')),
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () => context.pop(), onPressed: () => context.pop(),
@@ -263,8 +278,15 @@ class _QRScanScreenState extends State<QRScanScreen> {
const SizedBox(height: 10), const SizedBox(height: 10),
Text( Text(
isPermissionDenied isPermissionDenied
? '카메라 권한이 필요합니다.' ? tr(
: '카메라 오류: ${error.errorCode}', 'msg.userfront.qr.permission_required',
fallback: '카메라 권한이 필요합니다.',
)
: tr(
'msg.userfront.qr.camera_error',
fallback: '카메라 오류: {{error}}',
params: {'error': '${error.errorCode}'},
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
FilledButton( FilledButton(
@@ -273,8 +295,11 @@ class _QRScanScreenState extends State<QRScanScreen> {
: _requestCameraPermission, : _requestCameraPermission,
child: Text( child: Text(
_isRequestingCamera _isRequestingCamera
? '요청 중...' ? tr('ui.common.requesting', fallback: '요청 중...')
: '카메라 권한 요청하기', : tr(
'ui.userfront.qr.request_permission',
fallback: '카메라 권한 요청하기',
),
), ),
), ),
], ],

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/services/auth_proxy_service.dart'; import '../../../core/services/auth_proxy_service.dart';
import 'package:userfront/i18n.dart';
class ResetPasswordScreen extends StatefulWidget { class ResetPasswordScreen extends StatefulWidget {
final String? loginId; // Now receiving loginId final String? loginId; // Now receiving loginId
@@ -66,7 +67,12 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
Future<void> _handlePasswordReset() async { Future<void> _handlePasswordReset() async {
if (_formKey.currentState?.validate() != true) return; if (_formKey.currentState?.validate() != true) return;
if ((_loginId == null || _loginId!.isEmpty) && (_token == null || _token!.isEmpty)) { if ((_loginId == null || _loginId!.isEmpty) && (_token == null || _token!.isEmpty)) {
_showError("유효하지 않은 재설정 링크입니다. (loginId/token 누락)"); _showError(
tr(
'msg.userfront.reset.invalid_link',
fallback: '유효하지 않은 재설정 링크입니다. (loginId/token 누락)',
),
);
return; return;
} }
@@ -81,8 +87,13 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text("비밀번호가 성공적으로 변경되었습니다. 다시 로그인해주세요."), content: Text(
tr(
'msg.userfront.reset.success',
fallback: '비밀번호가 성공적으로 변경되었습니다. 다시 로그인해주세요.',
),
),
backgroundColor: Colors.green, backgroundColor: Colors.green,
), ),
); );
@@ -90,7 +101,13 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
_showError("비밀번호 변경에 실패했습니다: ${e.toString()}"); _showError(
tr(
'msg.userfront.reset.error.generic',
fallback: '비밀번호 변경에 실패했습니다: {{error}}',
params: {'error': e.toString()},
),
);
} }
} finally { } finally {
if (mounted) { if (mounted) {
@@ -107,7 +124,10 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
String _buildPolicyDescription() { String _buildPolicyDescription() {
if (_isPolicyLoading) { if (_isPolicyLoading) {
return "비밀번호 정책을 불러오는 중입니다..."; return tr(
'msg.userfront.reset.policy_loading',
fallback: '비밀번호 정책을 불러오는 중입니다...',
);
} }
final minLength = (_policy?['minLength'] as int?) ?? 12; final minLength = (_policy?['minLength'] as int?) ?? 12;
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0; final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
@@ -116,14 +136,42 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
final requiresNumber = _policy?['number'] ?? true; final requiresNumber = _policy?['number'] ?? true;
final requiresSymbol = _policy?['nonAlphanumeric'] ?? true; final requiresSymbol = _policy?['nonAlphanumeric'] ?? true;
final parts = <String>["최소 ${minLength}자 이상"]; final parts = <String>[
tr(
'msg.userfront.reset.policy.min_length',
fallback: '최소 {{count}}자 이상',
params: {'count': '$minLength'},
),
];
if (minTypes > 0) { if (minTypes > 0) {
parts.add("영문 대/소문자/숫자/특수문자 중 ${minTypes}가지 이상"); parts.add(
tr(
'msg.userfront.reset.policy.min_types',
fallback: '영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상',
params: {'count': '$minTypes'},
),
);
}
if (requiresLower) {
parts.add(
tr('msg.userfront.reset.policy.lowercase', fallback: '소문자 1개 이상'),
);
}
if (requiresUpper) {
parts.add(
tr('msg.userfront.reset.policy.uppercase', fallback: '대문자 1개 이상'),
);
}
if (requiresNumber) {
parts.add(
tr('msg.userfront.reset.policy.number', fallback: '숫자 1개 이상'),
);
}
if (requiresSymbol) {
parts.add(
tr('msg.userfront.reset.policy.symbol', fallback: '특수문자 1개 이상'),
);
} }
if (requiresLower) parts.add("소문자 1개 이상");
if (requiresUpper) parts.add("대문자 1개 이상");
if (requiresNumber) parts.add("숫자 1개 이상");
if (requiresSymbol) parts.add("특수문자 1개 이상");
return parts.join(", "); return parts.join(", ");
} }
@@ -132,7 +180,9 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text("새 비밀번호 설정"), title: Text(
tr('ui.userfront.reset.title', fallback: '새 비밀번호 설정'),
),
centerTitle: true, centerTitle: true,
), ),
body: Center( body: Center(
@@ -148,7 +198,10 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Text(
"새로운 비밀번호 설정", tr(
'ui.userfront.reset.subtitle',
fallback: '새로운 비밀번호 설정',
),
style: TextStyle( style: TextStyle(
fontSize: 28, fontSize: 28,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -166,7 +219,10 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
controller: _passwordController, controller: _passwordController,
obscureText: _isPasswordObscured, obscureText: _isPasswordObscured,
decoration: InputDecoration( decoration: InputDecoration(
labelText: "새 비밀번호", labelText: tr(
'ui.userfront.reset.new_password',
fallback: '새 비밀번호',
),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock_outline), prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton( suffixIcon: IconButton(
@@ -183,11 +239,18 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
validator: (value) { validator: (value) {
final val = value ?? ""; final val = value ?? "";
if (val.isEmpty) { if (val.isEmpty) {
return '비밀번호를 입력해주세요.'; return tr(
'msg.userfront.reset.error.empty_password',
fallback: '비밀번호를 입력해주세요.',
);
} }
final minLength = (_policy?['minLength'] as int?) ?? 12; final minLength = (_policy?['minLength'] as int?) ?? 12;
if (val.length < minLength) { if (val.length < minLength) {
return '비밀번호는 최소 $minLength자 이상이어야 합니다.'; return tr(
'msg.userfront.reset.error.min_length',
fallback: '비밀번호는 최소 {{count}}자 이상이어야 합니다.',
params: {'count': '$minLength'},
);
} }
final hasLower = RegExp(r'[a-z]').hasMatch(val); final hasLower = RegExp(r'[a-z]').hasMatch(val);
final hasUpper = RegExp(r'[A-Z]').hasMatch(val); final hasUpper = RegExp(r'[A-Z]').hasMatch(val);
@@ -201,20 +264,37 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0; final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
if (minTypes > 0 && typeCount < minTypes) { if (minTypes > 0 && typeCount < minTypes) {
return '비밀번호는 영문 대/소문자/숫자/특수문자 중 $minTypes가지 이상 포함해야 합니다.'; return tr(
'msg.userfront.reset.error.min_types',
fallback:
'비밀번호는 영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상 포함해야 합니다.',
params: {'count': '$minTypes'},
);
} }
if ((_policy?['lowercase'] ?? true) && !hasLower) { if ((_policy?['lowercase'] ?? true) && !hasLower) {
return '최소 1개 이상의 소문자를 포함해야 합니다.'; return tr(
'msg.userfront.reset.error.lowercase',
fallback: '최소 1개 이상의 소문자를 포함해야 합니다.',
);
} }
if ((_policy?['uppercase'] ?? false) && !hasUpper) { if ((_policy?['uppercase'] ?? false) && !hasUpper) {
return '최소 1개 이상의 대문자를 포함해야 합니다.'; return tr(
'msg.userfront.reset.error.uppercase',
fallback: '최소 1개 이상의 대문자를 포함해야 합니다.',
);
} }
if ((_policy?['number'] ?? true) && !hasNumber) { if ((_policy?['number'] ?? true) && !hasNumber) {
return '최소 1개 이상의 숫자를 포함해야 합니다.'; return tr(
'msg.userfront.reset.error.number',
fallback: '최소 1개 이상의 숫자를 포함해야 합니다.',
);
} }
if ((_policy?['nonAlphanumeric'] ?? true) && !hasSymbol) { if ((_policy?['nonAlphanumeric'] ?? true) && !hasSymbol) {
return '최소 1개 이상의 특수문자를 포함해야 합니다.'; return tr(
'msg.userfront.reset.error.symbol',
fallback: '최소 1개 이상의 특수문자를 포함해야 합니다.',
);
} }
return null; return null;
}, },
@@ -224,7 +304,10 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
controller: _confirmPasswordController, controller: _confirmPasswordController,
obscureText: _isConfirmPasswordObscured, obscureText: _isConfirmPasswordObscured,
decoration: InputDecoration( decoration: InputDecoration(
labelText: "새 비밀번호 확인", labelText: tr(
'ui.userfront.reset.confirm_password',
fallback: '새 비밀번호 확인',
),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock_outline), prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton( suffixIcon: IconButton(
@@ -240,7 +323,10 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
), ),
validator: (value) { validator: (value) {
if (value != _passwordController.text) { if (value != _passwordController.text) {
return '비밀번호가 일치하지 않습니다.'; return tr(
'msg.userfront.reset.error.mismatch',
fallback: '비밀번호가 일치하지 않습니다.',
);
} }
return null; return null;
}, },
@@ -255,9 +341,17 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
? const SizedBox( ? const SizedBox(
height: 20, height: 20,
width: 20, width: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
) )
: const Text("비밀번호 변경"), : Text(
tr(
'ui.userfront.reset.submit',
fallback: '비밀번호 변경',
),
),
), ),
], ],
), ),
@@ -268,20 +362,24 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
} }
Widget _buildInvalidTokenView() { Widget _buildInvalidTokenView() {
return const Center( return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.error_outline, color: Colors.red, size: 60), const Icon(Icons.error_outline, color: Colors.red, size: 60),
SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
"유효하지 않은 링크입니다.", tr('msg.userfront.reset.invalid_title',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), fallback: '유효하지 않은 링크입니다.'),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
"비밀번호 재설정 링크가 만료되었거나 잘못되었습니다. 다시 시도해주세요.", tr(
'msg.userfront.reset.invalid_body',
fallback: '비밀번호 재설정 링크가 만료되었거나 잘못되었습니다. 다시 시도해주세요.',
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
], ],

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:userfront/i18n.dart';
import '../../../core/services/auth_proxy_service.dart'; import '../../../core/services/auth_proxy_service.dart';
class SignupScreen extends StatefulWidget { class SignupScreen extends StatefulWidget {
@@ -130,8 +131,11 @@ class _SignupScreenState extends State<SignupScreen> {
_emailTimer = Timer.periodic(const Duration(seconds: 1), (timer) { _emailTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
if (_emailSeconds > 0) _emailSeconds--; if (_emailSeconds > 0) {
else timer.cancel(); _emailSeconds--;
} else {
timer.cancel();
}
}); });
}); });
} else { } else {
@@ -140,8 +144,11 @@ class _SignupScreenState extends State<SignupScreen> {
_phoneTimer = Timer.periodic(const Duration(seconds: 1), (timer) { _phoneTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
if (_phoneSeconds > 0) _phoneSeconds--; if (_phoneSeconds > 0) {
else timer.cancel(); _phoneSeconds--;
} else {
timer.cancel();
}
}); });
}); });
} }
@@ -157,20 +164,30 @@ class _SignupScreenState extends State<SignupScreen> {
final email = _emailController.text.trim(); final email = _emailController.text.trim();
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$'); final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
if (!emailRegex.hasMatch(email)) { if (!emailRegex.hasMatch(email)) {
setState(() => _emailError = '유효한 이메일 형식이 아닙니다.'); setState(() => _emailError = tr(
'msg.userfront.signup.email.invalid',
fallback: '유효한 이메일 형식이 아닙니다.',
));
return; return;
} }
setState(() { _isLoading = true; _emailError = null; }); setState(() { _isLoading = true; _emailError = null; });
try { try {
final available = await AuthProxyService.checkEmailAvailability(email); final available = await AuthProxyService.checkEmailAvailability(email);
if (!available) { if (!available) {
setState(() => _emailError = '이미 가입된 이메일입니다.'); setState(() => _emailError = tr(
'msg.userfront.signup.email.duplicate',
fallback: '이미 가입된 이메일입니다.',
));
return; return;
} }
await AuthProxyService.sendSignupCode(email, 'email'); await AuthProxyService.sendSignupCode(email, 'email');
_startTimer('email'); _startTimer('email');
} catch (e) { } catch (e) {
setState(() => _emailError = '발송 실패: $e'); setState(() => _emailError = tr(
'msg.userfront.signup.email.send_failed',
fallback: '발송 실패: {{error}}',
params: {'error': e.toString()},
));
} finally { } finally {
setState(() => _isLoading = false); setState(() => _isLoading = false);
} }
@@ -189,10 +206,17 @@ class _SignupScreenState extends State<SignupScreen> {
_emailError = null; _emailError = null;
}); });
} else { } else {
setState(() => _emailError = '인증코드가 일치하지 않습니다.'); setState(() => _emailError = tr(
'msg.userfront.signup.email.code_mismatch',
fallback: '인증코드가 일치하지 않습니다.',
));
} }
} catch (e) { } catch (e) {
setState(() => _emailError = '인증 실패: $e'); setState(() => _emailError = tr(
'msg.userfront.signup.email.verify_failed',
fallback: '인증 실패: {{error}}',
params: {'error': e.toString()},
));
} }
} }
@@ -204,7 +228,11 @@ class _SignupScreenState extends State<SignupScreen> {
await AuthProxyService.sendSignupCode(phone, 'phone'); await AuthProxyService.sendSignupCode(phone, 'phone');
_startTimer('phone'); _startTimer('phone');
} catch (e) { } catch (e) {
setState(() => _phoneError = '발송 실패: $e'); setState(() => _phoneError = tr(
'msg.userfront.signup.phone.send_failed',
fallback: '발송 실패: {{error}}',
params: {'error': e.toString()},
));
} finally { } finally {
setState(() => _isLoading = false); setState(() => _isLoading = false);
} }
@@ -223,16 +251,26 @@ class _SignupScreenState extends State<SignupScreen> {
_phoneError = null; _phoneError = null;
}); });
} else { } else {
setState(() => _phoneError = '인증코드가 일치하지 않습니다.'); setState(() => _phoneError = tr(
'msg.userfront.signup.phone.code_mismatch',
fallback: '인증코드가 일치하지 않습니다.',
));
} }
} catch (e) { } catch (e) {
setState(() => _phoneError = '인증 실패: $e'); setState(() => _phoneError = tr(
'msg.userfront.signup.phone.verify_failed',
fallback: '인증 실패: {{error}}',
params: {'error': e.toString()},
));
} }
} }
Future<void> _handleSignup() async { Future<void> _handleSignup() async {
if (_passwordController.text != _confirmPasswordController.text) { if (_passwordController.text != _confirmPasswordController.text) {
setState(() => _confirmPasswordError = '비밀번호가 일치하지 않습니다.'); setState(() => _confirmPasswordError = tr(
'msg.userfront.signup.password.mismatch',
fallback: '비밀번호가 일치하지 않습니다.',
));
return; return;
} }
if (!_formKey.currentState!.validate()) return; if (!_formKey.currentState!.validate()) return;
@@ -257,12 +295,38 @@ class _SignupScreenState extends State<SignupScreen> {
} catch (e) { } catch (e) {
String eStr = e.toString().toLowerCase(); String eStr = e.toString().toLowerCase();
setState(() { setState(() {
if (eStr.contains('uppercase')) _passwordError = '대문자가 최소 1개 이상 포함되어야 합니다.'; if (eStr.contains('uppercase')) {
else if (eStr.contains('lowercase')) _passwordError = '소문자가 최소 1개 이상 포함되어야 합니다.'; _passwordError = tr(
else if (eStr.contains('digit') || eStr.contains('number')) _passwordError = '숫자가 최소 1개 이상 포함되어야 합니다.'; 'msg.userfront.signup.password.uppercase_required',
else if (eStr.contains('symbol') || eStr.contains('special')) _passwordError = '특수문자가 최소 1개 이상 포함되어야 합니다.'; fallback: '문자가 최소 1개 이상 포함되어야 합니다.',
else if (eStr.contains('length') || eStr.contains('12 characters')) _passwordError = '비밀번호는 최소 12자 이상이어야 합니다.'; );
else _passwordError = '가입 실패: $e'; } else if (eStr.contains('lowercase')) {
_passwordError = tr(
'msg.userfront.signup.password.lowercase_required',
fallback: '소문자가 최소 1개 이상 포함되어야 합니다.',
);
} else if (eStr.contains('digit') || eStr.contains('number')) {
_passwordError = tr(
'msg.userfront.signup.password.number_required',
fallback: '숫자가 최소 1개 이상 포함되어야 합니다.',
);
} else if (eStr.contains('symbol') || eStr.contains('special')) {
_passwordError = tr(
'msg.userfront.signup.password.symbol_required',
fallback: '특수문자가 최소 1개 이상 포함되어야 합니다.',
);
} else if (eStr.contains('length') || eStr.contains('12 characters')) {
_passwordError = tr(
'msg.userfront.signup.password.length_required',
fallback: '비밀번호는 최소 12자 이상이어야 합니다.',
);
} else {
_passwordError = tr(
'msg.userfront.signup.failed',
fallback: '가입 실패: {{error}}',
params: {'error': e.toString()},
);
}
}); });
} finally { } finally {
setState(() => _isLoading = false); setState(() => _isLoading = false);
@@ -274,9 +338,20 @@ class _SignupScreenState extends State<SignupScreen> {
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('회원가입 완료'), title: Text(
content: const Text('성공적으로 가입되었습니다.'), tr('msg.userfront.signup.success.title', fallback: '회원가입 완료'),
actions: [TextButton(onPressed: () => context.go('/signin'), child: const Text('로그인하기'))], ),
content: Text(
tr('msg.userfront.signup.success.body', fallback: '성공적으로 가입되었습니다.'),
),
actions: [
TextButton(
onPressed: () => context.go('/signin'),
child: Text(
tr('ui.userfront.signup.success.action', fallback: '로그인하기'),
),
),
],
), ),
); );
} }
@@ -288,13 +363,25 @@ class _SignupScreenState extends State<SignupScreen> {
padding: const EdgeInsets.symmetric(vertical: 20), padding: const EdgeInsets.symmetric(vertical: 20),
child: Row( child: Row(
children: [ children: [
_stepCircle(1, '약관동의'), _stepCircle(
1,
tr('ui.userfront.signup.steps.agreement', fallback: '약관동의'),
),
_stepLine(1), _stepLine(1),
_stepCircle(2, '본인인증'), _stepCircle(
2,
tr('ui.userfront.signup.steps.verify', fallback: '본인인증'),
),
_stepLine(2), _stepLine(2),
_stepCircle(3, '정보입력'), _stepCircle(
3,
tr('ui.userfront.signup.steps.profile', fallback: '정보입력'),
),
_stepLine(3), _stepLine(3),
_stepCircle(4, '비밀번호'), _stepCircle(
4,
tr('ui.userfront.signup.steps.password', fallback: '비밀번호'),
),
], ],
), ),
); );
@@ -330,9 +417,17 @@ class _SignupScreenState extends State<SignupScreen> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text('서비스 이용을 위해\n약관에 동의해주세요', Text(
style: TextStyle( tr(
fontSize: 20, fontWeight: FontWeight.bold, height: 1.3)), 'msg.userfront.signup.agreement.title',
fallback: '서비스 이용을 위해\n약관에 동의해주세요',
),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
height: 1.3,
),
),
const SizedBox(height: 24), const SizedBox(height: 24),
// 모두 동의 버튼 // 모두 동의 버튼
Container( Container(
@@ -342,8 +437,13 @@ class _SignupScreenState extends State<SignupScreen> {
border: Border.all(color: Colors.grey[200]!), border: Border.all(color: Colors.grey[200]!),
), ),
child: CheckboxListTile( child: CheckboxListTile(
title: const Text('모두 동의합니다', title: Text(
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)), tr(
'ui.userfront.signup.agreement.all',
fallback: '모두 동의합니다',
),
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
),
value: _termsAccepted && _privacyAccepted, value: _termsAccepted && _privacyAccepted,
onChanged: (val) { onChanged: (val) {
setState(() { setState(() {
@@ -357,14 +457,20 @@ class _SignupScreenState extends State<SignupScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_agreementSection( _agreementSection(
title: '바론 소프트웨어 이용약관 (필수)', title: tr(
'ui.userfront.signup.agreement.tos_title',
fallback: '바론 소프트웨어 이용약관 (필수)',
),
content: _tosText, content: _tosText,
value: _termsAccepted, value: _termsAccepted,
onChanged: (val) => setState(() => _termsAccepted = val!), onChanged: (val) => setState(() => _termsAccepted = val!),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_agreementSection( _agreementSection(
title: '개인정보 수집 및 이용 동의 (필수)', title: tr(
'ui.userfront.signup.agreement.privacy_title',
fallback: '개인정보 수집 및 이용 동의 (필수)',
),
content: _privacyText, content: _privacyText,
value: _privacyAccepted, value: _privacyAccepted,
onChanged: (val) => setState(() => _privacyAccepted = val!), onChanged: (val) => setState(() => _privacyAccepted = val!),
@@ -410,7 +516,9 @@ class _SignupScreenState extends State<SignupScreen> {
); );
} }
static const String _tosText = """ static String get _tosText => tr(
'msg.userfront.signup.tos_full',
fallback: """
바론 소프트웨어 이용약관 바론 소프트웨어 이용약관
제1장 총칙 제1장 총칙
@@ -480,9 +588,12 @@ class _SignupScreenState extends State<SignupScreen> {
본 약관에 따른 분쟁은 서울중앙지방법원을 관할 법원으로 합니다. 본 약관에 따른 분쟁은 서울중앙지방법원을 관할 법원으로 합니다.
부칙 부칙
본 약관은 2024년 10월 1일부터 시행됩니다. 본 약관은 2024년 10월 1일부터 시행됩니다.
"""; """,
);
static const String _privacyText = """ static String get _privacyText => tr(
'msg.userfront.signup.privacy_full',
fallback: """
개인정보 수집 및 이용 동의 개인정보 수집 및 이용 동의
바론서비스 개인정보처리방침 바론서비스 개인정보처리방침
@@ -590,33 +701,46 @@ class _SignupScreenState extends State<SignupScreen> {
회사는 이용자의 개인정보를 국외로 이전하지 않으며, 향후 필요한 경우, 사전에 이용자의 동의를 받습니다. 회사는 이용자의 개인정보를 국외로 이전하지 않으며, 향후 필요한 경우, 사전에 이용자의 동의를 받습니다.
제8조 (기타) 제8조 (기타)
본 방침에 명시되지 않은 사항은 회사의 내부 방침과 관련 법령에 따릅니다. 본 방침에 명시되지 않은 사항은 회사의 내부 방침과 관련 법령에 따릅니다.
"""; """,
);
Widget _buildStepAuth() { Widget _buildStepAuth() {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text('본인 확인을 위해\n인증을 진행해주세요', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), Text(
tr(
'msg.userfront.signup.auth.title',
fallback: '본인 확인을 위해\n인증을 진행해주세요',
),
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16), const SizedBox(height: 16),
// 가족사 이메일 안내 문구 // 가족사 이메일 안내 문구
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(color: Colors.blue[50], borderRadius: BorderRadius.circular(6)), decoration: BoxDecoration(color: Colors.blue[50], borderRadius: BorderRadius.circular(6)),
child: const Row( child: Row(
children: [ children: [
Icon(Icons.info_outline, size: 16, color: Colors.blue), const Icon(Icons.info_outline, size: 16, color: Colors.blue),
SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
'가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요.', tr(
style: TextStyle(fontSize: 12, color: Colors.blue, fontWeight: FontWeight.w500), 'msg.userfront.signup.auth.affiliate_notice',
fallback: '가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요.',
),
style: const TextStyle(fontSize: 12, color: Colors.blue, fontWeight: FontWeight.w500),
), ),
), ),
], ],
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text('이메일 인증', style: const TextStyle(fontWeight: FontWeight.bold)), Text(
tr('ui.userfront.signup.auth.email.title', fallback: '이메일 인증'),
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Row( Row(
children: [ children: [
@@ -625,7 +749,10 @@ class _SignupScreenState extends State<SignupScreen> {
controller: _emailController, controller: _emailController,
onChanged: _checkEmailAffiliation, // 도메인 실시간 체크 onChanged: _checkEmailAffiliation, // 도메인 실시간 체크
decoration: InputDecoration( decoration: InputDecoration(
labelText: '이메일 주소', labelText: tr(
'ui.userfront.signup.auth.email.label',
fallback: '이메일 주소',
),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
errorText: _emailError, errorText: _emailError,
hintText: 'example@hanmaceng.co.kr', hintText: 'example@hanmaceng.co.kr',
@@ -639,7 +766,14 @@ class _SignupScreenState extends State<SignupScreen> {
child: ElevatedButton( child: ElevatedButton(
onPressed: (_isEmailVerified || _isLoading) ? null : _sendEmailCode, onPressed: (_isEmailVerified || _isLoading) ? null : _sendEmailCode,
style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[100], foregroundColor: Colors.black, elevation: 0), style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[100], foregroundColor: Colors.black, elevation: 0),
child: Text(_emailSeconds > 0 ? '재발송' : '인증요청'), child: Text(
_emailSeconds > 0
? tr('ui.common.resend', fallback: '재발송')
: tr(
'ui.userfront.signup.auth.request_code',
fallback: '인증요청',
),
),
), ),
), ),
], ],
@@ -649,7 +783,10 @@ class _SignupScreenState extends State<SignupScreen> {
TextFormField( TextFormField(
controller: _emailCodeController, controller: _emailCodeController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: '인증코드 6자리', labelText: tr(
'ui.userfront.signup.auth.code_label',
fallback: '인증코드 6자리',
),
suffixText: _formatTime(_emailSeconds), suffixText: _formatTime(_emailSeconds),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
@@ -658,19 +795,40 @@ class _SignupScreenState extends State<SignupScreen> {
onChanged: (val) { if(val.length == 6) _verifyEmailCode(); }, onChanged: (val) { if(val.length == 6) _verifyEmailCode(); },
), ),
], ],
if (_isEmailVerified) const Padding( if (_isEmailVerified)
padding: EdgeInsets.only(top: 8), Padding(
child: Text('✅ 이메일 인증 완료', style: TextStyle(color: Colors.green, fontSize: 13, fontWeight: FontWeight.bold)), padding: const EdgeInsets.only(top: 8),
), child: Text(
tr(
'msg.userfront.signup.email.verified',
fallback: '✅ 이메일 인증 완료',
),
style: const TextStyle(
color: Colors.green,
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 24), const SizedBox(height: 24),
Text('휴대폰 인증', style: const TextStyle(fontWeight: FontWeight.bold)), Text(
tr('ui.userfront.signup.phone.title', fallback: '휴대폰 인증'),
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Row( Row(
children: [ children: [
Expanded( Expanded(
child: TextFormField( child: TextFormField(
controller: _phoneController, controller: _phoneController,
decoration: InputDecoration(labelText: '휴대폰 번호 (-없이)', border: const OutlineInputBorder(), errorText: _phoneError), decoration: InputDecoration(
labelText: tr(
'ui.userfront.signup.phone.label',
fallback: '휴대폰 번호 (-없이)',
),
border: const OutlineInputBorder(),
errorText: _phoneError,
),
readOnly: _isPhoneVerified, readOnly: _isPhoneVerified,
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
), ),
@@ -681,7 +839,14 @@ class _SignupScreenState extends State<SignupScreen> {
child: ElevatedButton( child: ElevatedButton(
onPressed: (_isPhoneVerified || _isLoading) ? null : _sendPhoneCode, onPressed: (_isPhoneVerified || _isLoading) ? null : _sendPhoneCode,
style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[100], foregroundColor: Colors.black, elevation: 0), style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[100], foregroundColor: Colors.black, elevation: 0),
child: Text(_phoneSeconds > 0 ? '재발송' : '인증요청'), child: Text(
_phoneSeconds > 0
? tr('ui.common.resend', fallback: '재발송')
: tr(
'ui.userfront.signup.auth.request_code',
fallback: '인증요청',
),
),
), ),
), ),
], ],
@@ -691,7 +856,10 @@ class _SignupScreenState extends State<SignupScreen> {
TextFormField( TextFormField(
controller: _phoneCodeController, controller: _phoneCodeController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: '인증코드 6자리', labelText: tr(
'ui.userfront.signup.auth.code_label',
fallback: '인증코드 6자리',
),
suffixText: _formatTime(_phoneSeconds), suffixText: _formatTime(_phoneSeconds),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
@@ -700,10 +868,21 @@ class _SignupScreenState extends State<SignupScreen> {
onChanged: (val) { if(val.length == 6) _verifyPhoneCode(); }, onChanged: (val) { if(val.length == 6) _verifyPhoneCode(); },
), ),
], ],
if (_isPhoneVerified) const Padding( if (_isPhoneVerified)
padding: EdgeInsets.only(top: 8), Padding(
child: Text('✅ 휴대폰 인증 완료', style: TextStyle(color: Colors.green, fontSize: 13, fontWeight: FontWeight.bold)), padding: const EdgeInsets.only(top: 8),
), child: Text(
tr(
'msg.userfront.signup.phone.verified',
fallback: '✅ 휴대폰 인증 완료',
),
style: const TextStyle(
color: Colors.green,
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
),
], ],
); );
} }
@@ -712,12 +891,24 @@ class _SignupScreenState extends State<SignupScreen> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text('회원님의\n소속 정보를 알려주세요', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), Text(
tr(
'msg.userfront.signup.profile.title',
fallback: '회원님의\n소속 정보를 알려주세요',
),
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 24), const SizedBox(height: 24),
TextFormField( TextFormField(
controller: _nameController, controller: _nameController,
onChanged: (_) => setState(() {}), onChanged: (_) => setState(() {}),
decoration: const InputDecoration(labelText: '이름', border: OutlineInputBorder()), decoration: InputDecoration(
labelText: tr(
'ui.userfront.signup.profile.name',
fallback: '이름',
),
border: const OutlineInputBorder(),
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// 소속 유형 선택 (가족사 메일일 경우 비활성화) // 소속 유형 선택 (가족사 메일일 경우 비활성화)
@@ -726,17 +917,51 @@ class _SignupScreenState extends State<SignupScreen> {
child: Opacity( child: Opacity(
opacity: _isAffiliateEmail ? 0.7 : 1.0, opacity: _isAffiliateEmail ? 0.7 : 1.0,
child: DropdownButtonFormField<String>( child: DropdownButtonFormField<String>(
value: _affiliationType, key: ValueKey(_affiliationType),
initialValue: _affiliationType,
decoration: InputDecoration( decoration: InputDecoration(
labelText: '소속 유형', labelText: tr(
'ui.userfront.signup.profile.affiliation_type',
fallback: '소속 유형',
),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
helperText: _isAffiliateEmail ? '가족사 이메일 사용 시 자동으로 선택됩니다.' : null, helperText: _isAffiliateEmail
? tr(
'msg.userfront.signup.profile.affiliate_hint',
fallback: '가족사 이메일 사용 시 자동으로 선택됩니다.',
)
: null,
), ),
items: const [ items: [
DropdownMenuItem(value: 'GENERAL', child: Text('일반 사용자')), DropdownMenuItem(
DropdownMenuItem(value: 'AFFILIATE', child: Text('가족사 임직원')), value: 'GENERAL',
child: Text(
tr(
'domain.affiliation.general',
fallback: '일반 사용자',
),
),
),
DropdownMenuItem(
value: 'AFFILIATE',
child: Text(
tr(
'domain.affiliation.affiliate',
fallback: '가족사 임직원',
),
),
),
], ],
onChanged: _isAffiliateEmail ? null : (val) => setState(() { _affiliationType = val!; }), onChanged: _isAffiliateEmail
? null
: (val) {
if (val == null) {
return;
}
setState(() {
_affiliationType = val;
});
},
), ),
), ),
), ),
@@ -748,17 +973,56 @@ class _SignupScreenState extends State<SignupScreen> {
child: Opacity( child: Opacity(
opacity: _isAffiliateEmail ? 0.7 : 1.0, opacity: _isAffiliateEmail ? 0.7 : 1.0,
child: DropdownButtonFormField<String>( child: DropdownButtonFormField<String>(
value: _companyCode, key: ValueKey(_companyCode ?? 'none'),
decoration: const InputDecoration(labelText: '가족사 선택', border: OutlineInputBorder()), initialValue: _companyCode,
items: const [ decoration: InputDecoration(
DropdownMenuItem(value: 'HANMAC', child: Text('한맥')), labelText: tr(
DropdownMenuItem(value: 'SAMAN', child: Text('삼안')), 'ui.userfront.signup.profile.company',
DropdownMenuItem(value: 'PTC', child: Text('PTC')), fallback: '가족사 선택',
DropdownMenuItem(value: 'JANGHEON', child: Text('장헌')), ),
DropdownMenuItem(value: 'BARON', child: Text('바론')), border: const OutlineInputBorder(),
DropdownMenuItem(value: 'HALLA', child: Text('한라')), ),
items: [
DropdownMenuItem(
value: 'HANMAC',
child: Text(
tr('domain.company.hanmac', fallback: '한맥'),
),
),
DropdownMenuItem(
value: 'SAMAN',
child: Text(
tr('domain.company.saman', fallback: '삼안'),
),
),
DropdownMenuItem(
value: 'PTC',
child: Text(
tr('domain.company.ptc', fallback: 'PTC'),
),
),
DropdownMenuItem(
value: 'JANGHEON',
child: Text(
tr('domain.company.jangheon', fallback: '장헌'),
),
),
DropdownMenuItem(
value: 'BARON',
child: Text(
tr('domain.company.baron', fallback: '바론'),
),
),
DropdownMenuItem(
value: 'HALLA',
child: Text(
tr('domain.company.halla', fallback: '한라'),
),
),
], ],
onChanged: _isAffiliateEmail ? null : (val) => setState(() => _companyCode = val), onChanged: _isAffiliateEmail
? null
: (val) => setState(() => _companyCode = val),
), ),
), ),
), ),
@@ -768,7 +1032,12 @@ class _SignupScreenState extends State<SignupScreen> {
controller: _deptController, controller: _deptController,
onChanged: (_) => setState(() {}), onChanged: (_) => setState(() {}),
decoration: InputDecoration( decoration: InputDecoration(
labelText: _affiliationType == 'AFFILIATE' ? '부서명' : '소속 정보 (선택)', labelText: _affiliationType == 'AFFILIATE'
? tr('ui.userfront.signup.profile.department', fallback: '부서명')
: tr(
'ui.userfront.signup.profile.department_optional',
fallback: '소속 정보 (선택)',
),
border: const OutlineInputBorder() border: const OutlineInputBorder()
), ),
), ),
@@ -778,7 +1047,10 @@ class _SignupScreenState extends State<SignupScreen> {
String _buildPolicyDescription() { String _buildPolicyDescription() {
if (_isPolicyLoading) { if (_isPolicyLoading) {
return "비밀번호 정책을 불러오는 중입니다..."; return tr(
'msg.userfront.signup.policy.loading',
fallback: '비밀번호 정책을 불러오는 중입니다...',
);
} }
final minLength = (_policy?['minLength'] as int?) ?? 12; final minLength = (_policy?['minLength'] as int?) ?? 12;
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0; final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
@@ -787,16 +1059,60 @@ class _SignupScreenState extends State<SignupScreen> {
final requiresNumber = _policy?['number'] ?? true; final requiresNumber = _policy?['number'] ?? true;
final requiresSymbol = _policy?['nonAlphanumeric'] ?? true; final requiresSymbol = _policy?['nonAlphanumeric'] ?? true;
final parts = <String>["최소 $minLength자 이상"]; final parts = <String>[
tr(
'msg.userfront.signup.policy.min_length',
fallback: '최소 {{count}}자 이상',
params: {'count': minLength.toString()},
),
];
if (minTypes > 0) { if (minTypes > 0) {
parts.add("영문 대/소문자/숫자/특수문자 중 ${minTypes}가지 이상"); parts.add(
tr(
'msg.userfront.signup.policy.min_types',
fallback: '영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상',
params: {'count': minTypes.toString()},
),
);
}
if (requiresUpper) {
parts.add(
tr(
'msg.userfront.signup.policy.uppercase',
fallback: '대문자',
),
);
}
if (requiresLower) {
parts.add(
tr(
'msg.userfront.signup.policy.lowercase',
fallback: '소문자',
),
);
}
if (requiresNumber) {
parts.add(
tr(
'msg.userfront.signup.policy.number',
fallback: '숫자',
),
);
}
if (requiresSymbol) {
parts.add(
tr(
'msg.userfront.signup.policy.symbol',
fallback: '특수문자',
),
);
} }
if (requiresUpper) parts.add("대문자");
if (requiresLower) parts.add("소문자");
if (requiresNumber) parts.add("숫자");
if (requiresSymbol) parts.add("특수문자");
return "보안 정책: ${parts.join(', ')}"; return tr(
'msg.userfront.signup.policy.summary',
fallback: '보안 정책: {{rules}}',
params: {'rules': parts.join(', ')},
);
} }
Widget _buildStepPassword() { Widget _buildStepPassword() {
@@ -825,7 +1141,13 @@ class _SignupScreenState extends State<SignupScreen> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text('마지막으로\n비밀번호를 설정해주세요', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), Text(
tr(
'msg.userfront.signup.password.title',
fallback: '마지막으로\n비밀번호를 설정해주세요',
),
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16), const SizedBox(height: 16),
// 비밀번호 정책 안내 박스 // 비밀번호 정책 안내 박스
Container( Container(
@@ -850,7 +1172,10 @@ class _SignupScreenState extends State<SignupScreen> {
obscureText: true, obscureText: true,
onChanged: (_) => setState(() {}), onChanged: (_) => setState(() {}),
decoration: InputDecoration( decoration: InputDecoration(
labelText: '비밀번호', labelText: tr(
'ui.userfront.signup.password.label',
fallback: '비밀번호',
),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
errorText: _passwordError, errorText: _passwordError,
), ),
@@ -859,12 +1184,55 @@ class _SignupScreenState extends State<SignupScreen> {
Wrap( Wrap(
spacing: 10, spacing: 10,
children: [ children: [
_cryptoCheck('$minLength자 이상', hasLength), _cryptoCheck(
if (minTypes > 0) _cryptoCheck('문자 유형 ${minTypes}가지 이상', hasTypeCount), tr(
if (requiresUpper) _cryptoCheck('대문자', hasUpper), 'msg.userfront.signup.password.rule.min_length',
if (requiresLower) _cryptoCheck('소문자', hasLower), fallback: '{{count}}자 이상',
if (requiresNumber) _cryptoCheck('숫자', hasDigit), params: {'count': minLength.toString()},
if (requiresSymbol) _cryptoCheck('특수문자', hasSpecial), ),
hasLength,
),
if (minTypes > 0)
_cryptoCheck(
tr(
'msg.userfront.signup.password.rule.min_types',
fallback: '문자 유형 {{count}}가지 이상',
params: {'count': minTypes.toString()},
),
hasTypeCount,
),
if (requiresUpper)
_cryptoCheck(
tr(
'msg.userfront.signup.password.rule.uppercase',
fallback: '대문자',
),
hasUpper,
),
if (requiresLower)
_cryptoCheck(
tr(
'msg.userfront.signup.password.rule.lowercase',
fallback: '소문자',
),
hasLower,
),
if (requiresNumber)
_cryptoCheck(
tr(
'msg.userfront.signup.password.rule.number',
fallback: '숫자',
),
hasDigit,
),
if (requiresSymbol)
_cryptoCheck(
tr(
'msg.userfront.signup.password.rule.symbol',
fallback: '특수문자',
),
hasSpecial,
),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -873,11 +1241,19 @@ class _SignupScreenState extends State<SignupScreen> {
obscureText: true, obscureText: true,
onChanged: (val) { onChanged: (val) {
setState(() { setState(() {
_confirmPasswordError = (val != _passwordController.text) ? '비밀번호가 일치하지 않습니다.' : null; _confirmPasswordError = (val != _passwordController.text)
? tr(
'msg.userfront.signup.password.mismatch',
fallback: '비밀번호가 일치하지 않습니다.',
)
: null;
}); });
}, },
decoration: InputDecoration( decoration: InputDecoration(
labelText: '비밀번호 확인', labelText: tr(
'ui.userfront.signup.password.confirm_label',
fallback: '비밀번호 확인',
),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
errorText: _confirmPasswordError, errorText: _confirmPasswordError,
), ),
@@ -917,7 +1293,10 @@ class _SignupScreenState extends State<SignupScreen> {
return Scaffold( return Scaffold(
backgroundColor: Colors.white, backgroundColor: Colors.white,
appBar: AppBar( appBar: AppBar(
title: Text('회원가입', style: TextStyle(fontWeight: FontWeight.bold)), title: Text(
tr('ui.userfront.signup.title', fallback: '회원가입'),
style: const TextStyle(fontWeight: FontWeight.bold),
),
elevation: 0, elevation: 0,
backgroundColor: Colors.white, backgroundColor: Colors.white,
foregroundColor: Colors.black, foregroundColor: Colors.black,
@@ -951,7 +1330,10 @@ class _SignupScreenState extends State<SignupScreen> {
child: OutlinedButton( child: OutlinedButton(
onPressed: () => setState(() => _currentStep--), onPressed: () => setState(() => _currentStep--),
style: OutlinedButton.styleFrom(minimumSize: const Size.fromHeight(55), side: const BorderSide(color: Colors.black)), style: OutlinedButton.styleFrom(minimumSize: const Size.fromHeight(55), side: const BorderSide(color: Colors.black)),
child: const Text('이전', style: TextStyle(color: Colors.black)), child: Text(
tr('ui.common.prev', fallback: '이전'),
style: const TextStyle(color: Colors.black),
),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -967,7 +1349,11 @@ class _SignupScreenState extends State<SignupScreen> {
), ),
child: _isLoading child: _isLoading
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) ? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: Text(_currentStep < 4 ? '다음 단계' : '가입 완료'), : Text(
_currentStep < 4
? tr('ui.userfront.signup.next_step', fallback: '다음 단계')
: tr('ui.userfront.signup.complete', fallback: '가입 완료'),
),
), ),
), ),
], ],

View File

@@ -3,9 +3,9 @@ import 'dart:convert';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/services/auth_proxy_service.dart';
import '../../../../core/services/auth_token_store.dart'; import '../../../../core/services/auth_token_store.dart';
import '../../../../core/services/http_client.dart'; import '../../../../core/services/http_client.dart';
import 'package:userfront/i18n.dart';
import 'models.dart'; import 'models.dart';
String _envOrDefault(String key, String fallback) { String _envOrDefault(String key, String fallback) {
@@ -17,50 +17,6 @@ String _envOrDefault(String key, String fallback) {
String get _baseUrl => _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr'); String get _baseUrl => _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
Future<List<LinkedRp>> _fetchLinkedRps() async {
final items = await AuthProxyService.fetchLinkedRps();
final result = <LinkedRp>[];
for (final item in items) {
if (item is Map) {
result.add(LinkedRp.fromJson(Map<String, dynamic>.from(item)));
}
}
return result;
}
Future<List<RpHistoryItem>> _fetchRpHistory() async {
final url = Uri.parse('$_baseUrl/api/v1/user/rp/history');
final useCookie = AuthTokenStore.usesCookie();
final token = AuthTokenStore.getToken();
final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{
'Content-Type': 'application/json',
};
if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token';
}
try {
final response = await client.get(url, headers: headers);
if (response.statusCode != 200) {
throw Exception('Failed to load rp history');
}
final body = jsonDecode(response.body) as Map<String, dynamic>;
final items = (body['items'] as List?) ?? [];
final result = <RpHistoryItem>[];
for (final item in items) {
if (item is Map) {
result.add(RpHistoryItem.fromJson(Map<String, dynamic>.from(item)));
}
}
return result;
} finally {
client.close();
}
}
Future<AuditPage> _fetchAuthTimelinePage({String? cursor}) async { Future<AuditPage> _fetchAuthTimelinePage({String? cursor}) async {
final queryParameters = <String, String>{ final queryParameters = <String, String>{
'limit': '20', 'limit': '20',
@@ -104,13 +60,9 @@ Future<AuditPage> _fetchAuthTimelinePage({String? cursor}) async {
} }
} }
final linkedRpsProvider = FutureProvider<List<LinkedRp>>((ref) async {
return _fetchLinkedRps();
});
final rpHistoryProvider = FutureProvider<List<RpHistoryItem>>((ref) async {
return _fetchRpHistory();
});
typedef AuthTimelineFetcher = Future<AuditPage> Function({String? cursor}); typedef AuthTimelineFetcher = Future<AuditPage> Function({String? cursor});
@@ -227,7 +179,10 @@ class AuthTimelineNotifier extends Notifier<AuthTimelineState> {
state = state.copyWith( state = state.copyWith(
isLoading: false, isLoading: false,
isLoadingMore: false, isLoadingMore: false,
error: '접속이력을 불러오지 못했습니다.', error: tr(
'msg.userfront.dashboard.timeline.load_error',
fallback: '접속이력을 불러오지 못했습니다.',
),
); );
} }
} }

View File

@@ -0,0 +1,111 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:userfront/core/services/auth_proxy_service.dart';
import 'package:userfront/core/services/auth_token_store.dart';
import 'package:userfront/core/services/http_client.dart';
class LinkedRp {
final String id;
final String name;
final String logo;
final String url;
final String status;
final List<String> scopes;
final DateTime? lastAuthenticatedAt;
LinkedRp({
required this.id,
required this.name,
required this.logo,
required this.url,
required this.status,
required this.scopes,
required this.lastAuthenticatedAt,
});
factory LinkedRp.fromJson(Map<String, dynamic> json) {
final rawLastAuth = json['lastAuthenticatedAt']?.toString() ?? '';
DateTime? parsedLastAuth;
if (rawLastAuth.isNotEmpty) {
try {
parsedLastAuth = DateTime.parse(rawLastAuth).toLocal();
} catch (_) {
parsedLastAuth = null;
}
}
return LinkedRp(
id: json['id']?.toString() ?? '',
name: json['name']?.toString() ?? '',
logo: json['logo']?.toString() ?? '',
url: json['url']?.toString() ?? '',
status: json['status']?.toString() ?? 'unknown',
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
lastAuthenticatedAt: parsedLastAuth,
);
}
}
class LinkedRpsNotifier extends AsyncNotifier<List<LinkedRp>> {
@override
Future<List<LinkedRp>> build() async {
return _fetchLinkedRps();
}
String _envOrDefault(String key, String fallback) {
if (!dotenv.isInitialized) {
return fallback;
}
return dotenv.env[key] ?? fallback;
}
Future<List<LinkedRp>> _fetchLinkedRps() async {
try {
final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
final url = Uri.parse('$baseUrl/api/v1/user/rp/linked');
final useCookie = AuthTokenStore.usesCookie();
final token = AuthTokenStore.getToken();
final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{
'Content-Type': 'application/json',
};
if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token';
}
final response = await client.get(url, headers: headers);
client.close();
if (response.statusCode != 200) {
throw Exception('Failed to load linked rps: ${response.statusCode}');
}
final body = jsonDecode(response.body) as Map<String, dynamic>;
final items = (body['items'] as List?) ?? [];
return items
.whereType<Map<String, dynamic>>()
.map(LinkedRp.fromJson)
.toList();
} catch (e) {
rethrow;
}
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() => _fetchLinkedRps());
}
Future<void> revokeRp(String clientId) async {
await AuthProxyService.revokeLinkedRp(clientId);
await refresh();
}
}
final linkedRpsProvider = AsyncNotifierProvider<LinkedRpsNotifier, List<LinkedRp>>(() {
return LinkedRpsNotifier();
});

View File

@@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:userfront/i18n.dart';
import '../models/user_profile_model.dart'; import '../models/user_profile_model.dart';
import '../../../../core/services/auth_token_store.dart'; import '../../../../core/services/auth_token_store.dart';
import '../../../../core/services/http_client.dart'; import '../../../../core/services/http_client.dart';
@@ -23,7 +24,9 @@ class ProfileRepository {
final token = await _getToken(); final token = await _getToken();
final useCookie = AuthTokenStore.usesCookie(); final useCookie = AuthTokenStore.usesCookie();
if (token == null && !useCookie) { if (token == null && !useCookie) {
throw Exception('No active session'); throw Exception(
tr('err.userfront.session.missing', fallback: '활성 세션이 없습니다.'),
);
} }
final url = Uri.parse('$_baseUrl/api/v1/user/me'); final url = Uri.parse('$_baseUrl/api/v1/user/me');
@@ -40,7 +43,13 @@ class ProfileRepository {
if (response.statusCode == 200) { if (response.statusCode == 200) {
return UserProfile.fromJson(jsonDecode(response.body)); return UserProfile.fromJson(jsonDecode(response.body));
} else { } else {
throw Exception('Failed to load profile: ${response.body}'); throw Exception(
tr(
'err.userfront.profile.load_failed',
fallback: '프로필을 불러오지 못했습니다: {{error}}',
params: {'error': response.body},
),
);
} }
} }
@@ -51,7 +60,11 @@ class ProfileRepository {
}) async { }) async {
final token = await _getToken(); final token = await _getToken();
final useCookie = AuthTokenStore.usesCookie(); final useCookie = AuthTokenStore.usesCookie();
if (token == null && !useCookie) throw Exception('No active session'); if (token == null && !useCookie) {
throw Exception(
tr('err.userfront.session.missing', fallback: '활성 세션이 없습니다.'),
);
}
final url = Uri.parse('$_baseUrl/api/v1/user/me'); final url = Uri.parse('$_baseUrl/api/v1/user/me');
final client = createHttpClient(withCredentials: useCookie); final client = createHttpClient(withCredentials: useCookie);
@@ -73,14 +86,24 @@ class ProfileRepository {
client.close(); client.close();
if (response.statusCode != 200) { if (response.statusCode != 200) {
throw Exception('Failed to update profile: ${response.body}'); throw Exception(
tr(
'err.userfront.profile.update_failed',
fallback: '프로필 업데이트에 실패했습니다: {{error}}',
params: {'error': response.body},
),
);
} }
} }
Future<void> sendUpdateCode(String phone) async { Future<void> sendUpdateCode(String phone) async {
final token = await _getToken(); final token = await _getToken();
final useCookie = AuthTokenStore.usesCookie(); final useCookie = AuthTokenStore.usesCookie();
if (token == null && !useCookie) throw Exception('No active session'); if (token == null && !useCookie) {
throw Exception(
tr('err.userfront.session.missing', fallback: '활성 세션이 없습니다.'),
);
}
final url = Uri.parse('$_baseUrl/api/v1/user/me/send-code'); final url = Uri.parse('$_baseUrl/api/v1/user/me/send-code');
final client = createHttpClient(withCredentials: useCookie); final client = createHttpClient(withCredentials: useCookie);
@@ -98,7 +121,13 @@ class ProfileRepository {
client.close(); client.close();
if (response.statusCode != 200) { if (response.statusCode != 200) {
throw Exception('인증번호 전송 실패: ${response.body}'); throw Exception(
tr(
'err.userfront.profile.send_code_failed',
fallback: '인증번호 전송 실패: {{error}}',
params: {'error': response.body},
),
);
} }
} }
@@ -108,7 +137,11 @@ class ProfileRepository {
}) async { }) async {
final token = await _getToken(); final token = await _getToken();
final useCookie = AuthTokenStore.usesCookie(); final useCookie = AuthTokenStore.usesCookie();
if (token == null && !useCookie) throw Exception('No active session'); if (token == null && !useCookie) {
throw Exception(
tr('err.userfront.session.missing', fallback: '활성 세션이 없습니다.'),
);
}
final url = Uri.parse('$_baseUrl/api/v1/user/me/password'); final url = Uri.parse('$_baseUrl/api/v1/user/me/password');
final client = createHttpClient(withCredentials: useCookie); final client = createHttpClient(withCredentials: useCookie);
@@ -129,14 +162,24 @@ class ProfileRepository {
client.close(); client.close();
if (response.statusCode != 200) { if (response.statusCode != 200) {
throw Exception('Failed to change password: ${response.body}'); throw Exception(
tr(
'err.userfront.profile.password_change_failed',
fallback: '비밀번호 변경에 실패했습니다: {{error}}',
params: {'error': response.body},
),
);
} }
} }
Future<void> verifyUpdateCode(String phone, String code) async { Future<void> verifyUpdateCode(String phone, String code) async {
final token = await _getToken(); final token = await _getToken();
final useCookie = AuthTokenStore.usesCookie(); final useCookie = AuthTokenStore.usesCookie();
if (token == null && !useCookie) throw Exception('No active session'); if (token == null && !useCookie) {
throw Exception(
tr('err.userfront.session.missing', fallback: '활성 세션이 없습니다.'),
);
}
final url = Uri.parse('$_baseUrl/api/v1/user/me/verify-code'); final url = Uri.parse('$_baseUrl/api/v1/user/me/verify-code');
final client = createHttpClient(withCredentials: useCookie); final client = createHttpClient(withCredentials: useCookie);
@@ -154,7 +197,13 @@ class ProfileRepository {
client.close(); client.close();
if (response.statusCode != 200) { if (response.statusCode != 200) {
throw Exception('인증 실패: ${response.body}'); throw Exception(
tr(
'err.userfront.profile.verify_code_failed',
fallback: '인증 실패: {{error}}',
params: {'error': response.body},
),
);
} }
} }
} }

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:userfront/i18n.dart';
import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/notifiers/auth_notifier.dart';
import '../../../../core/services/auth_token_store.dart'; import '../../../../core/services/auth_token_store.dart';
import '../../../../core/ui/layout_breakpoints.dart'; import '../../../../core/ui/layout_breakpoints.dart';
@@ -229,14 +230,29 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
}); });
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('인증번호가 전송되었습니다.')), SnackBar(
content: Text(
tr(
'msg.userfront.profile.phone.code_sent',
fallback: '인증번호가 전송되었습니다.',
),
),
),
); );
} }
} catch (e) { } catch (e) {
setState(() => _isVerifying = false); setState(() => _isVerifying = false);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('전송 실패: $e')), SnackBar(
content: Text(
tr(
'msg.userfront.profile.phone.send_failed',
fallback: '전송 실패: {{error}}',
params: {'error': e.toString()},
),
),
),
); );
} }
} }
@@ -256,7 +272,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
}); });
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('인증되었습니다.')), SnackBar(
content: Text(
tr(
'msg.userfront.profile.phone.verified',
fallback: '인증되었습니다.',
),
),
),
); );
} }
if (_editingField == 'phone') { if (_editingField == 'phone') {
@@ -266,7 +289,15 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
setState(() => _isVerifying = false); setState(() => _isVerifying = false);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('인증 실패: $e')), SnackBar(
content: Text(
tr(
'msg.userfront.profile.phone.verify_failed',
fallback: '인증 실패: {{error}}',
params: {'error': e.toString()},
),
),
),
); );
} }
} }
@@ -279,15 +310,24 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
final confirmPassword = _confirmPasswordController?.text.trim() ?? ''; final confirmPassword = _confirmPasswordController?.text.trim() ?? '';
if (currentPassword.isEmpty) { if (currentPassword.isEmpty) {
setState(() => _passwordError = '현재 비밀번호를 입력해 주세요.'); setState(() => _passwordError = tr(
'msg.userfront.profile.password.current_required',
fallback: '현재 비밀번호를 입력해 주세요.',
));
return; return;
} }
if (newPassword.isEmpty) { if (newPassword.isEmpty) {
setState(() => _passwordError = '새 비밀번호를 입력해 주세요.'); setState(() => _passwordError = tr(
'msg.userfront.profile.password.new_required',
fallback: '새 비밀번호를 입력해 주세요.',
));
return; return;
} }
if (newPassword != confirmPassword) { if (newPassword != confirmPassword) {
setState(() => _passwordError = '새 비밀번호가 일치하지 않습니다.'); setState(() => _passwordError = tr(
'msg.userfront.profile.password.mismatch',
fallback: '새 비밀번호가 일치하지 않습니다.',
));
return; return;
} }
@@ -306,12 +346,19 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_newPasswordController?.clear(); _newPasswordController?.clear();
_confirmPasswordController?.clear(); _confirmPasswordController?.clear();
setState(() { setState(() {
_passwordSuccess = '비밀번호가 변경되었습니다.'; _passwordSuccess = tr(
'msg.userfront.profile.password.changed',
fallback: '비밀번호가 변경되었습니다.',
);
}); });
} catch (e) { } catch (e) {
final message = e.toString().replaceFirst('Exception: ', ''); final message = e.toString().replaceFirst('Exception: ', '');
setState(() { setState(() {
_passwordError = '비밀번호 변경 실패: $message'; _passwordError = tr(
'msg.userfront.profile.password.change_failed',
fallback: '비밀번호 변경 실패: {{error}}',
params: {'error': message},
);
}); });
} finally { } finally {
if (mounted) { if (mounted) {
@@ -385,26 +432,54 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
if (_editingField == 'name' && nextName.isEmpty) { if (_editingField == 'name' && nextName.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('이름을 입력해주세요.')), SnackBar(
content: Text(
tr(
'msg.userfront.profile.name_required',
fallback: '이름을 입력해주세요.',
),
),
),
); );
return; return;
} }
if (_editingField == 'department' && nextDepartment.isEmpty) { if (_editingField == 'department' && nextDepartment.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('소속을 입력해주세요.')), SnackBar(
content: Text(
tr(
'msg.userfront.profile.department_required',
fallback: '소속을 입력해주세요.',
),
),
),
); );
return; return;
} }
if (_editingField == 'phone') { if (_editingField == 'phone') {
if (nextPhone.isEmpty) { if (nextPhone.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('휴대폰 번호를 입력해주세요.')), SnackBar(
content: Text(
tr(
'msg.userfront.profile.phone_required',
fallback: '휴대폰 번호를 입력해주세요.',
),
),
),
); );
return; return;
} }
if (_isPhoneChanged && !_isPhoneVerified) { if (_isPhoneChanged && !_isPhoneVerified) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('휴대폰 번호 인증이 필요합니다.')), SnackBar(
content: Text(
tr(
'msg.userfront.profile.phone_verify_required',
fallback: '휴대폰 번호 인증이 필요합니다.',
),
),
),
); );
return; return;
} }
@@ -441,13 +516,28 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_departmentTouched = false; _departmentTouched = false;
}); });
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('정보가 수정되었습니다.')), SnackBar(
content: Text(
tr(
'msg.userfront.profile.update_success',
fallback: '정보가 수정되었습니다.',
),
),
),
); );
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('수정 실패: $e')), SnackBar(
content: Text(
tr(
'msg.userfront.profile.update_failed',
fallback: '수정 실패: {{error}}',
params: {'error': e.toString()},
),
),
),
); );
} }
} finally { } finally {
@@ -461,24 +551,32 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
children: [ children: [
ListTile( ListTile(
leading: const Icon(Icons.home_outlined), leading: const Icon(Icons.home_outlined),
title: const Text('대시보드'), title: Text(
tr('ui.userfront.nav.dashboard', fallback: '대시보드'),
),
onTap: () => context.go('/'), onTap: () => context.go('/'),
), ),
ListTile( ListTile(
leading: const Icon(Icons.person_outline), leading: const Icon(Icons.person_outline),
title: const Text('내 정보'), title: Text(
tr('ui.userfront.nav.profile', fallback: '내 정보'),
),
selected: true, selected: true,
onTap: () => context.go('/profile'), onTap: () => context.go('/profile'),
), ),
ListTile( ListTile(
leading: const Icon(Icons.qr_code_scanner), leading: const Icon(Icons.qr_code_scanner),
title: const Text('QR 스캔'), title: Text(
tr('ui.userfront.nav.qr_scan', fallback: 'QR 스캔'),
),
onTap: () => context.go('/scan'), onTap: () => context.go('/scan'),
), ),
const Divider(), const Divider(),
ListTile( ListTile(
leading: const Icon(Icons.logout), leading: const Icon(Icons.logout),
title: const Text('로그아웃'), title: Text(
tr('ui.userfront.nav.logout', fallback: '로그아웃'),
),
onTap: _logout, onTap: _logout,
), ),
], ],
@@ -525,9 +623,15 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
} }
Widget _buildHeaderCard(UserProfile profile) { Widget _buildHeaderCard(UserProfile profile) {
final name = profile.name.isEmpty ? '이름 없음' : profile.name; final name = profile.name.isEmpty
final email = profile.email.isEmpty ? '메일 없음' : profile.email; ? tr('msg.userfront.profile.name_missing', fallback: ' 없음')
final department = profile.department.isEmpty ? '소속 정보 없음' : profile.department; : profile.name;
final email = profile.email.isEmpty
? tr('msg.userfront.profile.email_missing', fallback: '이메일 없음')
: profile.email;
final department = profile.department.isEmpty
? tr('msg.userfront.profile.department_missing', fallback: '소속 정보 없음')
: profile.department;
return Container( return Container(
width: double.infinity, width: double.infinity,
@@ -538,7 +642,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
border: Border.all(color: _border), border: Border.all(color: _border),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.04), color: Colors.black.withValues(alpha: 10),
blurRadius: 18, blurRadius: 18,
offset: const Offset(0, 8), offset: const Offset(0, 8),
), ),
@@ -556,8 +660,16 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'안녕하세요, $name님', tr(
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: _ink), 'msg.userfront.profile.greeting',
fallback: '안녕하세요, {{name}}님',
params: {'name': name},
),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: _ink,
),
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text(email, style: TextStyle(color: Colors.grey[600], fontSize: 14)), Text(email, style: TextStyle(color: Colors.grey[600], fontSize: 14)),
@@ -566,7 +678,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: [ children: [
_buildInfoChip(Icons.badge_outlined, '프로필 관리'), _buildInfoChip(
Icons.badge_outlined,
tr('ui.userfront.profile.manage', fallback: '프로필 관리'),
),
_buildInfoChip(Icons.apartment, profile.tenant?.name ?? department), _buildInfoChip(Icons.apartment, profile.tenant?.name ?? department),
], ],
), ),
@@ -588,7 +703,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
border: Border.all(color: _border), border: Border.all(color: _border),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.03), color: Colors.black.withValues(alpha: 8),
blurRadius: 12, blurRadius: 12,
offset: const Offset(0, 6), offset: const Offset(0, 6),
), ),
@@ -605,7 +720,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
title: Text(label), title: Text(label),
subtitle: Text(displayValue), subtitle: Text(displayValue),
trailing: Text( trailing: Text(
'읽기 전용', tr('ui.common.read_only', fallback: '읽기 전용'),
style: TextStyle(color: Colors.grey[500], fontSize: 12), style: TextStyle(color: Colors.grey[500], fontSize: 12),
), ),
); );
@@ -629,7 +744,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
subtitle: Text(displayValue), subtitle: Text(displayValue),
trailing: TextButton( trailing: TextButton(
onPressed: isUpdating ? null : () => _startEditing(field, profile), onPressed: isUpdating ? null : () => _startEditing(field, profile),
child: const Text('수정'), child: Text(tr('ui.common.edit', fallback: '수정')),
), ),
); );
} }
@@ -657,7 +772,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
const SizedBox(width: 12), const SizedBox(width: 12),
OutlinedButton( OutlinedButton(
onPressed: isUpdating ? null : () => _cancelEditing(profile), onPressed: isUpdating ? null : () => _cancelEditing(profile),
child: const Text('취소'), child: Text(tr('ui.common.cancel', fallback: '취소')),
), ),
], ],
), ),
@@ -672,11 +787,13 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
if (!isEditing) { if (!isEditing) {
return ListTile( return ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: const Text('전화번호'), title: Text(
tr('ui.userfront.profile.phone.title', fallback: '전화번호'),
),
subtitle: Text(displayValue), subtitle: Text(displayValue),
trailing: TextButton( trailing: TextButton(
onPressed: isUpdating ? null : () => _startEditing('phone', profile), onPressed: isUpdating ? null : () => _startEditing('phone', profile),
child: const Text('수정'), child: Text(tr('ui.common.edit', fallback: '수정')),
), ),
); );
} }
@@ -684,7 +801,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text('전화번호', style: TextStyle(fontWeight: FontWeight.w600)), Text(
tr('ui.userfront.profile.phone.title', fallback: '전화번호'),
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
@@ -710,12 +830,19 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
if (_isPhoneChanged && !_isPhoneVerified) if (_isPhoneChanged && !_isPhoneVerified)
ElevatedButton( ElevatedButton(
onPressed: _isVerifying ? null : _sendCode, onPressed: _isVerifying ? null : _sendCode,
child: Text(_isCodeSent ? '재전송' : '인증요청'), child: Text(
_isCodeSent
? tr('ui.common.resend', fallback: '재전송')
: tr(
'ui.userfront.profile.phone.request_code',
fallback: '인증요청',
),
),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
OutlinedButton( OutlinedButton(
onPressed: isUpdating ? null : () => _cancelEditing(profile), onPressed: isUpdating ? null : () => _cancelEditing(profile),
child: const Text('취소'), child: Text(tr('ui.common.cancel', fallback: '취소')),
), ),
], ],
), ),
@@ -731,26 +858,32 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
onSubmitted: (_) => _verifyCode(profile), onSubmitted: (_) => _verifyCode(profile),
decoration: const InputDecoration( decoration: InputDecoration(
border: OutlineInputBorder(), border: const OutlineInputBorder(),
hintText: '인증번호 6자리', hintText: tr(
'ui.userfront.profile.phone.code_hint',
fallback: '인증번호 6자리',
),
), ),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
ElevatedButton( ElevatedButton(
onPressed: _isVerifying ? null : () => _verifyCode(profile), onPressed: _isVerifying ? null : () => _verifyCode(profile),
child: const Text('확인'), child: Text(tr('ui.common.confirm', fallback: '확인')),
), ),
], ],
), ),
], ],
if (_isPhoneChanged && !_isPhoneVerified) if (_isPhoneChanged && !_isPhoneVerified)
const Padding( Padding(
padding: EdgeInsets.only(top: 8.0), padding: const EdgeInsets.only(top: 8.0),
child: Text( child: Text(
'휴대폰 번호를 변경하려면 SMS 인증이 필요합니다.', tr(
style: TextStyle(color: Colors.orange, fontSize: 12), 'msg.userfront.profile.phone.verify_notice',
fallback: '휴대폰 번호를 변경하려면 SMS 인증이 필요합니다.',
),
style: const TextStyle(color: Colors.orange, fontSize: 12),
), ),
), ),
], ],
@@ -763,20 +896,26 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'비밀번호 변경', tr('ui.userfront.profile.password.title', fallback: '비밀번호 변경'),
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
const Text( Text(
'현재 비밀번호 확인 후 새 비밀번호로 변경합니다.', tr(
style: TextStyle(color: Color(0xFF6B7280)), 'msg.userfront.profile.password.subtitle',
fallback: '현재 비밀번호 확인 후 새 비밀번호로 변경합니다.',
),
style: const TextStyle(color: Color(0xFF6B7280)),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextField( TextField(
controller: _currentPasswordController, controller: _currentPasswordController,
obscureText: !_showCurrentPassword, obscureText: !_showCurrentPassword,
decoration: InputDecoration( decoration: InputDecoration(
labelText: '현재 비밀번호', labelText: tr(
'ui.userfront.profile.password.current',
fallback: '현재 비밀번호',
),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
suffixIcon: IconButton( suffixIcon: IconButton(
icon: Icon(_showCurrentPassword ? Icons.visibility_off : Icons.visibility), icon: Icon(_showCurrentPassword ? Icons.visibility_off : Icons.visibility),
@@ -791,7 +930,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
controller: _newPasswordController, controller: _newPasswordController,
obscureText: !_showNewPassword, obscureText: !_showNewPassword,
decoration: InputDecoration( decoration: InputDecoration(
labelText: '새 비밀번호', labelText: tr(
'ui.userfront.profile.password.new',
fallback: '새 비밀번호',
),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
suffixIcon: IconButton( suffixIcon: IconButton(
icon: Icon(_showNewPassword ? Icons.visibility_off : Icons.visibility), icon: Icon(_showNewPassword ? Icons.visibility_off : Icons.visibility),
@@ -806,7 +948,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
controller: _confirmPasswordController, controller: _confirmPasswordController,
obscureText: !_showConfirmPassword, obscureText: !_showConfirmPassword,
decoration: InputDecoration( decoration: InputDecoration(
labelText: '새 비밀번호 확인', labelText: tr(
'ui.userfront.profile.password.confirm',
fallback: '새 비밀번호 확인',
),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
suffixIcon: IconButton( suffixIcon: IconButton(
icon: Icon(_showConfirmPassword ? Icons.visibility_off : Icons.visibility), icon: Icon(_showConfirmPassword ? Icons.visibility_off : Icons.visibility),
@@ -841,12 +986,22 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
height: 18, height: 18,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
: const Text('비밀번호 변경'), : Text(
tr(
'ui.userfront.profile.password.change',
fallback: '비밀번호 변경',
),
),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
TextButton( TextButton(
onPressed: () => context.go('/recovery'), onPressed: () => context.go('/recovery'),
child: const Text('비밀번호를 잊으셨나요?'), child: Text(
tr(
'ui.userfront.profile.password.forgot',
fallback: '비밀번호를 잊으셨나요?',
),
),
), ),
], ],
), ),
@@ -869,55 +1024,88 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
children: [ children: [
_buildHeaderCard(profile), _buildHeaderCard(profile),
const SizedBox(height: 28), const SizedBox(height: 28),
_buildSectionTitle('기본 정보', '계정 기본 정보를 관리합니다.'), _buildSectionTitle(
tr('ui.userfront.profile.section.basic', fallback: '기본 정보'),
tr(
'msg.userfront.profile.section.basic',
fallback: '계정 기본 정보를 관리합니다.',
),
),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildCard( _buildCard(
Column( Column(
children: [ children: [
_buildEditableTile( _buildEditableTile(
field: 'name', field: 'name',
label: '이름', label: tr('ui.userfront.profile.field.name', fallback: '이름'),
value: profile.name, value: profile.name,
profile: profile, profile: profile,
isUpdating: isUpdating, isUpdating: isUpdating,
controller: _nameController!, controller: _nameController!,
), ),
const Divider(height: 24), const Divider(height: 24),
_buildReadOnlyTile('이메일', profile.email), _buildReadOnlyTile(
tr('ui.userfront.profile.field.email', fallback: '이메일'),
profile.email,
),
const Divider(height: 24), const Divider(height: 24),
_buildPhoneEditor(profile, isUpdating), _buildPhoneEditor(profile, isUpdating),
], ],
), ),
), ),
const SizedBox(height: 28), const SizedBox(height: 28),
_buildSectionTitle('조직 정보', '소속 및 구분 정보입니다.'), _buildSectionTitle(
tr('ui.userfront.profile.section.organization', fallback: '조직 정보'),
tr(
'msg.userfront.profile.section.organization',
fallback: '소속 및 구분 정보입니다.',
),
),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildCard( _buildCard(
Column( Column(
children: [ children: [
_buildEditableTile( _buildEditableTile(
field: 'department', field: 'department',
label: '소속', label: tr('ui.userfront.profile.field.department', fallback: '소속'),
value: profile.department, value: profile.department,
profile: profile, profile: profile,
isUpdating: isUpdating, isUpdating: isUpdating,
controller: _departmentController!, controller: _departmentController!,
), ),
const Divider(height: 24), const Divider(height: 24),
_buildReadOnlyTile('구분', profile.affiliationType), _buildReadOnlyTile(
tr('ui.userfront.profile.field.affiliation', fallback: '구분'),
profile.affiliationType,
),
if (profile.tenant != null) ...[ if (profile.tenant != null) ...[
const Divider(height: 24), const Divider(height: 24),
_buildReadOnlyTile('소속 테넌트', profile.tenant!.name), _buildReadOnlyTile(
tr(
'ui.userfront.profile.field.tenant',
fallback: '소속 테넌트',
),
profile.tenant!.name,
),
], ],
if (profile.companyCode.isNotEmpty) ...[ if (profile.companyCode.isNotEmpty) ...[
const Divider(height: 24), const Divider(height: 24),
_buildReadOnlyTile('회사코드', profile.companyCode), _buildReadOnlyTile(
tr('ui.userfront.profile.field.company_code', fallback: '회사코드'),
profile.companyCode,
),
], ],
], ],
), ),
), ),
const SizedBox(height: 28), const SizedBox(height: 28),
_buildSectionTitle('보안', '비밀번호를 안전하게 관리합니다.'), _buildSectionTitle(
tr('ui.userfront.profile.section.security', fallback: '보안'),
tr(
'msg.userfront.profile.section.security',
fallback: '비밀번호를 안전하게 관리합니다.',
),
),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildPasswordSection(), _buildPasswordSection(),
if (isUpdating || _isVerifying) ...[ if (isUpdating || _isVerifying) ...[
@@ -943,18 +1131,25 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
final profile = profileState.value ?? _cachedProfile; final profile = profileState.value ?? _cachedProfile;
if (profile == null) { if (profile == null) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('내 정보')), appBar: AppBar(
title: Text(tr('ui.userfront.nav.profile', fallback: '내 정보')),
),
body: profileState.isLoading body: profileState.isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: Center( : Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Text('정보를 불러올 수 없습니다.'), Text(
tr(
'msg.userfront.profile.load_failed',
fallback: '정보를 불러올 수 없습니다.',
),
),
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton( ElevatedButton(
onPressed: () => ref.read(profileProvider.notifier).loadProfile(), onPressed: () => ref.read(profileProvider.notifier).loadProfile(),
child: const Text('재시도'), child: Text(tr('ui.common.retry', fallback: '재시도')),
), ),
], ],
), ),
@@ -971,8 +1166,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
backgroundColor: _subtle, backgroundColor: _subtle,
appBar: AppBar( appBar: AppBar(
title: Text( title: Text(
'Baron 로그인', tr('ui.userfront.app_title', fallback: 'Baron 로그인'),
style: TextStyle(fontWeight: FontWeight.bold), style: const TextStyle(fontWeight: FontWeight.bold),
), ),
elevation: 0, elevation: 0,
backgroundColor: _surface, backgroundColor: _surface,
@@ -980,17 +1175,17 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.home_outlined), icon: const Icon(Icons.home_outlined),
tooltip: '대시보드', tooltip: tr('ui.userfront.nav.dashboard', fallback: '대시보드'),
onPressed: () => context.go('/'), onPressed: () => context.go('/'),
), ),
IconButton( IconButton(
icon: const Icon(Icons.qr_code_scanner), icon: const Icon(Icons.qr_code_scanner),
tooltip: 'QR 스캔', tooltip: tr('ui.userfront.nav.qr_scan', fallback: 'QR 스캔'),
onPressed: () => context.push('/scan'), onPressed: () => context.push('/scan'),
), ),
IconButton( IconButton(
icon: const Icon(Icons.logout), icon: const Icon(Icons.logout),
tooltip: '로그아웃', tooltip: tr('ui.userfront.nav.logout', fallback: '로그아웃'),
onPressed: _logout, onPressed: _logout,
), ),
], ],

40
userfront/lib/i18n.dart Normal file
View File

@@ -0,0 +1,40 @@
import 'dart:ui';
import 'i18n_data.dart';
const _defaultLocale = 'ko';
const _supportedLocales = ['ko', 'en'];
String _resolveLocale() {
final locale = PlatformDispatcher.instance.locale;
final code = locale.languageCode.toLowerCase();
if (_supportedLocales.contains(code)) {
return code;
}
return _defaultLocale;
}
String _formatTemplate(String template, Map<String, String>? params) {
if (params == null || params.isEmpty) {
return template;
}
var result = template;
params.forEach((key, value) {
result = result.replaceAll('{{$key}}', value);
});
return result;
}
String tr(
String key, {
String? fallback,
Map<String, String>? params,
}) {
final locale = _resolveLocale();
final map = locale == 'en' ? enStrings : koStrings;
final value = map[key];
final template = (value != null && value.isNotEmpty)
? value
: (fallback ?? key);
return _formatTemplate(template, params);
}

1612
userfront/lib/i18n_data.dart Normal file

File diff suppressed because one or more lines are too long

View File

@@ -21,6 +21,7 @@ import 'core/services/logger_service.dart';
import 'core/notifiers/auth_notifier.dart'; import 'core/notifiers/auth_notifier.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'features/auth/presentation/consent_screen.dart'; import 'features/auth/presentation/consent_screen.dart';
import 'i18n.dart';
final _log = Logger('Main'); final _log = Logger('Main');
@@ -200,9 +201,12 @@ final _router = GoRouter(
path: '/settings', path: '/settings',
builder: (context, state) { builder: (context, state) {
_routerLogger.info("Navigating to /settings (disabled)"); _routerLogger.info("Navigating to /settings (disabled)");
return const ErrorScreen( return ErrorScreen(
errorCode: 'settings_disabled', errorCode: 'settings_disabled',
description: '현재 계정 설정 화면은 준비 중입니다.', description: tr(
'msg.userfront.settings.disabled',
fallback: '현재 계정 설정 화면은 준비 중입니다.',
),
); );
}, },
), ),
@@ -295,7 +299,7 @@ class BaronSSOApp extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp.router( return MaterialApp.router(
title: 'Baron 로그인', title: tr('ui.userfront.app_title', fallback: 'Baron 로그인'),
theme: ThemeData( theme: ThemeData(
colorScheme: ColorScheme.fromSeed( colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1A1F2C), // Dark Navy/Black base seedColor: const Color(0xFF1A1F2C), // Dark Navy/Black base

View File

@@ -156,7 +156,7 @@ packages:
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_web_plugins: flutter_web_plugins:
dependency: transitive dependency: "direct main"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"

View File

@@ -30,6 +30,8 @@ environment:
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
flutter_web_plugins:
sdk: flutter
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.

View File

@@ -1,8 +1,11 @@
import 'dart:ui';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:userfront/features/dashboard/domain/dashboard_providers.dart'; import 'package:userfront/features/dashboard/domain/dashboard_providers.dart';
import 'package:userfront/features/dashboard/domain/models.dart'; import 'package:userfront/features/dashboard/domain/models.dart';
import 'package:userfront/i18n.dart';
AuditLogEntry _log(String id) { AuditLogEntry _log(String id) {
return AuditLogEntry.fromJson({ return AuditLogEntry.fromJson({
@@ -21,6 +24,14 @@ Future<void> _drainMicrotasks() async {
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
final dispatcher = TestWidgetsFlutterBinding.instance.platformDispatcher;
dispatcher.localeTestValue = const Locale('ko');
dispatcher.localesTestValue = const [Locale('ko')];
tearDownAll(() {
dispatcher.clearLocaleTestValue();
dispatcher.clearLocalesTestValue();
});
test('AuthTimelineNotifier는 초기 페이지를 로드한다', () async { test('AuthTimelineNotifier는 초기 페이지를 로드한다', () async {
final cursors = <String?>[]; final cursors = <String?>[];
@@ -103,7 +114,13 @@ void main() {
final state = container.read(authTimelineProvider); final state = container.read(authTimelineProvider);
expect(state.items.isEmpty, true); expect(state.items.isEmpty, true);
expect(state.error, '접속이력을 불러오지 못했습니다.'); expect(
state.error,
tr(
'msg.userfront.dashboard.timeline.load_error',
fallback: '접속이력을 불러오지 못했습니다.',
),
);
container.dispose(); container.dispose();
}); });
} }

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:userfront/core/constants/error_whitelist.dart';
import 'package:userfront/features/auth/presentation/error_screen.dart'; import 'package:userfront/features/auth/presentation/error_screen.dart';
import 'package:userfront/i18n.dart';
Future<void> _pumpErrorScreen( Future<void> _pumpErrorScreen(
WidgetTester tester, { WidgetTester tester, {
@@ -21,6 +23,19 @@ Future<void> _pumpErrorScreen(
} }
void main() { void main() {
setUpAll(() {
TestWidgetsFlutterBinding.ensureInitialized();
final dispatcher = TestWidgetsFlutterBinding.instance.platformDispatcher;
dispatcher.localeTestValue = const Locale('ko');
dispatcher.localesTestValue = const [Locale('ko')];
});
tearDownAll(() {
final dispatcher = TestWidgetsFlutterBinding.instance.platformDispatcher;
dispatcher.clearLocaleTestValue();
dispatcher.clearLocalesTestValue();
});
testWidgets('개발환경은 원문 메시지를 노출한다', (WidgetTester tester) async { testWidgets('개발환경은 원문 메시지를 노출한다', (WidgetTester tester) async {
await _pumpErrorScreen( await _pumpErrorScreen(
tester, tester,
@@ -29,9 +44,20 @@ void main() {
isProdOverride: false, isProdOverride: false,
); );
expect(find.text('오류: custom_error'), findsOneWidget); final title = tr(
'msg.userfront.error.title_with_code',
fallback: '오류: {{code}}',
params: {'code': 'custom_error'},
);
final type = tr(
'msg.userfront.error.type',
fallback: '오류 종류: {{type}}',
params: {'type': 'custom_error'},
);
expect(find.text(title), findsOneWidget);
expect(find.text('원문 메시지'), findsOneWidget); expect(find.text('원문 메시지'), findsOneWidget);
expect(find.text('오류 종류: custom_error'), findsOneWidget); expect(find.text(type), findsOneWidget);
}); });
testWidgets('프로덕션은 whitelist 메시지를 노출한다', (WidgetTester tester) async { testWidgets('프로덕션은 whitelist 메시지를 노출한다', (WidgetTester tester) async {
@@ -42,10 +68,24 @@ void main() {
isProdOverride: true, isProdOverride: true,
); );
expect(find.text('인증 과정에서 오류가 발생했습니다'), findsOneWidget); final title = tr(
expect(find.text('현재 계정 설정 화면은 준비 중입니다.'), findsOneWidget); 'msg.userfront.error.title',
fallback: '인증 과정에서 오류가 발생했습니다',
);
final detail = tr(
'msg.userfront.error.whitelist.settings_disabled',
fallback: errorWhitelistMessages['settings_disabled']!,
);
final type = tr(
'msg.userfront.error.type',
fallback: '오류 종류: {{type}}',
params: {'type': 'settings_disabled'},
);
expect(find.text(title), findsOneWidget);
expect(find.text(detail), findsOneWidget);
expect(find.text('원문 메시지'), findsNothing); expect(find.text('원문 메시지'), findsNothing);
expect(find.text('오류 종류: settings_disabled'), findsOneWidget); expect(find.text(type), findsOneWidget);
}); });
testWidgets('프로덕션은 비허용 에러를 unknown_error로 처리한다', (WidgetTester tester) async { testWidgets('프로덕션은 비허용 에러를 unknown_error로 처리한다', (WidgetTester tester) async {
@@ -56,9 +96,23 @@ void main() {
isProdOverride: true, isProdOverride: true,
); );
expect(find.text('인증 과정에서 오류가 발생했습니다'), findsOneWidget); final title = tr(
expect(find.text('에러가 계속되면 관리자에게 문의해주세요'), findsOneWidget); 'msg.userfront.error.title',
fallback: '인증 과정에서 오류가 발생했습니다',
);
final detail = tr(
'msg.userfront.error.detail_contact',
fallback: '에러가 계속되면 관리자에게 문의해주세요',
);
final type = tr(
'msg.userfront.error.type',
fallback: '오류 종류: {{type}}',
params: {'type': 'unknown_error'},
);
expect(find.text(title), findsOneWidget);
expect(find.text(detail), findsOneWidget);
expect(find.text('원문 메시지'), findsNothing); expect(find.text('원문 메시지'), findsNothing);
expect(find.text('오류 종류: unknown_error'), findsOneWidget); expect(find.text(type), findsOneWidget);
}); });
} }