forked from baron/baron-sso
feat: i18n 개선 및 userfront 로그인/로케일 보완
This commit is contained in:
18
.dockerignore
Normal file
18
.dockerignore
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.codex
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
**/.dart_tool
|
||||||
|
**/.packages
|
||||||
|
**/build
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/.next
|
||||||
|
**/.cache
|
||||||
|
**/coverage
|
||||||
|
**/tmp
|
||||||
|
**/logs
|
||||||
|
**/*.log
|
||||||
|
**/*.swp
|
||||||
|
**/.DS_Store
|
||||||
@@ -1,118 +1,125 @@
|
|||||||
name: Code Check
|
name: Code Check
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
pull_request:
|
||||||
inputs:
|
branches:
|
||||||
run_lint:
|
- dev
|
||||||
description: "Run linters for Go and Flutter"
|
workflow_dispatch:
|
||||||
required: true
|
inputs:
|
||||||
type: boolean
|
run_lint:
|
||||||
default: true
|
description: "Run linters for Go and Flutter"
|
||||||
run_backend_tests:
|
required: true
|
||||||
description: "Run backend Go tests"
|
type: boolean
|
||||||
required: true
|
default: true
|
||||||
type: boolean
|
run_backend_tests:
|
||||||
default: true
|
description: "Run backend Go tests"
|
||||||
run_userfront_tests:
|
required: true
|
||||||
description: "Run userfront Flutter tests"
|
type: boolean
|
||||||
required: true
|
default: true
|
||||||
type: boolean
|
run_userfront_tests:
|
||||||
default: true
|
description: "Run userfront Flutter tests"
|
||||||
|
required: true
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
if: ${{ inputs.run_lint == true }}
|
if: ${{ github.event_name != 'workflow_dispatch' || inputs.run_lint == true }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "20"
|
||||||
|
|
||||||
- name: i18n resource check
|
- name: i18n resource check
|
||||||
run: |
|
run: |
|
||||||
node tools/i18n-scanner/index.js
|
mkdir -p reports
|
||||||
|
node tools/i18n-scanner/index.js
|
||||||
|
node tools/i18n-scanner/report.js
|
||||||
|
cat reports/i18n-report.txt
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.25"
|
go-version: "1.25"
|
||||||
cache-dependency-path: backend/go.sum
|
cache-dependency-path: backend/go.sum
|
||||||
|
|
||||||
- name: Setup Flutter
|
- name: Setup Flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- name: Lint Go backend
|
- name: Lint Go backend
|
||||||
uses: golangci/golangci-lint-action@v6
|
uses: golangci/golangci-lint-action@v6
|
||||||
with:
|
with:
|
||||||
version: v1.59
|
version: v1.59
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
args: --enable-only=gofmt,gofumpt
|
args: --enable-only=gofmt,gofumpt
|
||||||
|
|
||||||
- name: Analyze Flutter userfront
|
- name: Analyze Flutter userfront
|
||||||
run: |
|
run: |
|
||||||
cd userfront
|
cd userfront
|
||||||
flutter analyze --no-fatal-warnings --no-fatal-infos
|
flutter analyze --no-fatal-warnings --no-fatal-infos
|
||||||
|
|
||||||
backend-tests:
|
backend-tests:
|
||||||
needs: lint
|
needs: lint
|
||||||
if: ${{ inputs.run_backend_tests == true }}
|
if: ${{ github.event_name != 'workflow_dispatch' || inputs.run_backend_tests == true }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
services:
|
services:
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
options: >
|
options: >
|
||||||
--health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
--health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
||||||
clickhouse:
|
clickhouse:
|
||||||
image: clickhouse/clickhouse-server:24.6
|
image: clickhouse/clickhouse-server:24.6
|
||||||
options: >
|
options: >
|
||||||
--health-cmd "wget -qO- 'http://localhost:8123/ping'" --health-interval 10s --health-timeout 5s --health-retries 5
|
--health-cmd "wget -qO- 'http://localhost:8123/ping'" --health-interval 10s --health-timeout 5s --health-retries 5
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REDIS_ADDR: redis:6379
|
REDIS_ADDR: redis:6379
|
||||||
CLICKHOUSE_HOST: clickhouse
|
CLICKHOUSE_HOST: clickhouse
|
||||||
CLICKHOUSE_PORT_NATIVE: 9000
|
CLICKHOUSE_PORT_NATIVE: 9000
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.25"
|
go-version: "1.25"
|
||||||
cache-dependency-path: backend/go.sum
|
cache-dependency-path: backend/go.sum
|
||||||
|
|
||||||
- name: Run backend tests
|
- name: Run backend tests
|
||||||
run: |
|
run: |
|
||||||
cd backend
|
cd backend
|
||||||
go test -v ./...
|
go test -v ./...
|
||||||
|
|
||||||
userfront-tests:
|
userfront-tests:
|
||||||
needs: lint
|
needs: lint
|
||||||
if: ${{ inputs.run_userfront_tests == true }}
|
if: ${{ github.event_name != 'workflow_dispatch' || inputs.run_userfront_tests == true }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Flutter
|
- name: Setup Flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- name: Run userfront tests
|
- name: Run userfront tests
|
||||||
run: |
|
run: |
|
||||||
cd userfront
|
cd userfront
|
||||||
if [ -d test ]; then
|
if [ -d test ]; then
|
||||||
flutter test
|
flutter test
|
||||||
else
|
flutter test --platform chrome test/locale_storage_web_test.dart
|
||||||
echo "No userfront tests: skipping (test/ directory not found)."
|
else
|
||||||
fi
|
echo "No userfront tests: skipping (test/ directory not found)."
|
||||||
|
fi
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ flowchart
|
|||||||
- userfront가 바라보는 backend
|
- userfront가 바라보는 backend
|
||||||
|
|
||||||
### 2. UserFront(Flutter Web/App)
|
### 2. UserFront(Flutter Web/App)
|
||||||
- **Framework**: Flutter 3.32+
|
- **Framework**: Flutter 3.38.0+
|
||||||
- **Key Packages**: `flutter_riverpod`, `go_router`
|
- **Key Packages**: `flutter_riverpod`, `go_router`
|
||||||
- **Features**:
|
- **Features**:
|
||||||
- 탭 기반 로그인 UI (비밀번호 기반 / 링크 기반 / QR 기반 등)
|
- 탭 기반 로그인 UI (비밀번호 기반 / 링크 기반 / QR 기반 등)
|
||||||
@@ -147,7 +147,7 @@ Kratos가 사용자 SoT이며 Hydra는 순수 OIDC 토큰 엔진입니다. 비
|
|||||||
|
|
||||||
### 사전 요구사항 (Prerequisites)
|
### 사전 요구사항 (Prerequisites)
|
||||||
- Docker & Docker Compose
|
- Docker & Docker Compose
|
||||||
- Flutter SDK (로컬 개발용)
|
- Flutter SDK (로컬 개발용, 3.38.0+)
|
||||||
- Go (로컬 백엔드 개발용)
|
- Go (로컬 백엔드 개발용)
|
||||||
|
|
||||||
### 환경 설정 (Environment Setup)
|
### 환경 설정 (Environment Setup)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ It leverages **Descope** for secure, passwordless authentication (Enchanted Link
|
|||||||
## 🏗 Architecture
|
## 🏗 Architecture
|
||||||
|
|
||||||
### 1. Frontend (Flutter Web)
|
### 1. Frontend (Flutter Web)
|
||||||
- **Framework**: Flutter 3.32+
|
- **Framework**: Flutter 3.38.0+
|
||||||
- **Organization**: `kr.co.baroncs`
|
- **Organization**: `kr.co.baroncs`
|
||||||
- **Key Packages**: `descope`, `flutter_riverpod`, `go_router`
|
- **Key Packages**: `descope`, `flutter_riverpod`, `go_router`
|
||||||
- **Features**:
|
- **Features**:
|
||||||
@@ -32,7 +32,7 @@ It leverages **Descope** for secure, passwordless authentication (Enchanted Link
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Docker & Docker Compose
|
- Docker & Docker Compose
|
||||||
- Flutter SDK (for local development)
|
- Flutter SDK (for local development, 3.38.0+)
|
||||||
- Go (for local backend development)
|
- Go (for local backend development)
|
||||||
|
|
||||||
### Environment Setup
|
### Environment Setup
|
||||||
|
|||||||
57
adminfront/src/components/common/LanguageSelector.tsx
Normal file
57
adminfront/src/components/common/LanguageSelector.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { t } from "../../lib/i18n";
|
||||||
|
|
||||||
|
const LOCALE_STORAGE_KEY = "locale";
|
||||||
|
const SUPPORTED_LOCALES = ["ko", "en"] as const;
|
||||||
|
|
||||||
|
type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||||
|
|
||||||
|
function resolveLocale(): Locale {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return "ko";
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||||
|
if (stored === "ko" || stored === "en") {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathLocale = window.location.pathname.split("/")[1];
|
||||||
|
if (pathLocale === "ko" || pathLocale === "en") {
|
||||||
|
return pathLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
const browserLang = window.navigator.language.toLowerCase();
|
||||||
|
return browserLang.startsWith("ko") ? "ko" : "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
function LanguageSelector() {
|
||||||
|
const [locale, setLocale] = useState<Locale>(resolveLocale());
|
||||||
|
|
||||||
|
const handleChange = (next: Locale) => {
|
||||||
|
if (next === locale) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.localStorage.setItem(LOCALE_STORAGE_KEY, next);
|
||||||
|
setLocale(next);
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={locale}
|
||||||
|
onChange={(event) => handleChange(event.target.value as Locale)}
|
||||||
|
className="rounded-full border border-border bg-transparent px-3 py-2 text-sm text-muted-foreground transition hover:bg-muted/20"
|
||||||
|
aria-label={t("ui.common.language", "언어")}
|
||||||
|
>
|
||||||
|
<option value="ko">
|
||||||
|
{t("ui.common.language_ko", "한국어")}
|
||||||
|
</option>
|
||||||
|
<option value="en">
|
||||||
|
{t("ui.common.language_en", "English")}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LanguageSelector;
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { NavLink, Outlet } from "react-router-dom";
|
import { NavLink, Outlet } from "react-router-dom";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
|
import LanguageSelector from "../common/LanguageSelector";
|
||||||
import RoleSwitcher from "./RoleSwitcher";
|
import RoleSwitcher from "./RoleSwitcher";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
@@ -132,6 +133,7 @@ function AppLayout() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<LanguageSelector />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
|
|||||||
@@ -454,7 +454,8 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
slog.Info("[Signup] New user registered", "email", req.Email, "type", req.AffiliationType, "provider", h.IdpProvider.Name(), "subject", providerID)
|
slog.Info("[Signup] New user registered", "email", req.Email, "type", req.AffiliationType, "provider", h.IdpProvider.Name(), "subject", providerID)
|
||||||
|
|
||||||
// [New] Local DB Sync
|
// [SoT Policy] Kratos가 SoT이므로 로컬 DB 저장은 비동기 Read-Model 동기화로 처리합니다.
|
||||||
|
// 로컬 DB 저장이 실패하더라도 회원가입 프로세스는 성공으로 간주합니다.
|
||||||
localUser := &domain.User{
|
localUser := &domain.User{
|
||||||
ID: providerID, // Match IDP Subject
|
ID: providerID, // Match IDP Subject
|
||||||
Email: req.Email,
|
Email: req.Email,
|
||||||
@@ -470,9 +471,17 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if h.UserRepo != nil {
|
if h.UserRepo != nil {
|
||||||
if err := h.UserRepo.Create(c.Context(), localUser); err != nil {
|
go func(u *domain.User) {
|
||||||
slog.Error("[Signup] Failed to sync user to local DB", "email", req.Email, "error", err)
|
// 요청 Context가 취소될 수 있으므로 Background Context 사용
|
||||||
}
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := h.UserRepo.Create(ctx, u); err != nil {
|
||||||
|
slog.Error("[Signup] Failed to sync user to Read-Model (Local DB)", "email", u.Email, "error", err)
|
||||||
|
} else {
|
||||||
|
slog.Debug("[Signup] Synced user to Read-Model", "email", u.Email)
|
||||||
|
}
|
||||||
|
}(localUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
// [Keto] Sync user-tenant relationship
|
// [Keto] Sync user-tenant relationship
|
||||||
|
|||||||
251
backend/internal/handler/auth_handler_async_test.go
Normal file
251
backend/internal/handler/auth_handler_async_test.go
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
"baron-sso-backend/internal/service"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Async Test Mocks ---
|
||||||
|
|
||||||
|
type AsyncMockIdpProvider struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AsyncMockIdpProvider) Name() string { return "mock-idp" }
|
||||||
|
func (m *AsyncMockIdpProvider) GetMetadata() (*domain.IDPMetadata, error) {
|
||||||
|
return &domain.IDPMetadata{}, nil
|
||||||
|
}
|
||||||
|
func (m *AsyncMockIdpProvider) UserExists(loginID string) (bool, error) {
|
||||||
|
args := m.Called(loginID)
|
||||||
|
return args.Bool(0), args.Error(1)
|
||||||
|
}
|
||||||
|
func (m *AsyncMockIdpProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) {
|
||||||
|
args := m.Called(user, password)
|
||||||
|
return args.String(0), args.Error(1)
|
||||||
|
}
|
||||||
|
func (m *AsyncMockIdpProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *AsyncMockIdpProvider) IssueSession(loginID string) (*domain.AuthInfo, error) { return nil, nil }
|
||||||
|
func (m *AsyncMockIdpProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *AsyncMockIdpProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *AsyncMockIdpProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) {
|
||||||
|
return &domain.PasswordPolicy{MinLength: 12}, nil
|
||||||
|
}
|
||||||
|
func (m *AsyncMockIdpProvider) InitiatePasswordReset(loginID, redirectUrl string) error { return nil }
|
||||||
|
func (m *AsyncMockIdpProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *AsyncMockIdpProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type AsyncMockUserRepo struct {
|
||||||
|
mock.Mock
|
||||||
|
createCalled chan bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AsyncMockUserRepo) Create(ctx context.Context, user *domain.User) error {
|
||||||
|
// Simulate DB latency
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
args := m.Called(ctx, user)
|
||||||
|
if m.createCalled != nil {
|
||||||
|
m.createCalled <- true
|
||||||
|
}
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
func (m *AsyncMockUserRepo) Update(ctx context.Context, user *domain.User) error { return nil }
|
||||||
|
func (m *AsyncMockUserRepo) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *AsyncMockUserRepo) FindByID(ctx context.Context, id string) (*domain.User, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *AsyncMockUserRepo) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *AsyncMockUserRepo) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *AsyncMockUserRepo) List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) {
|
||||||
|
return nil, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type AsyncMockRedisRepo struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AsyncMockRedisRepo) Set(key string, value string, expiration time.Duration) error {
|
||||||
|
args := m.Called(key, value, expiration)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
func (m *AsyncMockRedisRepo) Get(key string) (string, error) {
|
||||||
|
args := m.Called(key)
|
||||||
|
return args.String(0), args.Error(1)
|
||||||
|
}
|
||||||
|
func (m *AsyncMockRedisRepo) Delete(key string) error {
|
||||||
|
args := m.Called(key)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
func (m *AsyncMockRedisRepo) StoreVerificationCode(phone, code string) error { return nil }
|
||||||
|
func (m *AsyncMockRedisRepo) GetVerificationCode(phone string) (string, error) { return "", nil }
|
||||||
|
func (m *AsyncMockRedisRepo) DeleteVerificationCode(phone string) error { return nil }
|
||||||
|
|
||||||
|
type AsyncMockTenantService struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AsyncMockTenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string) (*domain.Tenant, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *AsyncMockTenantService) RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *AsyncMockTenantService) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) {
|
||||||
|
args := m.Called(ctx, emailDomain)
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.Error(1)
|
||||||
|
}
|
||||||
|
return args.Get(0).(*domain.Tenant), args.Error(1)
|
||||||
|
}
|
||||||
|
func (m *AsyncMockTenantService) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *AsyncMockTenantService) GetTenant(ctx context.Context, id string) (*domain.Tenant, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *AsyncMockTenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *AsyncMockTenantService) ApproveTenant(ctx context.Context, id string) error { return nil }
|
||||||
|
func (m *AsyncMockTenantService) SetKetoService(keto service.KetoService) {}
|
||||||
|
func (m *AsyncMockTenantService) AddTenantAdmin(ctx context.Context, tenantID, userID string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *AsyncMockTenantService) RemoveTenantAdmin(ctx context.Context, tenantID, userID string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *AsyncMockTenantService) ListTenantAdmins(ctx context.Context, tenantID string) ([]string, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type AsyncMockKetoService struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AsyncMockKetoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
||||||
|
args := m.Called(ctx, namespace, object, relation, subject)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
func (m *AsyncMockKetoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *AsyncMockKetoService) CheckPermission(ctx context.Context, namespace, object, relation, subject string) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
func (m *AsyncMockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *AsyncMockKetoService) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]service.RelationTuple, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tests ---
|
||||||
|
|
||||||
|
func TestSignup_AsyncDB_Isolation(t *testing.T) {
|
||||||
|
mockIdp := new(AsyncMockIdpProvider)
|
||||||
|
mockUserRepo := new(AsyncMockUserRepo)
|
||||||
|
mockRedis := new(AsyncMockRedisRepo)
|
||||||
|
mockTenant := new(AsyncMockTenantService)
|
||||||
|
mockKeto := new(AsyncMockKetoService)
|
||||||
|
|
||||||
|
h := &AuthHandler{
|
||||||
|
IdpProvider: mockIdp,
|
||||||
|
UserRepo: mockUserRepo,
|
||||||
|
RedisService: mockRedis,
|
||||||
|
TenantService: mockTenant,
|
||||||
|
KetoService: mockKeto,
|
||||||
|
}
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
app.Post("/signup", h.Signup)
|
||||||
|
|
||||||
|
t.Run("SoT_DB_Failure_Ignored_And_Async", func(t *testing.T) {
|
||||||
|
email := "test@example.com"
|
||||||
|
phone := "010-1234-5678"
|
||||||
|
emailKey := "signup:email:" + email
|
||||||
|
phoneKey := "signup:phone:" + "01012345678"
|
||||||
|
|
||||||
|
// Redis Mocks
|
||||||
|
mockRedis.On("Get", emailKey).Return(`{"verified": true, "expires_at": 9999999999}`, nil)
|
||||||
|
mockRedis.On("Get", phoneKey).Return(`{"verified": true, "expires_at": 9999999999}`, nil)
|
||||||
|
mockRedis.On("Delete", emailKey).Return(nil)
|
||||||
|
mockRedis.On("Delete", phoneKey).Return(nil)
|
||||||
|
|
||||||
|
// Tenant Mocks
|
||||||
|
mockTenant.On("GetTenantByDomain", mock.Anything, "example.com").Return(nil, errors.New("not found"))
|
||||||
|
|
||||||
|
// Kratos Mocks (Success)
|
||||||
|
mockIdp.On("CreateUser", mock.Anything, "Password123!").Return("new-user-uuid", nil)
|
||||||
|
|
||||||
|
// UserRepo Mocks (Async & Failure)
|
||||||
|
mockUserRepo.createCalled = make(chan bool, 1)
|
||||||
|
mockUserRepo.On("Create", mock.Anything, mock.MatchedBy(func(u *domain.User) bool {
|
||||||
|
return u.Email == email
|
||||||
|
})).Return(errors.New("db connection error"))
|
||||||
|
|
||||||
|
// Keto Mocks (Optional, since it's also async)
|
||||||
|
// We won't block on this either
|
||||||
|
|
||||||
|
body, _ := json.Marshal(domain.SignupRequest{
|
||||||
|
Email: email,
|
||||||
|
Password: "Password123!",
|
||||||
|
Name: "Test User",
|
||||||
|
Phone: phone,
|
||||||
|
TermsAccepted: true,
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest("POST", "/signup", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := app.Test(req, 5000)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Request failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
|
// Ensure API responded faster than DB latency (50ms)
|
||||||
|
assert.Less(t, int64(elapsed), int64(60*time.Millisecond), "API should return before DB timeout")
|
||||||
|
|
||||||
|
// Wait for async execution
|
||||||
|
select {
|
||||||
|
case <-mockUserRepo.createCalled:
|
||||||
|
// Pass
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("UserRepo.Create was not called asynchronously")
|
||||||
|
}
|
||||||
|
|
||||||
|
mockRedis.AssertExpectations(t)
|
||||||
|
mockIdp.AssertExpectations(t)
|
||||||
|
mockUserRepo.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -304,10 +304,15 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
|||||||
localUser.TenantID = &tenantID
|
localUser.TenantID = &tenantID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [SoT Policy] Kratos가 SoT이므로 로컬 DB 저장은 비동기 Read-Model 동기화로 처리합니다.
|
||||||
if h.UserRepo != nil {
|
if h.UserRepo != nil {
|
||||||
if err := h.UserRepo.Create(c.Context(), localUser); err != nil {
|
go func(u *domain.User) {
|
||||||
slog.Error("[UserHandler] Failed to sync user to local DB", "email", email, "error", err)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
}
|
defer cancel()
|
||||||
|
if err := h.UserRepo.Create(ctx, u); err != nil {
|
||||||
|
slog.Error("[UserHandler] Failed to sync user to local DB", "email", u.Email, "error", err)
|
||||||
|
}
|
||||||
|
}(localUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
// [Keto] Sync relations
|
// [Keto] Sync relations
|
||||||
@@ -483,27 +488,32 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
localUser.Metadata = req.Metadata
|
localUser.Metadata = req.Metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.UserRepo.Update(c.Context(), localUser); err == nil {
|
// [SoT Policy] Kratos가 SoT이므로 로컬 DB 저장은 비동기 Read-Model 동기화로 처리합니다.
|
||||||
// [Keto Sync on Role Change]
|
go func(u *domain.User, rRole *string, oRole string, oTenantID string) {
|
||||||
if h.KetoService != nil && req.Role != nil && *req.Role != oldRole {
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
go func(uID, oldR, newR, tID string) {
|
defer cancel()
|
||||||
ctx := context.Background()
|
|
||||||
if oldR == domain.RoleSuperAdmin {
|
if err := h.UserRepo.Update(ctx, u); err == nil {
|
||||||
|
// [Keto Sync on Role Change]
|
||||||
|
if h.KetoService != nil && rRole != nil && *rRole != oRole {
|
||||||
|
uID := u.ID
|
||||||
|
newR := *rRole
|
||||||
|
if oRole == domain.RoleSuperAdmin {
|
||||||
_ = h.KetoService.DeleteRelation(ctx, "System", "global", "super_admins", uID)
|
_ = h.KetoService.DeleteRelation(ctx, "System", "global", "super_admins", uID)
|
||||||
} else if oldR == domain.RoleTenantAdmin && tID != "" {
|
} else if oRole == domain.RoleTenantAdmin && oTenantID != "" {
|
||||||
_ = h.KetoService.DeleteRelation(ctx, "Tenant", tID, "admins", uID)
|
_ = h.KetoService.DeleteRelation(ctx, "Tenant", oTenantID, "admins", uID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if newR == domain.RoleSuperAdmin {
|
if newR == domain.RoleSuperAdmin {
|
||||||
_ = h.KetoService.CreateRelation(ctx, "System", "global", "super_admins", uID)
|
_ = h.KetoService.CreateRelation(ctx, "System", "global", "super_admins", uID)
|
||||||
} else if newR == domain.RoleTenantAdmin && tID != "" {
|
} else if newR == domain.RoleTenantAdmin && u.TenantID != nil {
|
||||||
_ = h.KetoService.CreateRelation(ctx, "Tenant", tID, "admins", uID)
|
_ = h.KetoService.CreateRelation(ctx, "Tenant", *u.TenantID, "admins", uID)
|
||||||
}
|
}
|
||||||
}(userID, oldRole, *req.Role, oldTenantID)
|
}
|
||||||
|
} else {
|
||||||
|
slog.Error("[UserHandler] Failed to sync user update to local DB", "userID", u.ID, "error", err)
|
||||||
}
|
}
|
||||||
} else {
|
}(localUser, req.Role, oldRole, oldTenantID)
|
||||||
slog.Error("[UserHandler] Failed to sync user update to local DB", "userID", userID, "error", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
57
devfront/src/components/common/LanguageSelector.tsx
Normal file
57
devfront/src/components/common/LanguageSelector.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { t } from "../../lib/i18n";
|
||||||
|
|
||||||
|
const LOCALE_STORAGE_KEY = "locale";
|
||||||
|
const SUPPORTED_LOCALES = ["ko", "en"] as const;
|
||||||
|
|
||||||
|
type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||||
|
|
||||||
|
function resolveLocale(): Locale {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return "ko";
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||||
|
if (stored === "ko" || stored === "en") {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathLocale = window.location.pathname.split("/")[1];
|
||||||
|
if (pathLocale === "ko" || pathLocale === "en") {
|
||||||
|
return pathLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
const browserLang = window.navigator.language.toLowerCase();
|
||||||
|
return browserLang.startsWith("ko") ? "ko" : "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
function LanguageSelector() {
|
||||||
|
const [locale, setLocale] = useState<Locale>(resolveLocale());
|
||||||
|
|
||||||
|
const handleChange = (next: Locale) => {
|
||||||
|
if (next === locale) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.localStorage.setItem(LOCALE_STORAGE_KEY, next);
|
||||||
|
setLocale(next);
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={locale}
|
||||||
|
onChange={(event) => handleChange(event.target.value as Locale)}
|
||||||
|
className="rounded-full border border-border bg-transparent px-3 py-2 text-sm text-muted-foreground transition hover:bg-muted/20"
|
||||||
|
aria-label={t("ui.common.language", "언어")}
|
||||||
|
>
|
||||||
|
<option value="ko">
|
||||||
|
{t("ui.common.language_ko", "한국어")}
|
||||||
|
</option>
|
||||||
|
<option value="en">
|
||||||
|
{t("ui.common.language_en", "English")}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LanguageSelector;
|
||||||
@@ -2,6 +2,7 @@ import { BadgeCheck, Moon, ShieldHalf, Sun } from "lucide-react";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { NavLink, Outlet } from "react-router-dom";
|
import { NavLink, Outlet } from "react-router-dom";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
|
import LanguageSelector from "../common/LanguageSelector";
|
||||||
import { Toaster } from "../ui/toaster";
|
import { Toaster } from "../ui/toaster";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
@@ -105,6 +106,7 @@ function AppLayout() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<LanguageSelector />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ services:
|
|||||||
- GO_ENV=${APP_ENV:-development}
|
- GO_ENV=${APP_ENV:-development}
|
||||||
- COOKIE_SECRET=${COOKIE_SECRET}
|
- COOKIE_SECRET=${COOKIE_SECRET}
|
||||||
- JWT_SECRET=${JWT_SECRET}
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
- DESCOPE_PROJECT_ID=${DESCOPE_PROJECT_ID}
|
- DESCOPE_PROJECT_ID=${DESCOPE_PROJECT_ID:-}
|
||||||
- DESCOPE_MANAGEMENT_KEY=${DESCOPE_MANAGEMENT_KEY}
|
- DESCOPE_MANAGEMENT_KEY=${DESCOPE_MANAGEMENT_KEY:-}
|
||||||
- NAVER_CLOUD_ACCESS_KEY=${NAVER_CLOUD_ACCESS_KEY}
|
- NAVER_CLOUD_ACCESS_KEY=${NAVER_CLOUD_ACCESS_KEY}
|
||||||
- NAVER_CLOUD_SECRET_KEY=${NAVER_CLOUD_SECRET_KEY}
|
- NAVER_CLOUD_SECRET_KEY=${NAVER_CLOUD_SECRET_KEY}
|
||||||
- NAVER_CLOUD_SERVICE_ID=${NAVER_CLOUD_SERVICE_ID}
|
- NAVER_CLOUD_SERVICE_ID=${NAVER_CLOUD_SERVICE_ID}
|
||||||
@@ -82,13 +82,13 @@ services:
|
|||||||
|
|
||||||
userfront:
|
userfront:
|
||||||
build:
|
build:
|
||||||
context: ./userfront
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: userfront/Dockerfile
|
||||||
container_name: baron_userfront
|
container_name: baron_userfront
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- BACKEND_URL=${BACKEND_URL}
|
- BACKEND_URL=${BACKEND_URL:-}
|
||||||
- USERFRONT_URL=${USERFRONT_URL}
|
- USERFRONT_URL=${USERFRONT_URL}
|
||||||
- APP_ENV=${APP_ENV}
|
- APP_ENV=${APP_ENV}
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -1,135 +1,136 @@
|
|||||||
services:
|
services:
|
||||||
postgres_ory:
|
postgres_ory:
|
||||||
image: postgres:${ORY_POSTGRES_TAG:-17-alpine}
|
image: postgres:${ORY_POSTGRES_TAG:-17-alpine}
|
||||||
container_name: ory_postgres
|
container_name: ory_postgres
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_USER=${ORY_POSTGRES_USER:-ory}
|
- POSTGRES_USER=${ORY_POSTGRES_USER:-ory}
|
||||||
- POSTGRES_PASSWORD=${ORY_POSTGRES_PASSWORD:-secret}
|
- POSTGRES_PASSWORD=${ORY_POSTGRES_PASSWORD:-secret}
|
||||||
- POSTGRES_DB=${ORY_POSTGRES_DB:-ory}
|
- POSTGRES_DB=${ORY_POSTGRES_DB:-ory}
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/ory/init-db:/docker-entrypoint-initdb.d
|
- ./docker/ory/init-db:/docker-entrypoint-initdb.d
|
||||||
- ory_postgres_data:/var/lib/postgresql/data
|
- ory_postgres_data:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
- ory-net
|
- ory-net
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
[
|
[
|
||||||
"CMD-SHELL",
|
"CMD-SHELL",
|
||||||
"pg_isready -U ${ORY_POSTGRES_USER:-ory} -d ${KRATOS_DB:-ory_kratos}",
|
"pg_isready -U ${ORY_POSTGRES_USER:-ory} -d ${KRATOS_DB:-ory_kratos}",
|
||||||
]
|
]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
kratos-migrate:
|
kratos-migrate:
|
||||||
image: oryd/kratos:${KRATOS_VERSION:-v25.4.0}
|
image: oryd/kratos:${KRATOS_VERSION:-v25.4.0}
|
||||||
environment:
|
environment:
|
||||||
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20
|
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20
|
||||||
- KRATOS_SERVE_PUBLIC_BASE_URL="${KRATOS_BROWSER_URL:-http://localhost:4433}"
|
- KRATOS_SERVE_PUBLIC_BASE_URL="${KRATOS_BROWSER_URL:-http://localhost:4433}"
|
||||||
- KRATOS_SERVE_ADMIN_BASE_URL="${KRATOS_ADMIN_URL:-http://kratos:4434}"
|
- KRATOS_SERVE_ADMIN_BASE_URL="${KRATOS_ADMIN_URL:-http://kratos:4434}"
|
||||||
- KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL="${KRATOS_UI_URL:-http://localhost:5000}"
|
- KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL="${KRATOS_UI_URL:-http://localhost:5000}"
|
||||||
- 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 up -e -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
|
||||||
networks:
|
networks:
|
||||||
- ory-net
|
- ory-net
|
||||||
|
|
||||||
kratos:
|
kratos:
|
||||||
image: oryd/kratos:${KRATOS_VERSION:-v25.4.0}
|
image: oryd/kratos:${KRATOS_VERSION:-v25.4.0}
|
||||||
container_name: ory_kratos
|
container_name: ory_kratos
|
||||||
environment:
|
environment:
|
||||||
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20
|
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20
|
||||||
- COOKIE_SECRET="${COOKIE_SECRET:-localcookie123}"
|
- COOKIE_SECRET="${COOKIE_SECRET:-localcookie123}"
|
||||||
- KRATOS_SERVE_PUBLIC_BASE_URL="${KRATOS_BROWSER_URL:-http://localhost:4433}"
|
- KRATOS_SERVE_PUBLIC_BASE_URL="${KRATOS_BROWSER_URL:-http://localhost:4433}"
|
||||||
- KRATOS_SERVE_ADMIN_BASE_URL="${KRATOS_ADMIN_URL:-http://kratos:4434}"
|
- KRATOS_SERVE_ADMIN_BASE_URL="${KRATOS_ADMIN_URL:-http://kratos:4434}"
|
||||||
- KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL="${KRATOS_UI_URL:-http://localhost:5000}"
|
- KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL="${KRATOS_UI_URL:-http://localhost:5000}"
|
||||||
- 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: serve -c /etc/config/kratos/kratos.yml
|
command: serve -c /etc/config/kratos/kratos.yml
|
||||||
depends_on:
|
depends_on:
|
||||||
kratos-migrate:
|
kratos-migrate:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
networks:
|
networks:
|
||||||
- ory-net
|
- ory-net
|
||||||
- kratosnet
|
- kratosnet
|
||||||
|
|
||||||
hydra-migrate:
|
hydra-migrate:
|
||||||
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 up -e --yes
|
command: migrate sql up -e --yes
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres_ory:
|
postgres_ory:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- ory-net
|
- ory-net
|
||||||
|
|
||||||
hydra:
|
hydra:
|
||||||
image: oryd/hydra:${HYDRA_VERSION:-v25.4.0}
|
image: oryd/hydra:${HYDRA_VERSION:-v25.4.0}
|
||||||
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
|
||||||
depends_on:
|
depends_on:
|
||||||
hydra-migrate:
|
hydra-migrate:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
networks:
|
networks:
|
||||||
- ory-net
|
- ory-net
|
||||||
- hydranet
|
- hydranet
|
||||||
|
|
||||||
# [수정됨] Oathkeeper 서비스 추가 (Backend 연결 문제 해결)
|
# [수정됨] Oathkeeper 서비스 추가 (Backend 연결 문제 해결)
|
||||||
oathkeeper:
|
oathkeeper:
|
||||||
image: oryd/oathkeeper:${OATHKEEPER_VERSION:-v0.40.6}
|
image: oryd/oathkeeper:${OATHKEEPER_VERSION:-v0.40.6}
|
||||||
container_name: oathkeeper
|
container_name: oathkeeper
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
kratos:
|
kratos:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
environment:
|
environment:
|
||||||
- LOG_LEVEL=debug
|
- LOG_LEVEL=debug
|
||||||
command: serve proxy --config /etc/config/oathkeeper/oathkeeper.yml
|
command: serve proxy --config /etc/config/oathkeeper/oathkeeper.yml
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/ory/oathkeeper:/etc/config/oathkeeper
|
- ./docker/ory/oathkeeper:/etc/config/oathkeeper
|
||||||
networks:
|
- oathkeeper_logs:/var/log/oathkeeper
|
||||||
- ory-net
|
networks:
|
||||||
- baron_net # Backend가 통신하기 위해 필수
|
- ory-net
|
||||||
- public_net
|
- baron_net # Backend가 통신하기 위해 필수
|
||||||
ports:
|
- public_net
|
||||||
- "4455:4455" # Proxy
|
ports:
|
||||||
- "4456:4456" # API (Backend 헬스체크용)
|
- "4455:4455" # Proxy
|
||||||
healthcheck:
|
- "4456:4456" # API (Backend 헬스체크용)
|
||||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4456/health/ready"]
|
healthcheck:
|
||||||
interval: 5s
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4456/health/ready"]
|
||||||
timeout: 5s
|
interval: 5s
|
||||||
retries: 5
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
volumes:
|
volumes:
|
||||||
ory_postgres_data:
|
ory_postgres_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
ory-net:
|
ory-net:
|
||||||
external: true
|
external: true
|
||||||
name: ory-net
|
name: ory-net
|
||||||
hydranet:
|
hydranet:
|
||||||
external: true
|
external: true
|
||||||
name: hydranet
|
name: hydranet
|
||||||
kratosnet:
|
kratosnet:
|
||||||
external: true
|
external: true
|
||||||
name: kratosnet
|
name: kratosnet
|
||||||
public_net:
|
public_net:
|
||||||
external: true
|
external: true
|
||||||
name: public_net
|
name: public_net
|
||||||
# [수정됨] Baron Net 추가 정의 (Oathkeeper 연결용)
|
# [수정됨] Baron Net 추가 정의 (Oathkeeper 연결용)
|
||||||
baron_net:
|
baron_net:
|
||||||
external: true
|
external: true
|
||||||
name: baron_net
|
name: baron_net
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- BACKEND_URL=${BACKEND_URL}
|
- BACKEND_URL=${BACKEND_URL:-}
|
||||||
- USERFRONT_URL=${USERFRONT_URL}
|
- USERFRONT_URL=${USERFRONT_URL}
|
||||||
- APP_ENV=stage
|
- APP_ENV=stage
|
||||||
networks:
|
networks:
|
||||||
@@ -75,7 +75,7 @@ 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 &&
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
version: v1.3.0
|
version: v25.4.0
|
||||||
|
|
||||||
dsn: ${DSN}
|
dsn: ${DSN}
|
||||||
|
|
||||||
@@ -7,19 +7,26 @@ serve:
|
|||||||
base_url: http://localhost:4433/
|
base_url: http://localhost:4433/
|
||||||
cors:
|
cors:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
allowed_origins:
|
||||||
|
- http://backend:3000
|
||||||
|
- http://baron_backend:3000
|
||||||
admin:
|
admin:
|
||||||
base_url: http://localhost:4434/
|
base_url: http://localhost:4434/
|
||||||
|
|
||||||
selfservice:
|
selfservice:
|
||||||
default_browser_return_url: http://localhost:5000/
|
default_browser_return_url: http://localhost:5000/
|
||||||
allowed_return_urls:
|
allowed_return_urls:
|
||||||
|
- http://baron_backend:3000
|
||||||
|
- http://baron_backend:3000/
|
||||||
- http://localhost:5000
|
- http://localhost:5000
|
||||||
|
- https://app.brsw.kr
|
||||||
|
- https://app.brsw.kr/
|
||||||
- https://sss.hmac.kr
|
- https://sss.hmac.kr
|
||||||
- https://sss.hmac.kr/
|
- https://sss.hmac.kr/
|
||||||
- https://sso.hmac.kr
|
- https://sso.hmac.kr
|
||||||
- https://sso.hmac.kr/
|
- https://sso.hmac.kr/
|
||||||
- https://app.hmac.kr
|
- https://ssologin.hmac.kr
|
||||||
- https://app.hmac.kr/
|
- https://ssologin.hmac.kr/
|
||||||
|
|
||||||
methods:
|
methods:
|
||||||
password:
|
password:
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ mkdir -p "$LOG_DIR"
|
|||||||
if ! touch "$LOG_FILE" 2>/dev/null; then
|
if ! touch "$LOG_FILE" 2>/dev/null; then
|
||||||
echo "[oathkeeper] log file not writable: $LOG_FILE"
|
echo "[oathkeeper] log file not writable: $LOG_FILE"
|
||||||
ls -ld "$LOG_DIR" || true
|
ls -ld "$LOG_DIR" || true
|
||||||
exit 1
|
LOG_FILE=""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exec /bin/sh -c "oathkeeper serve proxy -c /etc/config/oathkeeper/oathkeeper.yml 2>&1 | tee \"$LOG_FILE\""
|
if [ -n "$LOG_FILE" ]; then
|
||||||
|
exec /bin/sh -c "oathkeeper serve proxy -c /etc/config/oathkeeper/oathkeeper.yml 2>&1 | tee \"$LOG_FILE\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec /bin/sh -c "oathkeeper serve proxy -c /etc/config/oathkeeper/oathkeeper.yml"
|
||||||
|
|||||||
400
docker/staging_pull_compose.template.yaml
Normal file
400
docker/staging_pull_compose.template.yaml
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
container_name: baron_postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: "${DB_USER:-baron}"
|
||||||
|
POSTGRES_PASSWORD: "${DB_PASSWORD:-password}"
|
||||||
|
POSTGRES_DB: "${DB_NAME:-baron_sso}"
|
||||||
|
ports:
|
||||||
|
- "${DB_PORT:-5432}:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./docker/init-metadata:/docker-entrypoint-initdb.d
|
||||||
|
networks:
|
||||||
|
- baron_net
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD-SHELL",
|
||||||
|
"pg_isready -U ${DB_USER:-baron} -d ${DB_NAME:-baron_sso}",
|
||||||
|
]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
clickhouse:
|
||||||
|
image: clickhouse/clickhouse-server:latest
|
||||||
|
container_name: baron_clickhouse
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- clickhouse_data:/var/lib/clickhouse
|
||||||
|
environment:
|
||||||
|
CLICKHOUSE_USER: "${CLICKHOUSE_USER:-baron}"
|
||||||
|
CLICKHOUSE_PASSWORD: "${CLICKHOUSE_PASSWORD:-password}"
|
||||||
|
networks:
|
||||||
|
- baron_net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "clickhouse-client", "--query", "SELECT 1"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: baron_redis
|
||||||
|
restart: always
|
||||||
|
command: redis-server --port 6389
|
||||||
|
ports:
|
||||||
|
- "6389:6389"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
networks:
|
||||||
|
- baron_net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "-p", "6389", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
gateway:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: baron_gateway
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "${USERFRONT_PORT:-5000}:5000"
|
||||||
|
volumes:
|
||||||
|
- ./gateway/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
networks:
|
||||||
|
- baron_net
|
||||||
|
- public_net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5000/"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
postgres_ory:
|
||||||
|
image: postgres:${ORY_POSTGRES_TAG:-17-alpine}
|
||||||
|
container_name: ory_postgres
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=${ORY_POSTGRES_USER:-ory}
|
||||||
|
- POSTGRES_PASSWORD=${ORY_POSTGRES_PASSWORD:-secret}
|
||||||
|
- POSTGRES_DB=${ORY_POSTGRES_DB:-ory}
|
||||||
|
volumes:
|
||||||
|
- ./docker/ory/init-db:/docker-entrypoint-initdb.d
|
||||||
|
- ory_postgres_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- ory-net
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD-SHELL",
|
||||||
|
"pg_isready -U ${ORY_POSTGRES_USER:-ory} -d ${KRATOS_DB:-ory_kratos}",
|
||||||
|
]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
kratos-migrate:
|
||||||
|
image: oryd/kratos:${KRATOS_VERSION:-v25.4.0}
|
||||||
|
environment:
|
||||||
|
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20
|
||||||
|
- KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL:-http://localhost:4433}
|
||||||
|
- KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL:-http://kratos:4434}
|
||||||
|
- KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000}
|
||||||
|
- KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=["${KRATOS_UI_URL:-http://localhost:5000}","${USERFRONT_URL:-http://localhost:5000}"]
|
||||||
|
- KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/error
|
||||||
|
- KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/error?error=settings_disabled
|
||||||
|
- KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/recovery
|
||||||
|
- KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/verification
|
||||||
|
- KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/login
|
||||||
|
- KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/registration
|
||||||
|
- KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000}/login
|
||||||
|
volumes:
|
||||||
|
- ./docker/ory/kratos:/etc/config/kratos
|
||||||
|
command: migrate sql up -e -c /etc/config/kratos/kratos.yml --yes
|
||||||
|
depends_on:
|
||||||
|
postgres_ory:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- ory-net
|
||||||
|
|
||||||
|
kratos:
|
||||||
|
image: oryd/kratos:${KRATOS_VERSION:-v25.4.0}
|
||||||
|
container_name: ory_kratos
|
||||||
|
environment:
|
||||||
|
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20
|
||||||
|
- COOKIE_SECRET=${COOKIE_SECRET:-localcookie123}
|
||||||
|
- KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL:-http://localhost:4433}
|
||||||
|
- KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL:-http://kratos:4434}
|
||||||
|
- KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000}
|
||||||
|
- KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=["${KRATOS_UI_URL:-http://localhost:5000}","${USERFRONT_URL:-http://localhost:5000}"]
|
||||||
|
- KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/error
|
||||||
|
- KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/error?error=settings_disabled
|
||||||
|
- KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/recovery
|
||||||
|
- KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/verification
|
||||||
|
- KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/login
|
||||||
|
- KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/registration
|
||||||
|
- KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000}/login
|
||||||
|
volumes:
|
||||||
|
- ./docker/ory/kratos:/etc/config/kratos
|
||||||
|
command: serve -c /etc/config/kratos/kratos.yml --dev --watch-courier
|
||||||
|
depends_on:
|
||||||
|
kratos-migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
networks:
|
||||||
|
- ory-net
|
||||||
|
- kratosnet
|
||||||
|
|
||||||
|
hydra-migrate:
|
||||||
|
image: oryd/hydra:${HYDRA_VERSION:-v25.4.0}
|
||||||
|
environment:
|
||||||
|
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20
|
||||||
|
command: migrate sql up -e --yes
|
||||||
|
depends_on:
|
||||||
|
postgres_ory:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- ory-net
|
||||||
|
|
||||||
|
hydra:
|
||||||
|
image: oryd/hydra:${HYDRA_VERSION:-v25.4.0}
|
||||||
|
container_name: ory_hydra
|
||||||
|
environment:
|
||||||
|
- 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_LOGIN=${USERFRONT_URL:-http://localhost:5000}/login
|
||||||
|
- URLS_CONSENT=${USERFRONT_URL:-http://localhost:5000}/consent
|
||||||
|
- SECRETS_SYSTEM=${ORY_POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- ./docker/ory/hydra:/etc/config/hydra
|
||||||
|
command: serve -c /etc/config/hydra/hydra.yml all --dev
|
||||||
|
depends_on:
|
||||||
|
hydra-migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
networks:
|
||||||
|
- ory-net
|
||||||
|
- hydranet
|
||||||
|
|
||||||
|
keto-migrate:
|
||||||
|
image: oryd/keto:${KETO_VERSION:-v25.4.0}
|
||||||
|
environment:
|
||||||
|
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20
|
||||||
|
volumes:
|
||||||
|
- ./docker/ory/keto:/etc/config/keto
|
||||||
|
command: ["migrate", "up", "-c", "/etc/config/keto/keto.yml", "--yes"]
|
||||||
|
depends_on:
|
||||||
|
postgres_ory:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- ory-net
|
||||||
|
|
||||||
|
keto:
|
||||||
|
image: oryd/keto:${KETO_VERSION:-v25.4.0}
|
||||||
|
container_name: ory_keto
|
||||||
|
environment:
|
||||||
|
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20
|
||||||
|
volumes:
|
||||||
|
- ./docker/ory/keto:/etc/config/keto
|
||||||
|
command: serve -c /etc/config/keto/keto.yml
|
||||||
|
depends_on:
|
||||||
|
keto-migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
networks:
|
||||||
|
- ory-net
|
||||||
|
|
||||||
|
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
|
||||||
|
- oathkeeper_logs:/var/log/oathkeeper
|
||||||
|
networks:
|
||||||
|
- ory-net
|
||||||
|
- baron_net
|
||||||
|
- public_net
|
||||||
|
ports:
|
||||||
|
- "4455:4455"
|
||||||
|
- "4456:4456"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4456/health/ready"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
ory_clickhouse:
|
||||||
|
image: clickhouse/clickhouse-server:latest
|
||||||
|
container_name: ory_clickhouse
|
||||||
|
environment:
|
||||||
|
- CLICKHOUSE_USER=${ORY_CLICKHOUSE_USER:-ory}
|
||||||
|
- CLICKHOUSE_PASSWORD=${ORY_CLICKHOUSE_PASSWORD:-orypass}
|
||||||
|
volumes:
|
||||||
|
- ory_clickhouse_data:/var/lib/clickhouse
|
||||||
|
- ./docker/ory/clickhouse:/docker-entrypoint-initdb.d
|
||||||
|
networks:
|
||||||
|
- ory-net
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: baron_backend
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- APP_ENV=${APP_ENV:-development}
|
||||||
|
- GO_ENV=${APP_ENV:-development}
|
||||||
|
- COOKIE_SECRET=${COOKIE_SECRET}
|
||||||
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
|
- DESCOPE_PROJECT_ID=${DESCOPE_PROJECT_ID:-}
|
||||||
|
- DESCOPE_MANAGEMENT_KEY=${DESCOPE_MANAGEMENT_KEY:-}
|
||||||
|
- NAVER_CLOUD_ACCESS_KEY=${NAVER_CLOUD_ACCESS_KEY}
|
||||||
|
- NAVER_CLOUD_SECRET_KEY=${NAVER_CLOUD_SECRET_KEY}
|
||||||
|
- NAVER_CLOUD_SERVICE_ID=${NAVER_CLOUD_SERVICE_ID}
|
||||||
|
- NAVER_SENDER_PHONE_NUMBER=${NAVER_SENDER_PHONE_NUMBER}
|
||||||
|
- USERFRONT_URL=${USERFRONT_URL}
|
||||||
|
- REDIS_ADDR=${REDIS_ADDR}
|
||||||
|
- IDP_PROVIDER=${IDP_PROVIDER:-ory}
|
||||||
|
- KRATOS_ADMIN_URL=${KRATOS_ADMIN_URL:-http://kratos:4434}
|
||||||
|
- HYDRA_ADMIN_URL=${HYDRA_ADMIN_URL:-http://hydra:4445}
|
||||||
|
- HYDRA_PUBLIC_URL=${HYDRA_PUBLIC_URL:-http://hydra:4444}
|
||||||
|
- DB_HOST=postgres
|
||||||
|
- CLICKHOUSE_HOST=clickhouse
|
||||||
|
- CLICKHOUSE_PORT=${CLICKHOUSE_PORT_NATIVE:-9000}
|
||||||
|
- CLICKHOUSE_USER=${CLICKHOUSE_USER:-baron}
|
||||||
|
- CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD:-password}
|
||||||
|
depends_on:
|
||||||
|
clickhouse:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
oathkeeper:
|
||||||
|
condition: service_healthy
|
||||||
|
kratos:
|
||||||
|
condition: service_started
|
||||||
|
hydra:
|
||||||
|
condition: service_started
|
||||||
|
keto:
|
||||||
|
condition: service_started
|
||||||
|
infra_check:
|
||||||
|
condition: service_started
|
||||||
|
networks:
|
||||||
|
- baron_net
|
||||||
|
- ory-net
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
command: ["go", "run", "./cmd/server"]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
adminfront:
|
||||||
|
build:
|
||||||
|
context: ./adminfront
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: baron_adminfront
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- APP_ENV=${APP_ENV:-development}
|
||||||
|
- API_PROXY_TARGET=http://baron_backend:3000
|
||||||
|
ports:
|
||||||
|
- "${ADMIN_PORT:-5173}:5173"
|
||||||
|
volumes:
|
||||||
|
- ./adminfront:/app
|
||||||
|
- /app/node_modules
|
||||||
|
networks:
|
||||||
|
- baron_net
|
||||||
|
|
||||||
|
devfront:
|
||||||
|
build:
|
||||||
|
context: ./devfront
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: baron_devfront
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- APP_ENV=${APP_ENV:-development}
|
||||||
|
- API_PROXY_TARGET=http://baron_backend:3000
|
||||||
|
ports:
|
||||||
|
- "${DEVFRONT_PORT:-5174}:5173"
|
||||||
|
volumes:
|
||||||
|
- ./devfront:/app
|
||||||
|
- /app/node_modules
|
||||||
|
networks:
|
||||||
|
- baron_net
|
||||||
|
|
||||||
|
userfront:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: userfront/Dockerfile
|
||||||
|
container_name: baron_userfront
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- BACKEND_URL=${BACKEND_URL:-}
|
||||||
|
- USERFRONT_URL=${USERFRONT_URL}
|
||||||
|
- APP_ENV=${APP_ENV}
|
||||||
|
networks:
|
||||||
|
- baron_net
|
||||||
|
- ory-net
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
command: >
|
||||||
|
/bin/sh -c "mkdir -p /usr/share/nginx/html/assets &&
|
||||||
|
echo \"BACKEND_URL=$${BACKEND_URL}\" >> /usr/share/nginx/html/assets/.env &&
|
||||||
|
echo \"USERFRONT_URL=$${USERFRONT_URL}\" >> /usr/share/nginx/html/assets/.env &&
|
||||||
|
echo \"APP_ENV=$${APP_ENV}\" >> /usr/share/nginx/html/assets/.env &&
|
||||||
|
cp /usr/share/nginx/html/assets/.env /usr/share/nginx/html/.env &&
|
||||||
|
nginx -g 'daemon off;'"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5000/"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
infra_check:
|
||||||
|
image: alpine
|
||||||
|
command: ["echo", "Infrastructure assumed running"]
|
||||||
|
networks:
|
||||||
|
- baron_net
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
clickhouse_data:
|
||||||
|
redis_data:
|
||||||
|
ory_postgres_data:
|
||||||
|
ory_clickhouse_data:
|
||||||
|
oathkeeper_logs:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
baron_net:
|
||||||
|
external: true
|
||||||
|
name: baron_net
|
||||||
|
public_net:
|
||||||
|
external: true
|
||||||
|
name: public_net
|
||||||
|
ory-net:
|
||||||
|
external: true
|
||||||
|
name: ory-net
|
||||||
|
hydranet:
|
||||||
|
external: true
|
||||||
|
name: hydranet
|
||||||
|
kratosnet:
|
||||||
|
external: true
|
||||||
|
name: kratosnet
|
||||||
44
docs/SoT_Architecture_Policy.md
Normal file
44
docs/SoT_Architecture_Policy.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Baron SSO Data SoT (Source of Truth) Architecture Policy
|
||||||
|
|
||||||
|
## 1. Core Principle: "Ory Stack is the Single Source of Truth"
|
||||||
|
Baron SSO 시스템에서 인증(Identity), 인가(Authorization), OAuth2 위임(Delegation)의 데이터 원천은 **Ory Stack (Kratos, Keto, Hydra)** 입니다.
|
||||||
|
Backend의 로컬 데이터베이스(PostgreSQL)는 성능 최적화, 검색, 감사(Audit), 비즈니스 메타데이터 관리를 위한 **Read-Model** 및 **Cold Storage**의 역할만 수행합니다.
|
||||||
|
|
||||||
|
## 2. Component Policies
|
||||||
|
|
||||||
|
### 2.1 Identity & User Profile (Ory Kratos)
|
||||||
|
* **SoT:** Ory Kratos Identity (`traits`, `metadata_public`)
|
||||||
|
* **Local DB (`users` Table):** **Read-Model & Search Index**
|
||||||
|
* **목적:** 대규모 사용자 목록의 고속 검색(`LIKE`), 필터링, 정렬, 테넌트 조인(Join) 지원.
|
||||||
|
* **동기화 전략:** `Async Write-Behind`
|
||||||
|
* 사용자 생성/수정 API는 Kratos 처리가 성공하면 즉시 성공 응답을 반환합니다.
|
||||||
|
* 로컬 DB 동기화는 별도 고루틴(Goroutine)에서 비동기로 수행됩니다.
|
||||||
|
* **장애 격리:** 로컬 DB 장애가 사용자의 로그인/가입 프로세스를 차단하지 않습니다.
|
||||||
|
|
||||||
|
### 2.2 Permissions & Relationships (Ory Keto)
|
||||||
|
* **SoT:** Ory Keto (Relation Tuples)
|
||||||
|
* **Local DB:**
|
||||||
|
* 권한 판단 로직을 로컬 DB에 저장하지 않습니다.
|
||||||
|
* `Tenant`, `TenantGroup` 등 비즈니스 객체의 **생성/삭제 이벤트**를 Keto의 관계(Relation)로 비동기 동기화합니다.
|
||||||
|
* 모든 권한 검증(`CheckPermission`)은 반드시 Keto API를 통해 실시간으로 수행합니다.
|
||||||
|
|
||||||
|
### 2.3 OAuth2 Clients & Sessions (Ory Hydra)
|
||||||
|
* **SoT:** Ory Hydra (OAuth2 Clients, Access/Refresh Tokens, Consent Sessions)
|
||||||
|
* **Local DB (`client_secrets`, `client_consents`):** **Backup & Query-Model**
|
||||||
|
* `client_secrets`: Hydra는 해시된 시크릿만 저장하므로, 시크릿 재발급 및 관리를 위한 **원본 보관소(Cold Storage)**로 사용합니다.
|
||||||
|
* `client_consents`: Hydra API는 "특정 사용자의 동의 내역" 조회만 지원하므로, "특정 클라이언트의 전체 사용자 동의 목록"을 제공하기 위한 **조회용 모델(Query-Model)**로 사용합니다.
|
||||||
|
|
||||||
|
## 3. Data Flow & Synchronization Strategy
|
||||||
|
|
||||||
|
### 3.1 Write Path (Command)
|
||||||
|
1. **Request:** 클라이언트가 Backend API 요청.
|
||||||
|
2. **Ory Exec:** Backend가 Ory 서비스(Kratos/Hydra/Keto) API를 동기(Synchronous) 호출.
|
||||||
|
3. **Response:** Ory 성공 시 클라이언트에게 즉시 성공 응답 반환 (SoT 확정).
|
||||||
|
4. **Sync:** Backend가 비동기(Goroutine)로 로컬 DB 테이블을 갱신.
|
||||||
|
|
||||||
|
### 3.2 Read Path (Query)
|
||||||
|
* **Self Context (내 정보, 내 권한):** Ory Session/Token을 통해 직접 검증하거나 Kratos/Keto를 실시간 조회 (Always Fresh).
|
||||||
|
* **Admin Context (목록 조회, 검색):** 로컬 DB를 조회하여 빠른 응답 제공 (Eventually Consistent).
|
||||||
|
|
||||||
|
### 3.3 Conflict Resolution
|
||||||
|
* 데이터 불일치가 발견될 경우, 항상 **Ory Stack의 데이터를 기준(Authority)**으로 로컬 DB를 보정(Self-healing)합니다.
|
||||||
@@ -79,6 +79,7 @@ TOML에서는 `[Section]`을 사용하여 계층을 표현합니다.
|
|||||||
* `ko` 계열이면 `/ko/dashboard`로 리다이렉트합니다.
|
* `ko` 계열이면 `/ko/dashboard`로 리다이렉트합니다.
|
||||||
* 그 외에는 `/en/dashboard`로 리다이렉트합니다.
|
* 그 외에는 `/en/dashboard`로 리다이렉트합니다.
|
||||||
* *단, 이전에 언어를 선택한 쿠키나 로컬스토리지 값이 있다면 그 값을 우선합니다.*
|
* *단, 이전에 언어를 선택한 쿠키나 로컬스토리지 값이 있다면 그 값을 우선합니다.*
|
||||||
|
* 로컬스토리지 키는 **`locale`**을 사용합니다.
|
||||||
2. **언어 변경 시**:
|
2. **언어 변경 시**:
|
||||||
* 모든 화면에는 **언어 선택기(Language Selector)**가 노출되어야 합니다.
|
* 모든 화면에는 **언어 선택기(Language Selector)**가 노출되어야 합니다.
|
||||||
* 변경 시 해당 언어의 URL로 이동(`window.location` 변경 혹은 Router Push)하며, 선택된 언어를 로컬스토리지에 저장합니다.
|
* 변경 시 해당 언어의 URL로 이동(`window.location` 변경 혹은 Router Push)하며, 선택된 언어를 로컬스토리지에 저장합니다.
|
||||||
|
|||||||
86
docs/trouble-shooting/userfront-locale.md
Normal file
86
docs/trouble-shooting/userfront-locale.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# userfront locale 전환 문제 분석 및 대응
|
||||||
|
|
||||||
|
## 증상
|
||||||
|
- URL은 `/ko` ↔ `/en`으로 변경되지만 실제 텍스트가 바뀌지 않음.
|
||||||
|
- 브라우저에서 `window.localStorage.getItem('locale')`가 계속 `null`.
|
||||||
|
- 빌드/배포 후에도 동일 증상 유지.
|
||||||
|
|
||||||
|
## 현재 구조 요약
|
||||||
|
- Flutter Web + `easy_localization` 사용.
|
||||||
|
- 경로 기반 로케일: `/:locale` 라우트 + `LocaleGate`로 로케일 적용.
|
||||||
|
- 번역 리소스: `userfront/assets/translations/*.toml`.
|
||||||
|
- 로케일 저장: `localStorage` key `locale`.
|
||||||
|
|
||||||
|
## 이미 적용한 변경
|
||||||
|
- `easy_localization` + TOML loader 적용.
|
||||||
|
- `LocaleGate`에서 로케일 적용 및 저장.
|
||||||
|
- `LanguageSelector`에서 로케일 저장 + URL 변경.
|
||||||
|
- `userfront/assets/translations`에 `en.toml`, `ko.toml`, `template.toml` 포함.
|
||||||
|
- `pubspec.yaml` assets 등록.
|
||||||
|
- GoRouter 호환성 수정(빌드 오류 대응).
|
||||||
|
|
||||||
|
## 원인 후보(우선순위)
|
||||||
|
1) **로케일 저장이 실제로 실행되지 않음**
|
||||||
|
- `LanguageSelector` 클릭 이벤트가 동작하지 않거나,
|
||||||
|
- `LocaleGate`가 호출되지 않는 라우트 진입(예: 라우트 변경 없이 SPA 상태만 변경).
|
||||||
|
|
||||||
|
2) **서비스 워커/캐시로 인해 구 번들이 계속 로드됨**
|
||||||
|
- Flutter Web 기본 `flutter_service_worker.js`가 캐시를 고정.
|
||||||
|
- 기존 번들이 남아 `LocaleStorage.write()` 반영 전 코드가 계속 실행될 가능성.
|
||||||
|
|
||||||
|
3) **번역 리소스 미로딩**
|
||||||
|
- `assets/translations/en.toml`, `ko.toml` 요청이 404 또는 미요청.
|
||||||
|
- Nginx 경로/정적 파일 설정에서 `assets/`가 누락된 경우.
|
||||||
|
|
||||||
|
4) **en.toml 키 누락으로 fallback 문자열이 표시됨**
|
||||||
|
- UI에서 `tr(..., fallback: '한국어')`를 쓰는 경우,
|
||||||
|
en.toml에 키가 없으면 한국어가 그대로 노출됨.
|
||||||
|
|
||||||
|
## 확인 방법(권장 순서)
|
||||||
|
1) **로케일 저장 확인**
|
||||||
|
- 언어 변경 후 콘솔:
|
||||||
|
- `window.localStorage.getItem('locale')`
|
||||||
|
- `null`이면 저장 로직 실행 실패.
|
||||||
|
|
||||||
|
2) **번역 리소스 요청 확인**
|
||||||
|
- DevTools Network에서:
|
||||||
|
- `/assets/translations/ko.toml`
|
||||||
|
- `/assets/translations/en.toml`
|
||||||
|
- 404 또는 미요청이면 assets 로딩 문제.
|
||||||
|
|
||||||
|
3) **로케일 반영 확인**
|
||||||
|
- UI 내 임시 텍스트로 `context.locale.languageCode` 출력
|
||||||
|
- 로케일이 바뀌는데 텍스트가 그대로면 TOML 키 누락 가능성.
|
||||||
|
|
||||||
|
4) **캐시 무효화**
|
||||||
|
- 하드 리로드(Shift+Reload)
|
||||||
|
- service worker 제거 후 재접속
|
||||||
|
|
||||||
|
## 현재까지 실행한 대응
|
||||||
|
- `LocaleGate`에서 로케일 동일 여부와 상관없이 `LocaleStorage.write()`가 실행되도록 수정.
|
||||||
|
- 라우터 로케일 파싱/전환 로직 보강.
|
||||||
|
- 번역 리소스 파일 배치 및 assets 등록.
|
||||||
|
|
||||||
|
## 앞으로의 대응 계획
|
||||||
|
1) **서비스 워커 캐시 무효화 전략**
|
||||||
|
- `flutter_service_worker.js` 버전 갱신 또는 Nginx 캐시 무력화 정책 적용.
|
||||||
|
- 배포 시 `index.html`/`flutter_service_worker.js` 캐시 제어 헤더 추가.
|
||||||
|
|
||||||
|
2) **번역 파일 누락 방지**
|
||||||
|
- `scripts/sync_userfront_locales.sh`를 CI에서 자동 실행.
|
||||||
|
- en/ko 키 누락 검증 테스트 추가.
|
||||||
|
|
||||||
|
3) **로케일 저장 검증**
|
||||||
|
- 언어 변경 후 `localStorage`에 값이 반드시 들어오는지 E2E 체크 추가.
|
||||||
|
|
||||||
|
4) **fallback 사용 최소화**
|
||||||
|
- userfront에서 `fallback` 문자열(한국어) 남용 구간 정리.
|
||||||
|
- en.toml에 키가 없을 때 한국어가 보이는 현상 방지.
|
||||||
|
|
||||||
|
## 참고 파일
|
||||||
|
- `userfront/lib/core/i18n/locale_gate.dart`
|
||||||
|
- `userfront/lib/core/widgets/language_selector.dart`
|
||||||
|
- `userfront/lib/core/i18n/locale_storage_web.dart`
|
||||||
|
- `userfront/lib/core/i18n/toml_asset_loader.dart`
|
||||||
|
- `userfront/assets/translations/en.toml`
|
||||||
|
- `userfront/assets/translations/ko.toml`
|
||||||
@@ -43,6 +43,7 @@ missing = "Missing"
|
|||||||
[msg]
|
[msg]
|
||||||
|
|
||||||
[msg.admin]
|
[msg.admin]
|
||||||
|
logout_confirm = "Are you sure you want to log out?"
|
||||||
idp_env_prod = "IDP env: prod"
|
idp_env_prod = "IDP env: prod"
|
||||||
scope_admin = "Scoped to /admin"
|
scope_admin = "Scoped to /admin"
|
||||||
session_ttl = "Session TTL: 15m admin"
|
session_ttl = "Session TTL: 15m admin"
|
||||||
@@ -354,7 +355,7 @@ title_with_code = "Title With Code"
|
|||||||
type = "Type"
|
type = "Type"
|
||||||
|
|
||||||
[msg.userfront.error.whitelist]
|
[msg.userfront.error.whitelist]
|
||||||
$normalizedCode = "$NormalizedCode"
|
settings_disabled = "Account settings are currently unavailable."
|
||||||
|
|
||||||
[msg.userfront.forgot]
|
[msg.userfront.forgot]
|
||||||
description = "Description"
|
description = "Description"
|
||||||
@@ -370,10 +371,10 @@ link_failed = "Link Failed"
|
|||||||
link_send_failed = "Link Send Failed"
|
link_send_failed = "Link Send Failed"
|
||||||
link_sent_email = "Link Sent Email"
|
link_sent_email = "Link Sent Email"
|
||||||
link_sent_phone = "Link Sent Phone"
|
link_sent_phone = "Link Sent Phone"
|
||||||
link_timeout = "Link Timeout"
|
link_timeout = "Time expired."
|
||||||
no_account = "No Account"
|
no_account = "New to Baron?"
|
||||||
oidc_failed = "OIDC Failed"
|
oidc_failed = "OIDC Failed"
|
||||||
qr_expired = "QR Expired"
|
qr_expired = "Time expired."
|
||||||
qr_init_failed = "QR Init Failed"
|
qr_init_failed = "QR Init Failed"
|
||||||
qr_login_required = "QR Login Required"
|
qr_login_required = "QR Login Required"
|
||||||
token_missing = "Token Missing"
|
token_missing = "Token Missing"
|
||||||
@@ -381,7 +382,7 @@ verification_failed = "Verification Failed"
|
|||||||
|
|
||||||
[msg.userfront.login.link]
|
[msg.userfront.login.link]
|
||||||
approved = "Approved"
|
approved = "Approved"
|
||||||
helper = "Helper"
|
helper = "Sending you a login link"
|
||||||
missing_login_id = "Missing Login Id"
|
missing_login_id = "Missing Login Id"
|
||||||
missing_phone = "Missing Phone"
|
missing_phone = "Missing Phone"
|
||||||
resend_wait = "Resend Wait"
|
resend_wait = "Resend Wait"
|
||||||
@@ -840,9 +841,6 @@ role = "ROLE"
|
|||||||
status = "STATUS"
|
status = "STATUS"
|
||||||
tenant_dept = "TENANT / DEPT"
|
tenant_dept = "TENANT / DEPT"
|
||||||
|
|
||||||
[ui.btn]
|
|
||||||
cancel = "Cancel"
|
|
||||||
save = "Save"
|
|
||||||
|
|
||||||
[ui.common]
|
[ui.common]
|
||||||
add = "Add"
|
add = "Add"
|
||||||
@@ -872,6 +870,9 @@ retry = "Retry"
|
|||||||
save = "Save"
|
save = "Save"
|
||||||
search = "Search"
|
search = "Search"
|
||||||
show_more = "Show More"
|
show_more = "Show More"
|
||||||
|
language = "Language"
|
||||||
|
language_ko = "한국어"
|
||||||
|
language_en = "English"
|
||||||
theme_dark = "Dark"
|
theme_dark = "Dark"
|
||||||
theme_light = "Light"
|
theme_light = "Light"
|
||||||
theme_toggle = "Theme Toggle"
|
theme_toggle = "Theme Toggle"
|
||||||
@@ -1090,11 +1091,9 @@ title = "Stack readiness"
|
|||||||
plane = "Dev Plane"
|
plane = "Dev Plane"
|
||||||
subtitle = "Manage your applications"
|
subtitle = "Manage your applications"
|
||||||
|
|
||||||
[ui.nav]
|
|
||||||
dashboard = "Dashboard"
|
|
||||||
|
|
||||||
[ui.userfront]
|
[ui.userfront]
|
||||||
app_title = "App Title"
|
app_title = "Baron SW Portal"
|
||||||
|
|
||||||
[ui.userfront.app_label]
|
[ui.userfront.app_label]
|
||||||
admin_console = "Admin Console"
|
admin_console = "Admin Console"
|
||||||
@@ -1164,7 +1163,7 @@ signup = "Signup"
|
|||||||
submit = "Submit"
|
submit = "Submit"
|
||||||
|
|
||||||
[ui.userfront.login.field]
|
[ui.userfront.login.field]
|
||||||
login_id = "Login Id"
|
login_id = "Emain or Phone Number"
|
||||||
password = "Password"
|
password = "Password"
|
||||||
|
|
||||||
[ui.userfront.login.link]
|
[ui.userfront.login.link]
|
||||||
@@ -1178,7 +1177,7 @@ title = "Title"
|
|||||||
[ui.userfront.login.qr]
|
[ui.userfront.login.qr]
|
||||||
expired = "Expired"
|
expired = "Expired"
|
||||||
refresh = "Refresh"
|
refresh = "Refresh"
|
||||||
remaining = "Remaining"
|
remaining = "Remaining: {{time}}"
|
||||||
|
|
||||||
[ui.userfront.login.short_code]
|
[ui.userfront.login.short_code]
|
||||||
digits = "Digits"
|
digits = "Digits"
|
||||||
@@ -1187,9 +1186,9 @@ prefix = "Prefix"
|
|||||||
submit = "Submit"
|
submit = "Submit"
|
||||||
|
|
||||||
[ui.userfront.login.tabs]
|
[ui.userfront.login.tabs]
|
||||||
link = "Link"
|
link = "Link/Code"
|
||||||
password = "Password"
|
password = "Password"
|
||||||
qr = "QR"
|
qr = "QR Code"
|
||||||
|
|
||||||
[ui.userfront.login.unregistered]
|
[ui.userfront.login.unregistered]
|
||||||
action = "Action"
|
action = "Action"
|
||||||
@@ -1305,3 +1304,15 @@ verify = "Verify"
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = "Action"
|
action = "Action"
|
||||||
|
|
||||||
|
[ui.admin.nav]
|
||||||
|
api_keys = "API Keys"
|
||||||
|
audit_logs = "Audit Logs"
|
||||||
|
auth_guard = "Auth Guard"
|
||||||
|
logout = "Logout"
|
||||||
|
overview = "Overview"
|
||||||
|
relying_parties = "Apps (RP)"
|
||||||
|
tenant_dashboard = "Tenant Dashboard"
|
||||||
|
tenant_groups = "Tenant Groups"
|
||||||
|
tenants = "Tenants"
|
||||||
|
users = "Users"
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ missing = "활성 세션이 없습니다."
|
|||||||
[msg]
|
[msg]
|
||||||
|
|
||||||
[msg.admin]
|
[msg.admin]
|
||||||
|
logout_confirm = "로그아웃 하시겠습니까?"
|
||||||
idp_env_prod = "IDP env: prod"
|
idp_env_prod = "IDP env: prod"
|
||||||
scope_admin = "Scoped to /admin"
|
scope_admin = "Scoped to /admin"
|
||||||
session_ttl = "Session TTL: 15m admin"
|
session_ttl = "Session TTL: 15m admin"
|
||||||
@@ -354,7 +355,7 @@ title_with_code = "오류: {{code}}"
|
|||||||
type = "오류 종류: {{type}}"
|
type = "오류 종류: {{type}}"
|
||||||
|
|
||||||
[msg.userfront.error.whitelist]
|
[msg.userfront.error.whitelist]
|
||||||
$normalizedCode = "에러가 계속되면 관리자에게 문의해주세요"
|
settings_disabled = "현재 계정 설정 화면은 준비 중입니다."
|
||||||
|
|
||||||
[msg.userfront.forgot]
|
[msg.userfront.forgot]
|
||||||
description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다."
|
description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다."
|
||||||
@@ -370,10 +371,10 @@ link_failed = "오류: {{error}}"
|
|||||||
link_send_failed = "전송 실패: {{error}}"
|
link_send_failed = "전송 실패: {{error}}"
|
||||||
link_sent_email = "입력하신 이메일로 로그인 링크를 보냈습니다."
|
link_sent_email = "입력하신 이메일로 로그인 링크를 보냈습니다."
|
||||||
link_sent_phone = "입력하신 번호로 로그인 링크를 보냈습니다."
|
link_sent_phone = "입력하신 번호로 로그인 링크를 보냈습니다."
|
||||||
link_timeout = "로그인 요청 시간이 초과되었습니다."
|
link_timeout = "시간이 경과되었습니다."
|
||||||
no_account = "계정이 없으신가요?"
|
no_account = "계정이 없으신가요?"
|
||||||
oidc_failed = "OIDC 로그인 처리에 실패했습니다. 다시 시도해 주세요."
|
oidc_failed = "OIDC 로그인 처리에 실패했습니다. 다시 시도해 주세요."
|
||||||
qr_expired = "QR 세션이 만료되었습니다."
|
qr_expired = "시간이 경과되었습니다."
|
||||||
qr_init_failed = "QR 초기화에 실패했습니다: {{error}}"
|
qr_init_failed = "QR 초기화에 실패했습니다: {{error}}"
|
||||||
qr_login_required = "로그인 한 상태여야 QR 스캔으로 로그인 할 수 있습니다"
|
qr_login_required = "로그인 한 상태여야 QR 스캔으로 로그인 할 수 있습니다"
|
||||||
token_missing = "로그인 토큰을 확인할 수 없습니다."
|
token_missing = "로그인 토큰을 확인할 수 없습니다."
|
||||||
@@ -840,9 +841,6 @@ role = "ROLE"
|
|||||||
status = "STATUS"
|
status = "STATUS"
|
||||||
tenant_dept = "TENANT / DEPT"
|
tenant_dept = "TENANT / DEPT"
|
||||||
|
|
||||||
[ui.btn]
|
|
||||||
cancel = "취소"
|
|
||||||
save = "저장"
|
|
||||||
|
|
||||||
[ui.common]
|
[ui.common]
|
||||||
add = "추가"
|
add = "추가"
|
||||||
@@ -872,6 +870,9 @@ retry = "다시 시도"
|
|||||||
save = "저장"
|
save = "저장"
|
||||||
search = "검색"
|
search = "검색"
|
||||||
show_more = "+ 더보기"
|
show_more = "+ 더보기"
|
||||||
|
language = "언어"
|
||||||
|
language_ko = "한국어"
|
||||||
|
language_en = "English"
|
||||||
theme_dark = "Dark"
|
theme_dark = "Dark"
|
||||||
theme_light = "Light"
|
theme_light = "Light"
|
||||||
theme_toggle = "테마 전환"
|
theme_toggle = "테마 전환"
|
||||||
@@ -1090,11 +1091,9 @@ title = "Stack readiness"
|
|||||||
plane = "Dev Plane"
|
plane = "Dev Plane"
|
||||||
subtitle = "Manage your applications"
|
subtitle = "Manage your applications"
|
||||||
|
|
||||||
[ui.nav]
|
|
||||||
dashboard = "대시보드"
|
|
||||||
|
|
||||||
[ui.userfront]
|
[ui.userfront]
|
||||||
app_title = "Baron 로그인"
|
app_title = "Baron SW 포탈"
|
||||||
|
|
||||||
[ui.userfront.app_label]
|
[ui.userfront.app_label]
|
||||||
admin_console = "Admin Console"
|
admin_console = "Admin Console"
|
||||||
@@ -1305,3 +1304,15 @@ verify = "본인인증"
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = "로그인하기"
|
action = "로그인하기"
|
||||||
|
|
||||||
|
[ui.admin.nav]
|
||||||
|
api_keys = "API 키"
|
||||||
|
audit_logs = "감사 로그"
|
||||||
|
auth_guard = "인증 가드"
|
||||||
|
logout = "로그아웃"
|
||||||
|
overview = "개요"
|
||||||
|
relying_parties = "애플리케이션(RP)"
|
||||||
|
tenant_dashboard = "테넌트 대시보드"
|
||||||
|
tenant_groups = "테넌트 그룹"
|
||||||
|
tenants = "테넌트"
|
||||||
|
users = "사용자"
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ missing = ""
|
|||||||
|
|
||||||
[msg.admin]
|
[msg.admin]
|
||||||
idp_env_prod = ""
|
idp_env_prod = ""
|
||||||
|
logout_confirm = ""
|
||||||
scope_admin = ""
|
scope_admin = ""
|
||||||
session_ttl = ""
|
session_ttl = ""
|
||||||
tenant_headers = ""
|
tenant_headers = ""
|
||||||
@@ -354,7 +355,7 @@ title_with_code = ""
|
|||||||
type = ""
|
type = ""
|
||||||
|
|
||||||
[msg.userfront.error.whitelist]
|
[msg.userfront.error.whitelist]
|
||||||
$normalizedCode = ""
|
settings_disabled = ""
|
||||||
|
|
||||||
[msg.userfront.forgot]
|
[msg.userfront.forgot]
|
||||||
description = ""
|
description = ""
|
||||||
@@ -658,6 +659,18 @@ name = ""
|
|||||||
[ui.admin.header]
|
[ui.admin.header]
|
||||||
plane = ""
|
plane = ""
|
||||||
|
|
||||||
|
[ui.admin.nav]
|
||||||
|
api_keys = ""
|
||||||
|
audit_logs = ""
|
||||||
|
auth_guard = ""
|
||||||
|
logout = ""
|
||||||
|
overview = ""
|
||||||
|
relying_parties = ""
|
||||||
|
tenant_dashboard = ""
|
||||||
|
tenant_groups = ""
|
||||||
|
tenants = ""
|
||||||
|
users = ""
|
||||||
|
|
||||||
[ui.admin.overview]
|
[ui.admin.overview]
|
||||||
kicker = ""
|
kicker = ""
|
||||||
title = ""
|
title = ""
|
||||||
@@ -840,9 +853,6 @@ role = ""
|
|||||||
status = ""
|
status = ""
|
||||||
tenant_dept = ""
|
tenant_dept = ""
|
||||||
|
|
||||||
[ui.btn]
|
|
||||||
cancel = ""
|
|
||||||
save = ""
|
|
||||||
|
|
||||||
[ui.common]
|
[ui.common]
|
||||||
add = ""
|
add = ""
|
||||||
@@ -872,6 +882,9 @@ retry = ""
|
|||||||
save = ""
|
save = ""
|
||||||
search = ""
|
search = ""
|
||||||
show_more = ""
|
show_more = ""
|
||||||
|
language = ""
|
||||||
|
language_ko = ""
|
||||||
|
language_en = ""
|
||||||
theme_dark = ""
|
theme_dark = ""
|
||||||
theme_light = ""
|
theme_light = ""
|
||||||
theme_toggle = ""
|
theme_toggle = ""
|
||||||
@@ -1090,8 +1103,6 @@ title = ""
|
|||||||
plane = ""
|
plane = ""
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
|
|
||||||
[ui.nav]
|
|
||||||
dashboard = ""
|
|
||||||
|
|
||||||
[ui.userfront]
|
[ui.userfront]
|
||||||
app_title = ""
|
app_title = ""
|
||||||
@@ -1178,7 +1189,7 @@ title = ""
|
|||||||
[ui.userfront.login.qr]
|
[ui.userfront.login.qr]
|
||||||
expired = ""
|
expired = ""
|
||||||
refresh = ""
|
refresh = ""
|
||||||
remaining = ""
|
remaining = "Remaining: {{time}}"
|
||||||
|
|
||||||
[ui.userfront.login.short_code]
|
[ui.userfront.login.short_code]
|
||||||
digits = ""
|
digits = ""
|
||||||
|
|||||||
47
scripts/sync_userfront_locales.sh
Executable file
47
scripts/sync_userfront_locales.sh
Executable file
@@ -0,0 +1,47 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# 루트 locales/*.toml -> userfront/assets/translations/ 동기화
|
||||||
|
# - userfront에서 사용하는 섹션만 추출
|
||||||
|
# - {{param}} -> {param} 변환 (easy_localization 포맷)
|
||||||
|
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
src_dir="$repo_root/locales"
|
||||||
|
dest_dir="$repo_root/userfront/assets/translations"
|
||||||
|
|
||||||
|
mkdir -p "$dest_dir"
|
||||||
|
|
||||||
|
filter_toml() {
|
||||||
|
src_file="$1"
|
||||||
|
dest_file="$2"
|
||||||
|
awk '
|
||||||
|
function allowed(section) {
|
||||||
|
return section ~ /^(ui\.userfront|msg\.userfront|err\.userfront|ui\.common)(\.|$)/;
|
||||||
|
}
|
||||||
|
BEGIN { keep = 0; }
|
||||||
|
{
|
||||||
|
line = $0;
|
||||||
|
if (match(line, /^[[:space:]]*\[[^]]+\][[:space:]]*$/)) {
|
||||||
|
section = line;
|
||||||
|
gsub(/^[[:space:]]*\[/, "", section);
|
||||||
|
gsub(/\][[:space:]]*$/, "", section);
|
||||||
|
keep = allowed(section);
|
||||||
|
if (keep) {
|
||||||
|
print line;
|
||||||
|
}
|
||||||
|
next;
|
||||||
|
}
|
||||||
|
if (keep) {
|
||||||
|
print line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' "$src_file" \
|
||||||
|
| sed -E 's/\{\{[[:space:]]*([a-zA-Z0-9_]+)[[:space:]]*\}\}/{\1}/g' \
|
||||||
|
> "$dest_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
for file in "$src_dir"/*.toml; do
|
||||||
|
base="$(basename "$file")"
|
||||||
|
filter_toml "$file" "$dest_dir/$base"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Synced locales to userfront assets: $dest_dir"
|
||||||
237
tools/i18n-scanner/report.js
Normal file
237
tools/i18n-scanner/report.js
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const ROOT_DIR = process.cwd();
|
||||||
|
const LOCALES_DIR = path.join(ROOT_DIR, 'locales');
|
||||||
|
const TEMPLATE_PATH = path.join(LOCALES_DIR, 'template.toml');
|
||||||
|
const LANG_FILES = ['ko.toml', 'en.toml'];
|
||||||
|
|
||||||
|
const SKIP_DIRS = new Set([
|
||||||
|
'.git',
|
||||||
|
'node_modules',
|
||||||
|
'dist',
|
||||||
|
'build',
|
||||||
|
'.dart_tool',
|
||||||
|
'.idea',
|
||||||
|
'.vscode',
|
||||||
|
'coverage',
|
||||||
|
'.next',
|
||||||
|
'.cache',
|
||||||
|
'tmp',
|
||||||
|
'logs',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const CODE_EXTENSIONS = new Set(['.ts', '.tsx', '.dart']);
|
||||||
|
|
||||||
|
const CODE_PATTERNS = [
|
||||||
|
/\b(?:i18n\.)?t\s*\(\s*['"]([^'"]+)['"]/g,
|
||||||
|
/\btr\s*\(\s*['"]([^'"]+)['"]/g,
|
||||||
|
/['"]([^'"]+)['"]\s*\.tr\s*\(/g,
|
||||||
|
];
|
||||||
|
|
||||||
|
function readFileRequired(filePath) {
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return { ok: false, error: `파일이 없습니다: ${filePath}` };
|
||||||
|
}
|
||||||
|
return { ok: true, value: fs.readFileSync(filePath, 'utf8') };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTomlKeys(filePath) {
|
||||||
|
const result = readFileRequired(filePath);
|
||||||
|
if (!result.ok) {
|
||||||
|
return { ok: false, error: result.error, keys: new Set() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = new Set();
|
||||||
|
const lines = result.value.split(/\r?\n/);
|
||||||
|
let currentSection = [];
|
||||||
|
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line || line.startsWith('#')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('[[') && line.endsWith(']]')) {
|
||||||
|
const sectionName = line.slice(2, -2).trim();
|
||||||
|
currentSection = sectionName
|
||||||
|
? sectionName.split('.').map((p) => p.trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('[') && line.endsWith(']')) {
|
||||||
|
const sectionName = line.slice(1, -1).trim();
|
||||||
|
currentSection = sectionName
|
||||||
|
? sectionName.split('.').map((p) => p.trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eqIndex = line.indexOf('=');
|
||||||
|
if (eqIndex === -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = line.slice(0, eqIndex).trim();
|
||||||
|
if (!key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullKey = [...currentSection, key].join('.');
|
||||||
|
keys.add(fullKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, keys };
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkDir(dirPath, files) {
|
||||||
|
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (SKIP_DIRS.has(entry.name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
walkDir(path.join(dirPath, entry.name), files);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry.isFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(entry.name).toLowerCase();
|
||||||
|
if (!CODE_EXTENSIONS.has(ext)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
files.push(path.join(dirPath, entry.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectCodeKeys() {
|
||||||
|
const files = [];
|
||||||
|
walkDir(ROOT_DIR, files);
|
||||||
|
|
||||||
|
const keys = new Set();
|
||||||
|
for (const filePath of files) {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
|
for (const pattern of CODE_PATTERNS) {
|
||||||
|
let match;
|
||||||
|
while ((match = pattern.exec(content)) !== null) {
|
||||||
|
if (match[1]) {
|
||||||
|
keys.add(match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
function difference(aSet, bSet) {
|
||||||
|
const result = [];
|
||||||
|
for (const item of aSet) {
|
||||||
|
if (!bSet.has(item)) {
|
||||||
|
result.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReport() {
|
||||||
|
const report = {
|
||||||
|
generated_at: new Date().toISOString(),
|
||||||
|
errors: [],
|
||||||
|
warnings: [],
|
||||||
|
details: {
|
||||||
|
missing_in_template: [],
|
||||||
|
missing_in_lang: {},
|
||||||
|
unused_in_template: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const templateResult = parseTomlKeys(TEMPLATE_PATH);
|
||||||
|
if (!templateResult.ok) {
|
||||||
|
report.errors.push(templateResult.error);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateKeys = templateResult.keys;
|
||||||
|
const codeKeys = collectCodeKeys();
|
||||||
|
|
||||||
|
const langKeyMap = new Map();
|
||||||
|
for (const fileName of LANG_FILES) {
|
||||||
|
const langPath = path.join(LOCALES_DIR, fileName);
|
||||||
|
const langResult = parseTomlKeys(langPath);
|
||||||
|
if (!langResult.ok) {
|
||||||
|
report.errors.push(langResult.error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
langKeyMap.set(fileName, langResult.keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [fileName, langKeys] of langKeyMap.entries()) {
|
||||||
|
const missingInLang = difference(templateKeys, langKeys);
|
||||||
|
if (missingInLang.length > 0) {
|
||||||
|
report.errors.push(
|
||||||
|
`[Sync Error] ${fileName} 누락 키 ${missingInLang.length}개`,
|
||||||
|
);
|
||||||
|
report.details.missing_in_lang[fileName] = missingInLang;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingInTemplate = difference(codeKeys, templateKeys);
|
||||||
|
if (missingInTemplate.length > 0) {
|
||||||
|
report.errors.push(
|
||||||
|
`[Missing Key] template.toml 누락 키 ${missingInTemplate.length}개`,
|
||||||
|
);
|
||||||
|
report.details.missing_in_template = missingInTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unusedInTemplate = difference(templateKeys, codeKeys);
|
||||||
|
if (unusedInTemplate.length > 0) {
|
||||||
|
report.warnings.push(
|
||||||
|
`[Unused Key] template.toml 미사용 키 ${unusedInTemplate.length}개`,
|
||||||
|
);
|
||||||
|
report.details.unused_in_template = unusedInTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const report = buildReport();
|
||||||
|
const outDir = path.join(ROOT_DIR, 'reports');
|
||||||
|
if (!fs.existsSync(outDir)) {
|
||||||
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
}
|
||||||
|
const outPath = path.join(outDir, 'i18n-report.json');
|
||||||
|
fs.writeFileSync(outPath, JSON.stringify(report, null, 2));
|
||||||
|
const summaryPath = path.join(outDir, 'i18n-report.txt');
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
lines.push(`generated_at: ${report.generated_at}`);
|
||||||
|
if (report.errors.length > 0) {
|
||||||
|
lines.push('errors:');
|
||||||
|
report.errors.forEach((err) => lines.push(`- ${err}`));
|
||||||
|
} else {
|
||||||
|
lines.push('errors: none');
|
||||||
|
}
|
||||||
|
if (report.warnings.length > 0) {
|
||||||
|
lines.push('warnings:');
|
||||||
|
report.warnings.forEach((warn) => lines.push(`- ${warn}`));
|
||||||
|
} else {
|
||||||
|
lines.push('warnings: none');
|
||||||
|
}
|
||||||
|
fs.writeFileSync(summaryPath, lines.join('\n'));
|
||||||
|
|
||||||
|
if (report.errors.length > 0) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
# Stage 1: Build Flutter
|
# Stage 1: Build Flutter
|
||||||
FROM ghcr.io/cirruslabs/flutter:stable AS build
|
FROM ghcr.io/cirruslabs/flutter:3.38.0 AS build
|
||||||
ENV RUN_FLUTTER_AS_ROOT=true
|
ENV RUN_FLUTTER_AS_ROOT=true
|
||||||
# ENV RUN_FLUTTER_AS_ROOT=true
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . .
|
COPY . .
|
||||||
# Get dependencies and build for web
|
# Get dependencies and build for web
|
||||||
|
RUN /bin/sh ./scripts/sync_userfront_locales.sh
|
||||||
|
WORKDIR /app/userfront
|
||||||
RUN flutter pub get
|
RUN flutter pub get
|
||||||
RUN touch .env
|
RUN touch .env
|
||||||
RUN flutter build web --release --no-tree-shake-icons --wasm
|
RUN flutter build web --release --no-tree-shake-icons --wasm
|
||||||
@@ -12,9 +13,9 @@ 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
|
||||||
# Copy built assets
|
# Copy built assets
|
||||||
COPY --from=build /app/build/web /usr/share/nginx/html
|
COPY --from=build /app/userfront/build/web /usr/share/nginx/html
|
||||||
# Copy custom Nginx config
|
# Copy custom Nginx config
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY userfront/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
536
userfront/assets/translations/en.toml
Normal file
536
userfront/assets/translations/en.toml
Normal file
File diff suppressed because one or more lines are too long
536
userfront/assets/translations/ko.toml
Normal file
536
userfront/assets/translations/ko.toml
Normal file
File diff suppressed because one or more lines are too long
536
userfront/assets/translations/template.toml
Normal file
536
userfront/assets/translations/template.toml
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
[err.userfront]
|
||||||
|
|
||||||
|
[err.userfront.auth_proxy]
|
||||||
|
consent_accept = ""
|
||||||
|
consent_fetch = ""
|
||||||
|
consent_reject = ""
|
||||||
|
linked_app_revoke = ""
|
||||||
|
login_failed = ""
|
||||||
|
oidc_accept = ""
|
||||||
|
password_reset_complete = ""
|
||||||
|
password_reset_init = ""
|
||||||
|
|
||||||
|
[err.userfront.profile]
|
||||||
|
load_failed = ""
|
||||||
|
password_change_failed = ""
|
||||||
|
send_code_failed = ""
|
||||||
|
update_failed = ""
|
||||||
|
verify_code_failed = ""
|
||||||
|
|
||||||
|
[err.userfront.session]
|
||||||
|
missing = ""
|
||||||
|
|
||||||
|
[msg.userfront]
|
||||||
|
greeting = ""
|
||||||
|
|
||||||
|
[msg.userfront.audit]
|
||||||
|
date = ""
|
||||||
|
device = ""
|
||||||
|
end = ""
|
||||||
|
ip = ""
|
||||||
|
load_more_error = ""
|
||||||
|
result = ""
|
||||||
|
session_id = ""
|
||||||
|
status = ""
|
||||||
|
|
||||||
|
[msg.userfront.dashboard]
|
||||||
|
approved_device = ""
|
||||||
|
approved_ip = ""
|
||||||
|
audit_empty = ""
|
||||||
|
audit_load_error = ""
|
||||||
|
auth_method = ""
|
||||||
|
client_id = ""
|
||||||
|
client_id_missing = ""
|
||||||
|
current_status = ""
|
||||||
|
last_auth = ""
|
||||||
|
link_missing = ""
|
||||||
|
link_open_error = ""
|
||||||
|
session_id_copied = ""
|
||||||
|
|
||||||
|
[msg.userfront.dashboard.activities]
|
||||||
|
empty = ""
|
||||||
|
empty_detail = ""
|
||||||
|
error = ""
|
||||||
|
|
||||||
|
[msg.userfront.dashboard.approved_session]
|
||||||
|
copy_click = ""
|
||||||
|
copy_tap = ""
|
||||||
|
none = ""
|
||||||
|
|
||||||
|
[msg.userfront.dashboard.revoke]
|
||||||
|
confirm = ""
|
||||||
|
error = ""
|
||||||
|
success = ""
|
||||||
|
|
||||||
|
[msg.userfront.dashboard.scopes]
|
||||||
|
empty = ""
|
||||||
|
|
||||||
|
[msg.userfront.dashboard.timeline]
|
||||||
|
load_error = ""
|
||||||
|
|
||||||
|
[msg.userfront.error]
|
||||||
|
detail_contact = ""
|
||||||
|
detail_generic = ""
|
||||||
|
detail_request = ""
|
||||||
|
id = ""
|
||||||
|
title = ""
|
||||||
|
title_generic = ""
|
||||||
|
title_with_code = ""
|
||||||
|
type = ""
|
||||||
|
|
||||||
|
[msg.userfront.error.whitelist]
|
||||||
|
settings_disabled = ""
|
||||||
|
|
||||||
|
[msg.userfront.forgot]
|
||||||
|
description = ""
|
||||||
|
dry_send = ""
|
||||||
|
error = ""
|
||||||
|
input_required = ""
|
||||||
|
sent = ""
|
||||||
|
|
||||||
|
[msg.userfront.login]
|
||||||
|
cookie_check_failed = ""
|
||||||
|
dry_send = ""
|
||||||
|
link_failed = ""
|
||||||
|
link_send_failed = ""
|
||||||
|
link_sent_email = ""
|
||||||
|
link_sent_phone = ""
|
||||||
|
link_timeout = ""
|
||||||
|
no_account = ""
|
||||||
|
oidc_failed = ""
|
||||||
|
qr_expired = ""
|
||||||
|
qr_init_failed = ""
|
||||||
|
qr_login_required = ""
|
||||||
|
token_missing = ""
|
||||||
|
verification_failed = ""
|
||||||
|
|
||||||
|
[msg.userfront.login.link]
|
||||||
|
approved = ""
|
||||||
|
helper = ""
|
||||||
|
missing_login_id = ""
|
||||||
|
missing_phone = ""
|
||||||
|
resend_wait = ""
|
||||||
|
short_code_help = ""
|
||||||
|
|
||||||
|
[msg.userfront.login.password]
|
||||||
|
failed = ""
|
||||||
|
missing_credentials = ""
|
||||||
|
|
||||||
|
[msg.userfront.login.qr]
|
||||||
|
load_failed = ""
|
||||||
|
scan_hint = ""
|
||||||
|
|
||||||
|
[msg.userfront.login.short_code]
|
||||||
|
invalid = ""
|
||||||
|
|
||||||
|
[msg.userfront.login.unregistered]
|
||||||
|
body = ""
|
||||||
|
|
||||||
|
[msg.userfront.login.verification]
|
||||||
|
approved = ""
|
||||||
|
approved_local = ""
|
||||||
|
success = ""
|
||||||
|
|
||||||
|
[msg.userfront.login_success]
|
||||||
|
subtitle = ""
|
||||||
|
|
||||||
|
[msg.userfront.profile]
|
||||||
|
department_missing = ""
|
||||||
|
department_required = ""
|
||||||
|
email_missing = ""
|
||||||
|
greeting = ""
|
||||||
|
load_failed = ""
|
||||||
|
name_missing = ""
|
||||||
|
name_required = ""
|
||||||
|
phone_required = ""
|
||||||
|
phone_verify_required = ""
|
||||||
|
update_failed = ""
|
||||||
|
update_success = ""
|
||||||
|
|
||||||
|
[msg.userfront.profile.password]
|
||||||
|
change_failed = ""
|
||||||
|
changed = ""
|
||||||
|
current_required = ""
|
||||||
|
mismatch = ""
|
||||||
|
new_required = ""
|
||||||
|
subtitle = ""
|
||||||
|
|
||||||
|
[msg.userfront.profile.phone]
|
||||||
|
code_sent = ""
|
||||||
|
send_failed = ""
|
||||||
|
verified = ""
|
||||||
|
verify_failed = ""
|
||||||
|
verify_notice = ""
|
||||||
|
|
||||||
|
[msg.userfront.profile.section]
|
||||||
|
basic = ""
|
||||||
|
organization = ""
|
||||||
|
security = ""
|
||||||
|
|
||||||
|
[msg.userfront.qr]
|
||||||
|
approve_error = ""
|
||||||
|
approve_success = ""
|
||||||
|
camera_error = ""
|
||||||
|
permission_error = ""
|
||||||
|
permission_required = ""
|
||||||
|
|
||||||
|
[msg.userfront.reset]
|
||||||
|
invalid_body = ""
|
||||||
|
invalid_link = ""
|
||||||
|
invalid_title = ""
|
||||||
|
policy_loading = ""
|
||||||
|
success = ""
|
||||||
|
|
||||||
|
[msg.userfront.reset.error]
|
||||||
|
empty_password = ""
|
||||||
|
generic = ""
|
||||||
|
lowercase = ""
|
||||||
|
min_length = ""
|
||||||
|
min_types = ""
|
||||||
|
mismatch = ""
|
||||||
|
number = ""
|
||||||
|
symbol = ""
|
||||||
|
uppercase = ""
|
||||||
|
|
||||||
|
[msg.userfront.reset.policy]
|
||||||
|
lowercase = ""
|
||||||
|
min_length = ""
|
||||||
|
min_types = ""
|
||||||
|
number = ""
|
||||||
|
symbol = ""
|
||||||
|
uppercase = ""
|
||||||
|
|
||||||
|
[msg.userfront.sections]
|
||||||
|
apps_subtitle = ""
|
||||||
|
audit_subtitle = ""
|
||||||
|
|
||||||
|
[msg.userfront.settings]
|
||||||
|
disabled = ""
|
||||||
|
|
||||||
|
[msg.userfront.signup]
|
||||||
|
failed = ""
|
||||||
|
privacy_full = ""
|
||||||
|
tos_full = ""
|
||||||
|
|
||||||
|
[msg.userfront.signup.agreement]
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
[msg.userfront.signup.auth]
|
||||||
|
affiliate_notice = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
[msg.userfront.signup.email]
|
||||||
|
code_mismatch = ""
|
||||||
|
duplicate = ""
|
||||||
|
invalid = ""
|
||||||
|
send_failed = ""
|
||||||
|
verified = ""
|
||||||
|
verify_failed = ""
|
||||||
|
|
||||||
|
[msg.userfront.signup.password]
|
||||||
|
length_required = ""
|
||||||
|
lowercase_required = ""
|
||||||
|
mismatch = ""
|
||||||
|
number_required = ""
|
||||||
|
symbol_required = ""
|
||||||
|
title = ""
|
||||||
|
uppercase_required = ""
|
||||||
|
|
||||||
|
[msg.userfront.signup.password.rule]
|
||||||
|
lowercase = ""
|
||||||
|
min_length = ""
|
||||||
|
min_types = ""
|
||||||
|
number = ""
|
||||||
|
symbol = ""
|
||||||
|
uppercase = ""
|
||||||
|
|
||||||
|
[msg.userfront.signup.phone]
|
||||||
|
code_mismatch = ""
|
||||||
|
send_failed = ""
|
||||||
|
verified = ""
|
||||||
|
verify_failed = ""
|
||||||
|
|
||||||
|
[msg.userfront.signup.policy]
|
||||||
|
loading = ""
|
||||||
|
lowercase = ""
|
||||||
|
min_length = ""
|
||||||
|
min_types = ""
|
||||||
|
number = ""
|
||||||
|
summary = ""
|
||||||
|
symbol = ""
|
||||||
|
uppercase = ""
|
||||||
|
|
||||||
|
[msg.userfront.signup.profile]
|
||||||
|
affiliate_hint = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
[msg.userfront.signup.success]
|
||||||
|
body = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
[ui.common]
|
||||||
|
add = ""
|
||||||
|
back = ""
|
||||||
|
cancel = ""
|
||||||
|
close = ""
|
||||||
|
collapse = ""
|
||||||
|
confirm = ""
|
||||||
|
copy = ""
|
||||||
|
create = ""
|
||||||
|
delete = ""
|
||||||
|
details = ""
|
||||||
|
edit = ""
|
||||||
|
hyphen = ""
|
||||||
|
na = ""
|
||||||
|
never = ""
|
||||||
|
next = ""
|
||||||
|
page_of = ""
|
||||||
|
prev = ""
|
||||||
|
previous = ""
|
||||||
|
qr = ""
|
||||||
|
read_only = ""
|
||||||
|
refresh = ""
|
||||||
|
requesting = ""
|
||||||
|
resend = ""
|
||||||
|
retry = ""
|
||||||
|
save = ""
|
||||||
|
search = ""
|
||||||
|
show_more = ""
|
||||||
|
language = ""
|
||||||
|
language_ko = ""
|
||||||
|
language_en = ""
|
||||||
|
theme_dark = ""
|
||||||
|
theme_light = ""
|
||||||
|
theme_toggle = ""
|
||||||
|
unknown = ""
|
||||||
|
|
||||||
|
[ui.common.badge]
|
||||||
|
admin_only = ""
|
||||||
|
command_only = ""
|
||||||
|
system = ""
|
||||||
|
|
||||||
|
[ui.common.role]
|
||||||
|
admin = ""
|
||||||
|
user = ""
|
||||||
|
|
||||||
|
[ui.common.status]
|
||||||
|
active = ""
|
||||||
|
blocked = ""
|
||||||
|
failure = ""
|
||||||
|
inactive = ""
|
||||||
|
ok = ""
|
||||||
|
pending = ""
|
||||||
|
success = ""
|
||||||
|
|
||||||
|
[ui.userfront]
|
||||||
|
app_title = ""
|
||||||
|
|
||||||
|
[ui.userfront.app_label]
|
||||||
|
admin_console = ""
|
||||||
|
baron = ""
|
||||||
|
dev_console = ""
|
||||||
|
|
||||||
|
[ui.userfront.audit]
|
||||||
|
|
||||||
|
[ui.userfront.audit.table]
|
||||||
|
app = ""
|
||||||
|
auth_method = ""
|
||||||
|
date = ""
|
||||||
|
device = ""
|
||||||
|
ip = ""
|
||||||
|
pending = ""
|
||||||
|
result = ""
|
||||||
|
session_id = ""
|
||||||
|
status = ""
|
||||||
|
|
||||||
|
[ui.userfront.auth_method]
|
||||||
|
ory = ""
|
||||||
|
session = ""
|
||||||
|
|
||||||
|
[ui.userfront.dashboard]
|
||||||
|
last_auth_label = ""
|
||||||
|
status_history = ""
|
||||||
|
|
||||||
|
[ui.userfront.dashboard.activity]
|
||||||
|
linked = ""
|
||||||
|
|
||||||
|
[ui.userfront.dashboard.approved_session]
|
||||||
|
default = ""
|
||||||
|
userfront = ""
|
||||||
|
|
||||||
|
[ui.userfront.dashboard.revoke]
|
||||||
|
confirm_button = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
[ui.userfront.dashboard.scopes]
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
[ui.userfront.dashboard.status]
|
||||||
|
revoked = ""
|
||||||
|
|
||||||
|
[ui.userfront.device]
|
||||||
|
android = ""
|
||||||
|
ios = ""
|
||||||
|
linux = ""
|
||||||
|
macos = ""
|
||||||
|
windows = ""
|
||||||
|
|
||||||
|
[ui.userfront.error]
|
||||||
|
go_home = ""
|
||||||
|
go_login = ""
|
||||||
|
|
||||||
|
[ui.userfront.forgot]
|
||||||
|
heading = ""
|
||||||
|
input_label = ""
|
||||||
|
submit = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
[ui.userfront.login]
|
||||||
|
forgot_password = ""
|
||||||
|
signup = ""
|
||||||
|
|
||||||
|
[ui.userfront.login.action]
|
||||||
|
submit = ""
|
||||||
|
|
||||||
|
[ui.userfront.login.field]
|
||||||
|
login_id = ""
|
||||||
|
password = ""
|
||||||
|
|
||||||
|
[ui.userfront.login.link]
|
||||||
|
action_label = ""
|
||||||
|
code_only = ""
|
||||||
|
page_title = ""
|
||||||
|
resend_with_time = ""
|
||||||
|
send = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
[ui.userfront.login.qr]
|
||||||
|
expired = ""
|
||||||
|
refresh = ""
|
||||||
|
remaining = "Remaining: {time}"
|
||||||
|
|
||||||
|
[ui.userfront.login.short_code]
|
||||||
|
digits = ""
|
||||||
|
expire_time = ""
|
||||||
|
prefix = ""
|
||||||
|
submit = ""
|
||||||
|
|
||||||
|
[ui.userfront.login.tabs]
|
||||||
|
link = ""
|
||||||
|
password = ""
|
||||||
|
qr = ""
|
||||||
|
|
||||||
|
[ui.userfront.login.unregistered]
|
||||||
|
action = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
[ui.userfront.login.verification]
|
||||||
|
action_label = ""
|
||||||
|
page_title = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
[ui.userfront.login_success]
|
||||||
|
later = ""
|
||||||
|
qr = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
[ui.userfront.nav]
|
||||||
|
dashboard = ""
|
||||||
|
logout = ""
|
||||||
|
profile = ""
|
||||||
|
qr_scan = ""
|
||||||
|
|
||||||
|
[ui.userfront.profile]
|
||||||
|
department_empty = ""
|
||||||
|
manage = ""
|
||||||
|
user_fallback = ""
|
||||||
|
|
||||||
|
[ui.userfront.profile.field]
|
||||||
|
affiliation = ""
|
||||||
|
company_code = ""
|
||||||
|
department = ""
|
||||||
|
email = ""
|
||||||
|
name = ""
|
||||||
|
tenant = ""
|
||||||
|
|
||||||
|
[ui.userfront.profile.password]
|
||||||
|
change = ""
|
||||||
|
confirm = ""
|
||||||
|
current = ""
|
||||||
|
forgot = ""
|
||||||
|
new = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
[ui.userfront.profile.phone]
|
||||||
|
code_hint = ""
|
||||||
|
request_code = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
[ui.userfront.profile.section]
|
||||||
|
basic = ""
|
||||||
|
organization = ""
|
||||||
|
security = ""
|
||||||
|
|
||||||
|
[ui.userfront.qr]
|
||||||
|
request_permission = ""
|
||||||
|
rescan = ""
|
||||||
|
result_failure = ""
|
||||||
|
result_success = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
[ui.userfront.reset]
|
||||||
|
confirm_password = ""
|
||||||
|
new_password = ""
|
||||||
|
submit = ""
|
||||||
|
subtitle = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
[ui.userfront.sections]
|
||||||
|
apps = ""
|
||||||
|
audit = ""
|
||||||
|
|
||||||
|
[ui.userfront.session]
|
||||||
|
active = ""
|
||||||
|
unknown = ""
|
||||||
|
|
||||||
|
[ui.userfront.signup]
|
||||||
|
complete = ""
|
||||||
|
next_step = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
[ui.userfront.signup.agreement]
|
||||||
|
all = ""
|
||||||
|
privacy_title = ""
|
||||||
|
tos_title = ""
|
||||||
|
|
||||||
|
[ui.userfront.signup.auth]
|
||||||
|
code_label = ""
|
||||||
|
request_code = ""
|
||||||
|
|
||||||
|
[ui.userfront.signup.auth.email]
|
||||||
|
label = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
[ui.userfront.signup.password]
|
||||||
|
confirm_label = ""
|
||||||
|
label = ""
|
||||||
|
|
||||||
|
[ui.userfront.signup.phone]
|
||||||
|
label = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
[ui.userfront.signup.profile]
|
||||||
|
affiliation_type = ""
|
||||||
|
company = ""
|
||||||
|
department = ""
|
||||||
|
department_optional = ""
|
||||||
|
name = ""
|
||||||
|
|
||||||
|
[ui.userfront.signup.steps]
|
||||||
|
agreement = ""
|
||||||
|
password = ""
|
||||||
|
profile = ""
|
||||||
|
verify = ""
|
||||||
|
|
||||||
|
[ui.userfront.signup.success]
|
||||||
|
action = ""
|
||||||
51
userfront/lib/core/i18n/locale_gate.dart
Normal file
51
userfront/lib/core/i18n/locale_gate.dart
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart' hide tr;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:userfront/i18n.dart';
|
||||||
|
import '../services/web_window.dart';
|
||||||
|
import 'locale_storage.dart';
|
||||||
|
import 'locale_utils.dart';
|
||||||
|
|
||||||
|
class LocaleGate extends StatefulWidget {
|
||||||
|
const LocaleGate({super.key, required this.localeCode, required this.child});
|
||||||
|
|
||||||
|
final String localeCode;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LocaleGate> createState() => _LocaleGateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LocaleGateState extends State<LocaleGate> {
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
_applyLocale();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(LocaleGate oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.localeCode != widget.localeCode) {
|
||||||
|
_applyLocale();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _applyLocale() async {
|
||||||
|
final normalized = normalizeLocaleCode(widget.localeCode);
|
||||||
|
LocaleStorage.write(normalized);
|
||||||
|
webWindow.setTitle(
|
||||||
|
tr('ui.userfront.app_title'),
|
||||||
|
);
|
||||||
|
if (context.locale.languageCode == normalized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await context.setLocale(Locale(normalized));
|
||||||
|
webWindow.setTitle(
|
||||||
|
tr('ui.userfront.app_title'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => widget.child;
|
||||||
|
}
|
||||||
7
userfront/lib/core/i18n/locale_storage.dart
Normal file
7
userfront/lib/core/i18n/locale_storage.dart
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import 'locale_storage_stub.dart'
|
||||||
|
if (dart.library.html) 'locale_storage_web.dart';
|
||||||
|
|
||||||
|
abstract class LocaleStorage {
|
||||||
|
static String? read() => localeStorage.read();
|
||||||
|
static void write(String locale) => localeStorage.write(locale);
|
||||||
|
}
|
||||||
11
userfront/lib/core/i18n/locale_storage_stub.dart
Normal file
11
userfront/lib/core/i18n/locale_storage_stub.dart
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
class LocaleStorageImpl {
|
||||||
|
String? _locale;
|
||||||
|
|
||||||
|
String? read() => _locale;
|
||||||
|
|
||||||
|
void write(String locale) {
|
||||||
|
_locale = locale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final localeStorage = LocaleStorageImpl();
|
||||||
120
userfront/lib/core/i18n/locale_storage_web.dart
Normal file
120
userfront/lib/core/i18n/locale_storage_web.dart
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
// ignore_for_file: avoid_web_libraries_in_flutter
|
||||||
|
|
||||||
|
import 'dart:html' as html;
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
class LocaleStorageImpl {
|
||||||
|
static const _key = 'locale';
|
||||||
|
static const _legacyKey = 'baron_locale';
|
||||||
|
static final Map<String, String> _memory = {};
|
||||||
|
static bool _forceMemory = false;
|
||||||
|
static bool _forceSession = false;
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
static void forceMemoryStorageForTests(bool value) {
|
||||||
|
_forceMemory = value;
|
||||||
|
if (!value) {
|
||||||
|
_memory.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
static void forceSessionStorageForTests(bool value) {
|
||||||
|
_forceSession = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _read(String key) {
|
||||||
|
if (!_forceMemory && !_forceSession) {
|
||||||
|
try {
|
||||||
|
return html.window.localStorage[key];
|
||||||
|
} catch (_) {
|
||||||
|
// localStorage 접근이 차단된 경우 sessionStorage로 fallback.
|
||||||
|
try {
|
||||||
|
return html.window.sessionStorage[key];
|
||||||
|
} catch (_) {
|
||||||
|
// sessionStorage도 차단된 경우 메모리 fallback 사용.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!_forceMemory) {
|
||||||
|
try {
|
||||||
|
return html.window.sessionStorage[key];
|
||||||
|
} catch (_) {
|
||||||
|
// sessionStorage도 차단된 경우 메모리 fallback 사용.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _memory[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
void _write(String key, String value) {
|
||||||
|
if (!_forceMemory && !_forceSession) {
|
||||||
|
try {
|
||||||
|
html.window.localStorage[key] = value;
|
||||||
|
return;
|
||||||
|
} catch (_) {
|
||||||
|
// localStorage 접근이 차단된 경우 sessionStorage로 fallback.
|
||||||
|
try {
|
||||||
|
html.window.sessionStorage[key] = value;
|
||||||
|
return;
|
||||||
|
} catch (_) {
|
||||||
|
// sessionStorage도 차단된 경우 메모리 fallback 사용.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!_forceMemory) {
|
||||||
|
try {
|
||||||
|
html.window.sessionStorage[key] = value;
|
||||||
|
return;
|
||||||
|
} catch (_) {
|
||||||
|
// sessionStorage도 차단된 경우 메모리 fallback 사용.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_memory[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _remove(String key) {
|
||||||
|
if (!_forceMemory && !_forceSession) {
|
||||||
|
try {
|
||||||
|
html.window.localStorage.remove(key);
|
||||||
|
return;
|
||||||
|
} catch (_) {
|
||||||
|
// localStorage 접근이 차단된 경우 sessionStorage로 fallback.
|
||||||
|
try {
|
||||||
|
html.window.sessionStorage.remove(key);
|
||||||
|
return;
|
||||||
|
} catch (_) {
|
||||||
|
// sessionStorage도 차단된 경우 메모리 fallback 사용.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!_forceMemory) {
|
||||||
|
try {
|
||||||
|
html.window.sessionStorage.remove(key);
|
||||||
|
return;
|
||||||
|
} catch (_) {
|
||||||
|
// sessionStorage도 차단된 경우 메모리 fallback 사용.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_memory.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? read() {
|
||||||
|
final current = _read(_key);
|
||||||
|
if (current != null && current.isNotEmpty) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
final legacy = _read(_legacyKey);
|
||||||
|
if (legacy != null && legacy.isNotEmpty) {
|
||||||
|
_write(_key, legacy);
|
||||||
|
_remove(_legacyKey);
|
||||||
|
return legacy;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void write(String locale) {
|
||||||
|
_write(_key, locale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final localeStorage = LocaleStorageImpl();
|
||||||
69
userfront/lib/core/i18n/locale_utils.dart
Normal file
69
userfront/lib/core/i18n/locale_utils.dart
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'locale_storage.dart';
|
||||||
|
|
||||||
|
const supportedLocaleCodes = ['en', 'ko'];
|
||||||
|
const defaultLocaleCode = 'en';
|
||||||
|
|
||||||
|
String normalizeLocaleCode(String? code) {
|
||||||
|
if (code == null || code.isEmpty) {
|
||||||
|
return defaultLocaleCode;
|
||||||
|
}
|
||||||
|
final normalized = code.toLowerCase();
|
||||||
|
if (normalized == 'ko' || normalized.startsWith('ko-')) {
|
||||||
|
return 'ko';
|
||||||
|
}
|
||||||
|
if (normalized == 'en' || normalized.startsWith('en-')) {
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
return defaultLocaleCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
String resolvePreferredLocaleCode() {
|
||||||
|
final stored = LocaleStorage.read();
|
||||||
|
if (stored != null && supportedLocaleCodes.contains(stored)) {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
final deviceLocale = PlatformDispatcher.instance.locale;
|
||||||
|
return normalizeLocaleCode(deviceLocale.languageCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? extractLocaleFromPath(Uri uri) {
|
||||||
|
if (uri.pathSegments.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final code = uri.pathSegments.first.toLowerCase();
|
||||||
|
if (supportedLocaleCodes.contains(code)) {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String stripLocalePath(Uri uri) {
|
||||||
|
final segments = uri.pathSegments;
|
||||||
|
if (segments.isNotEmpty && supportedLocaleCodes.contains(segments.first)) {
|
||||||
|
final rest = segments.skip(1).join('/');
|
||||||
|
if (rest.isEmpty) {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
return '/$rest';
|
||||||
|
}
|
||||||
|
return uri.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
String buildLocalizedPath(String localeCode, Uri uri) {
|
||||||
|
final segments = uri.pathSegments;
|
||||||
|
Iterable<String> restSegments = segments;
|
||||||
|
if (segments.isNotEmpty) {
|
||||||
|
final head = segments.first.toLowerCase();
|
||||||
|
if (supportedLocaleCodes.contains(head) || head.length == 2) {
|
||||||
|
restSegments = segments.skip(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final newSegments = [localeCode, ...restSegments];
|
||||||
|
final path = '/${newSegments.join('/')}';
|
||||||
|
if (uri.queryParameters.isEmpty) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
return Uri(path: path, queryParameters: uri.queryParameters).toString();
|
||||||
|
}
|
||||||
22
userfront/lib/core/i18n/toml_asset_loader.dart
Normal file
22
userfront/lib/core/i18n/toml_asset_loader.dart
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:toml/toml.dart';
|
||||||
|
|
||||||
|
class TomlAssetLoader extends AssetLoader {
|
||||||
|
const TomlAssetLoader();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, dynamic>> load(String path, Locale locale) async {
|
||||||
|
final assetPath = '$path/${locale.languageCode}.toml';
|
||||||
|
try {
|
||||||
|
final content = await rootBundle.loadString(assetPath);
|
||||||
|
final document = TomlDocument.parse(content);
|
||||||
|
return document.toMap();
|
||||||
|
} catch (e) {
|
||||||
|
// 로딩 실패 시 빈 맵을 반환해 렌더링을 지속합니다.
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,18 +11,34 @@ class AuthProxyService {
|
|||||||
if (!dotenv.isInitialized) {
|
if (!dotenv.isInitialized) {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
return dotenv.env[key] ?? fallback;
|
final value = dotenv.env[key];
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _fallbackOrigin() {
|
||||||
|
try {
|
||||||
|
final origin = Uri.base.origin;
|
||||||
|
if (origin.isNotEmpty && origin != 'null') {
|
||||||
|
return origin;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return 'http://sso.hmac.kr';
|
||||||
}
|
}
|
||||||
|
|
||||||
static String get _baseUrl {
|
static String get _baseUrl {
|
||||||
final rawUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
|
final rawUrl = _envOrDefault('BACKEND_URL', _fallbackOrigin());
|
||||||
// 배포 환경에서 $ 기호나 공백이 섞여 들어오는 경우를 방지하기 위해 정제합니다.
|
// 배포 환경에서 $ 기호나 공백이 섞여 들어오는 경우를 방지하기 위해 정제합니다.
|
||||||
return rawUrl.replaceAll(r'$', '').trim().replaceAll(RegExp(r'/$'), '');
|
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';
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool get isProdEnv => _isProd;
|
static bool get isProdEnv => _isProd;
|
||||||
static bool _shouldSendDrySend(bool? drySend) {
|
static bool _shouldSendDrySend(bool? drySend) {
|
||||||
if (_isProd) {
|
if (_isProd) {
|
||||||
@@ -76,13 +92,14 @@ class AuthProxyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<int> getSessionStatus({String? token, bool useCookie = false}) async {
|
static Future<int> getSessionStatus({
|
||||||
|
String? token,
|
||||||
|
bool useCookie = false,
|
||||||
|
}) async {
|
||||||
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);
|
||||||
try {
|
try {
|
||||||
final headers = <String, String>{
|
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||||
'Content-Type': 'application/json',
|
|
||||||
};
|
|
||||||
if (!useCookie && token != null && token.isNotEmpty) {
|
if (!useCookie && token != null && token.isNotEmpty) {
|
||||||
headers['Authorization'] = 'Bearer $token';
|
headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
@@ -100,12 +117,9 @@ class AuthProxyService {
|
|||||||
bool? drySend,
|
bool? drySend,
|
||||||
}) async {
|
}) async {
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/init');
|
final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/init');
|
||||||
final userfrontUrl = _envOrDefault('USERFRONT_URL', 'http://sso.hmac.kr');
|
final userfrontUrl = _envOrDefault('USERFRONT_URL', _fallbackOrigin());
|
||||||
|
|
||||||
final body = <String, dynamic>{
|
final body = <String, dynamic>{'loginId': loginId, 'uri': userfrontUrl};
|
||||||
'loginId': loginId,
|
|
||||||
'uri': userfrontUrl,
|
|
||||||
};
|
|
||||||
if (_shouldSendDrySend(drySend)) {
|
if (_shouldSendDrySend(drySend)) {
|
||||||
body['drySend'] = true;
|
body['drySend'] = true;
|
||||||
}
|
}
|
||||||
@@ -133,15 +147,15 @@ class AuthProxyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> pollEnchantedLink(String pendingRef) async {
|
static Future<Map<String, dynamic>> pollEnchantedLink(
|
||||||
|
String pendingRef,
|
||||||
|
) async {
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/poll');
|
final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/poll');
|
||||||
|
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
url,
|
url,
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: jsonEncode({
|
body: jsonEncode({'pendingRef': pendingRef}),
|
||||||
'pendingRef': pendingRef,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
@@ -157,16 +171,16 @@ class AuthProxyService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> verifyMagicLink(String token, {bool verifyOnly = false}) async {
|
static Future<Map<String, dynamic>> verifyMagicLink(
|
||||||
|
String token, {
|
||||||
|
bool verifyOnly = false,
|
||||||
|
}) async {
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/auth/magic-link/verify');
|
final url = Uri.parse('$_baseUrl/api/v1/auth/magic-link/verify');
|
||||||
|
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
url,
|
url,
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: jsonEncode({
|
body: jsonEncode({'token': token, 'verifyOnly': verifyOnly}),
|
||||||
'token': token,
|
|
||||||
'verifyOnly': verifyOnly,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
@@ -223,10 +237,7 @@ class AuthProxyService {
|
|||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
url,
|
url,
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: jsonEncode({
|
body: jsonEncode({'shortCode': shortCode, 'verifyOnly': verifyOnly}),
|
||||||
'shortCode': shortCode,
|
|
||||||
'verifyOnly': verifyOnly,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
@@ -240,13 +251,18 @@ class AuthProxyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> loginWithPassword(String loginId, String password, {String? loginChallenge}) async {
|
static Future<Map<String, dynamic>> loginWithPassword(
|
||||||
|
String loginId,
|
||||||
|
String password, {
|
||||||
|
String? loginChallenge,
|
||||||
|
}) async {
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/auth/password/login');
|
final url = Uri.parse('$_baseUrl/api/v1/auth/password/login');
|
||||||
|
|
||||||
final payload = {
|
final payload = {
|
||||||
'loginId': loginId,
|
'loginId': loginId,
|
||||||
'password': password,
|
'password': password,
|
||||||
if (loginChallenge != null && loginChallenge.isNotEmpty) 'login_challenge': loginChallenge,
|
if (loginChallenge != null && loginChallenge.isNotEmpty)
|
||||||
|
'login_challenge': loginChallenge,
|
||||||
};
|
};
|
||||||
|
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
@@ -267,13 +283,17 @@ class AuthProxyService {
|
|||||||
errorBody['error'] ??
|
errorBody['error'] ??
|
||||||
tr(
|
tr(
|
||||||
'err.userfront.auth_proxy.login_failed',
|
'err.userfront.auth_proxy.login_failed',
|
||||||
fallback: '로그인에 실패했습니다.',
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
static Future<Map<String, dynamic>> getConsentInfo(String consentChallenge) async {
|
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/auth/consent').replace(queryParameters: {'consent_challenge': consentChallenge});
|
static Future<Map<String, dynamic>> getConsentInfo(
|
||||||
|
String consentChallenge,
|
||||||
|
) async {
|
||||||
|
final url = Uri.parse(
|
||||||
|
'$_baseUrl/api/v1/auth/consent',
|
||||||
|
).replace(queryParameters: {'consent_challenge': consentChallenge});
|
||||||
final response = await http.get(
|
final response = await http.get(
|
||||||
url,
|
url,
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
@@ -287,17 +307,17 @@ class AuthProxyService {
|
|||||||
errorBody['error'] ??
|
errorBody['error'] ??
|
||||||
tr(
|
tr(
|
||||||
'err.userfront.auth_proxy.consent_fetch',
|
'err.userfront.auth_proxy.consent_fetch',
|
||||||
fallback: '동의 정보를 가져오지 못했습니다.',
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> acceptConsent(String consentChallenge, {List<String>? grantScope}) async {
|
static Future<Map<String, dynamic>> acceptConsent(
|
||||||
|
String consentChallenge, {
|
||||||
|
List<String>? grantScope,
|
||||||
|
}) async {
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/auth/consent/accept');
|
final url = Uri.parse('$_baseUrl/api/v1/auth/consent/accept');
|
||||||
final body = <String, dynamic>{
|
final body = <String, dynamic>{'consent_challenge': consentChallenge};
|
||||||
'consent_challenge': consentChallenge,
|
|
||||||
};
|
|
||||||
if (grantScope != null) {
|
if (grantScope != null) {
|
||||||
body['grant_scope'] = grantScope;
|
body['grant_scope'] = grantScope;
|
||||||
}
|
}
|
||||||
@@ -316,17 +336,16 @@ class AuthProxyService {
|
|||||||
errorBody['error'] ??
|
errorBody['error'] ??
|
||||||
tr(
|
tr(
|
||||||
'err.userfront.auth_proxy.consent_accept',
|
'err.userfront.auth_proxy.consent_accept',
|
||||||
fallback: '동의 처리에 실패했습니다.',
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> rejectConsent(String consentChallenge) async {
|
static Future<Map<String, dynamic>> rejectConsent(
|
||||||
|
String consentChallenge,
|
||||||
|
) async {
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/auth/consent/reject');
|
final url = Uri.parse('$_baseUrl/api/v1/auth/consent/reject');
|
||||||
final body = <String, dynamic>{
|
final body = <String, dynamic>{'consent_challenge': consentChallenge};
|
||||||
'consent_challenge': consentChallenge,
|
|
||||||
};
|
|
||||||
|
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
url,
|
url,
|
||||||
@@ -342,7 +361,6 @@ class AuthProxyService {
|
|||||||
errorBody['error'] ??
|
errorBody['error'] ??
|
||||||
tr(
|
tr(
|
||||||
'err.userfront.auth_proxy.consent_reject',
|
'err.userfront.auth_proxy.consent_reject',
|
||||||
fallback: '동의 거부에 실패했습니다.',
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -353,9 +371,7 @@ class AuthProxyService {
|
|||||||
String? token,
|
String? token,
|
||||||
}) async {
|
}) async {
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/auth/oidc/login/accept');
|
final url = Uri.parse('$_baseUrl/api/v1/auth/oidc/login/accept');
|
||||||
final headers = <String, String>{
|
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||||
'Content-Type': 'application/json',
|
|
||||||
};
|
|
||||||
if (token != null && token.isNotEmpty) {
|
if (token != null && token.isNotEmpty) {
|
||||||
headers['Authorization'] = 'Bearer $token';
|
headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
@@ -364,9 +380,7 @@ class AuthProxyService {
|
|||||||
final response = await client.post(
|
final response = await client.post(
|
||||||
url,
|
url,
|
||||||
headers: headers,
|
headers: headers,
|
||||||
body: jsonEncode({
|
body: jsonEncode({'login_challenge': loginChallenge}),
|
||||||
'login_challenge': loginChallenge,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
@@ -377,7 +391,6 @@ class AuthProxyService {
|
|||||||
errorBody['error'] ??
|
errorBody['error'] ??
|
||||||
tr(
|
tr(
|
||||||
'err.userfront.auth_proxy.oidc_accept',
|
'err.userfront.auth_proxy.oidc_accept',
|
||||||
fallback: 'OIDC 로그인 승인에 실패했습니다.',
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -386,8 +399,10 @@ class AuthProxyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>> initiatePasswordReset(
|
||||||
static Future<Map<String, dynamic>> initiatePasswordReset(String loginId, {bool? drySend}) async {
|
String loginId, {
|
||||||
|
bool? drySend,
|
||||||
|
}) async {
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/auth/password/reset/initiate');
|
final url = Uri.parse('$_baseUrl/api/v1/auth/password/reset/initiate');
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
url,
|
url,
|
||||||
@@ -406,7 +421,6 @@ class AuthProxyService {
|
|||||||
errorBody['error'] ??
|
errorBody['error'] ??
|
||||||
tr(
|
tr(
|
||||||
'err.userfront.auth_proxy.password_reset_init',
|
'err.userfront.auth_proxy.password_reset_init',
|
||||||
fallback: '비밀번호 재설정을 시작하지 못했습니다.',
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -424,7 +438,9 @@ class AuthProxyService {
|
|||||||
if (token != null && token.isNotEmpty) {
|
if (token != null && token.isNotEmpty) {
|
||||||
query['token'] = token;
|
query['token'] = token;
|
||||||
}
|
}
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/auth/password/reset/complete').replace(queryParameters: query);
|
final url = Uri.parse(
|
||||||
|
'$_baseUrl/api/v1/auth/password/reset/complete',
|
||||||
|
).replace(queryParameters: query);
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
url,
|
url,
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
@@ -439,7 +455,6 @@ class AuthProxyService {
|
|||||||
errorBody['error'] ??
|
errorBody['error'] ??
|
||||||
tr(
|
tr(
|
||||||
'err.userfront.auth_proxy.password_reset_complete',
|
'err.userfront.auth_proxy.password_reset_complete',
|
||||||
fallback: '비밀번호 재설정에 실패했습니다.',
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -451,9 +466,7 @@ class AuthProxyService {
|
|||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
url,
|
url,
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: jsonEncode({
|
body: jsonEncode({'phoneNumber': phoneNumber}),
|
||||||
'phoneNumber': phoneNumber,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
@@ -465,16 +478,16 @@ class AuthProxyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> verifySmsCode(String phoneNumber, String code) async {
|
static Future<Map<String, dynamic>> verifySmsCode(
|
||||||
|
String phoneNumber,
|
||||||
|
String code,
|
||||||
|
) async {
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/auth/verify-sms');
|
final url = Uri.parse('$_baseUrl/api/v1/auth/verify-sms');
|
||||||
|
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
url,
|
url,
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: jsonEncode({
|
body: jsonEncode({'phoneNumber': phoneNumber, 'code': code}),
|
||||||
'phoneNumber': phoneNumber,
|
|
||||||
'code': code,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
@@ -532,10 +545,10 @@ class AuthProxyService {
|
|||||||
String? token,
|
String? token,
|
||||||
bool withCredentials = false,
|
bool withCredentials = false,
|
||||||
}) async {
|
}) async {
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/auth/qr/approve'); // Mapping to ScanQRLogin on backend
|
final url = Uri.parse(
|
||||||
final payload = <String, dynamic>{
|
'$_baseUrl/api/v1/auth/qr/approve',
|
||||||
'pendingRef': pendingRef,
|
); // Mapping to ScanQRLogin on backend
|
||||||
};
|
final payload = <String, dynamic>{'pendingRef': pendingRef};
|
||||||
if (token != null && token.isNotEmpty) {
|
if (token != null && token.isNotEmpty) {
|
||||||
payload['token'] = token;
|
payload['token'] = token;
|
||||||
}
|
}
|
||||||
@@ -617,7 +630,10 @@ class AuthProxyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<List<dynamic>> listUsers(String adminPassword, {String? query}) async {
|
static Future<List<dynamic>> listUsers(
|
||||||
|
String adminPassword, {
|
||||||
|
String? query,
|
||||||
|
}) async {
|
||||||
var uri = Uri.parse('$_baseUrl/api/v1/admin/users');
|
var uri = Uri.parse('$_baseUrl/api/v1/admin/users');
|
||||||
if (query != null && query.isNotEmpty) {
|
if (query != null && query.isNotEmpty) {
|
||||||
uri = uri.replace(queryParameters: {'text': query});
|
uri = uri.replace(queryParameters: {'text': query});
|
||||||
@@ -664,7 +680,11 @@ class AuthProxyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> updateUserStatus(String adminPassword, String loginId, String status) async {
|
static Future<void> updateUserStatus(
|
||||||
|
String adminPassword,
|
||||||
|
String loginId,
|
||||||
|
String status,
|
||||||
|
) async {
|
||||||
final encodedId = Uri.encodeComponent(loginId);
|
final encodedId = Uri.encodeComponent(loginId);
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/admin/users/$encodedId/status');
|
final url = Uri.parse('$_baseUrl/api/v1/admin/users/$encodedId/status');
|
||||||
|
|
||||||
@@ -725,18 +745,13 @@ class AuthProxyService {
|
|||||||
final token = AuthTokenStore.getToken();
|
final token = AuthTokenStore.getToken();
|
||||||
|
|
||||||
final client = createHttpClient(withCredentials: useCookie);
|
final client = createHttpClient(withCredentials: useCookie);
|
||||||
final headers = <String, String>{
|
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||||
'Content-Type': 'application/json',
|
|
||||||
};
|
|
||||||
if (!useCookie && token != null) {
|
if (!useCookie && token != null) {
|
||||||
headers['Authorization'] = 'Bearer $token';
|
headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await client.get(
|
final response = await client.get(url, headers: headers);
|
||||||
url,
|
|
||||||
headers: headers,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final data = jsonDecode(response.body);
|
final data = jsonDecode(response.body);
|
||||||
@@ -758,18 +773,13 @@ class AuthProxyService {
|
|||||||
final token = AuthTokenStore.getToken();
|
final token = AuthTokenStore.getToken();
|
||||||
|
|
||||||
final client = createHttpClient(withCredentials: useCookie);
|
final client = createHttpClient(withCredentials: useCookie);
|
||||||
final headers = <String, String>{
|
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||||
'Content-Type': 'application/json',
|
|
||||||
};
|
|
||||||
if (!useCookie && token != null) {
|
if (!useCookie && token != null) {
|
||||||
headers['Authorization'] = 'Bearer $token';
|
headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await client.delete(
|
final response = await client.delete(url, headers: headers);
|
||||||
url,
|
|
||||||
headers: headers,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
final errorBody = jsonDecode(response.body);
|
final errorBody = jsonDecode(response.body);
|
||||||
@@ -777,7 +787,6 @@ class AuthProxyService {
|
|||||||
errorBody['error'] ??
|
errorBody['error'] ??
|
||||||
tr(
|
tr(
|
||||||
'err.userfront.auth_proxy.linked_app_revoke',
|
'err.userfront.auth_proxy.linked_app_revoke',
|
||||||
fallback: '연동 해지에 실패했습니다.',
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -786,7 +795,11 @@ class AuthProxyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> sendLog(String level, String message, {Map<String, dynamic>? data}) async {
|
static Future<void> sendLog(
|
||||||
|
String level,
|
||||||
|
String message, {
|
||||||
|
Map<String, dynamic>? data,
|
||||||
|
}) async {
|
||||||
if (!_canSendClientLog()) {
|
if (!_canSendClientLog()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -808,7 +821,11 @@ class AuthProxyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> logError(String message, {dynamic error, StackTrace? stackTrace}) async {
|
static Future<void> logError(
|
||||||
|
String message, {
|
||||||
|
dynamic error,
|
||||||
|
StackTrace? stackTrace,
|
||||||
|
}) async {
|
||||||
final data = <String, dynamic>{};
|
final data = <String, dynamic>{};
|
||||||
if (error != null) data['error'] = error.toString();
|
if (error != null) data['error'] = error.toString();
|
||||||
if (stackTrace != null) data['stack'] = stackTrace.toString();
|
if (stackTrace != null) data['stack'] = stackTrace.toString();
|
||||||
@@ -877,17 +894,17 @@ class AuthProxyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<bool> verifySignupCode(String target, String type, String code) async {
|
static Future<bool> verifySignupCode(
|
||||||
|
String target,
|
||||||
|
String type,
|
||||||
|
String code,
|
||||||
|
) async {
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/auth/signup/verify-code');
|
final url = Uri.parse('$_baseUrl/api/v1/auth/signup/verify-code');
|
||||||
|
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
url,
|
url,
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: jsonEncode({
|
body: jsonEncode({'target': target, 'type': type, 'code': code}),
|
||||||
'target': target,
|
|
||||||
'type': type,
|
|
||||||
'code': code,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
class WebWindow {
|
class WebWindow {
|
||||||
|
void setTitle(String title) {}
|
||||||
|
|
||||||
void redirectTo(String url) {}
|
void redirectTo(String url) {}
|
||||||
|
|
||||||
void alert(String message) {}
|
void alert(String message) {}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
import 'dart:html' as html;
|
import 'dart:html' as html;
|
||||||
|
|
||||||
class WebWindow {
|
class WebWindow {
|
||||||
|
void setTitle(String title) {
|
||||||
|
html.document.title = title;
|
||||||
|
}
|
||||||
|
|
||||||
void redirectTo(String url) {
|
void redirectTo(String url) {
|
||||||
html.window.location.href = url;
|
html.window.location.href = url;
|
||||||
}
|
}
|
||||||
|
|||||||
65
userfront/lib/core/widgets/language_selector.dart
Normal file
65
userfront/lib/core/widgets/language_selector.dart
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart' hide tr;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:userfront/i18n.dart';
|
||||||
|
|
||||||
|
import '../i18n/locale_storage.dart';
|
||||||
|
import '../i18n/locale_utils.dart';
|
||||||
|
|
||||||
|
class LanguageSelector extends StatelessWidget {
|
||||||
|
const LanguageSelector({super.key, this.compact = false});
|
||||||
|
|
||||||
|
final bool compact;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final current = context.locale.languageCode;
|
||||||
|
final items = [
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'ko',
|
||||||
|
child: Text(tr('ui.common.language_ko')),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'en',
|
||||||
|
child: Text(tr('ui.common.language_en', fallback: 'English')),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
final iconSize = compact ? 16.0 : 18.0;
|
||||||
|
final dropdown = DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton<String>(
|
||||||
|
value: current,
|
||||||
|
items: items,
|
||||||
|
isDense: true,
|
||||||
|
icon: Icon(Icons.arrow_drop_down, size: compact ? 18 : 20),
|
||||||
|
onChanged: (value) async {
|
||||||
|
if (value == null || value == current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
LocaleStorage.write(value);
|
||||||
|
await context.setLocale(Locale(value));
|
||||||
|
final uri = GoRouterState.of(context).uri;
|
||||||
|
final target = buildLocalizedPath(value, uri);
|
||||||
|
if (context.mounted) {
|
||||||
|
context.go(target);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(top: compact ? 0 : 2),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(minHeight: compact ? 24 : 28),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.language, size: iconSize),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
dropdown,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,32 +30,33 @@ class ErrorScreen extends StatelessWidget {
|
|||||||
? (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: '인증 과정에서 오류가 발생했습니다')
|
? tr('msg.userfront.error.title')
|
||||||
: (hasCode
|
: (hasCode
|
||||||
? tr(
|
? tr(
|
||||||
'msg.userfront.error.title_with_code',
|
'msg.userfront.error.title_with_code',
|
||||||
fallback: '오류: {{code}}',
|
params: {'code': normalizedCode},
|
||||||
params: {'code': normalizedCode},
|
)
|
||||||
)
|
: tr(
|
||||||
: tr('msg.userfront.error.title_generic', fallback: '오류가 발생했습니다'));
|
'msg.userfront.error.title_generic',
|
||||||
|
));
|
||||||
final detail = isProd
|
final detail = isProd
|
||||||
? (isWhitelisted
|
? (isWhitelisted
|
||||||
? tr(
|
? tr(
|
||||||
'msg.userfront.error.whitelist.$normalizedCode',
|
'msg.userfront.error.whitelist.$normalizedCode',
|
||||||
fallback: whitelistFallback,
|
fallback: whitelistFallback,
|
||||||
)
|
)
|
||||||
: tr(
|
: tr(
|
||||||
'msg.userfront.error.detail_contact',
|
'msg.userfront.error.detail_contact',
|
||||||
fallback: '에러가 계속되면 관리자에게 문의해주세요',
|
))
|
||||||
))
|
|
||||||
: ((description?.isNotEmpty == true)
|
: ((description?.isNotEmpty == true)
|
||||||
? description!
|
? description!
|
||||||
: (hasCode
|
: (hasCode
|
||||||
? tr('msg.userfront.error.detail_generic', fallback: '오류가 발생했습니다.')
|
? tr(
|
||||||
: tr(
|
'msg.userfront.error.detail_generic',
|
||||||
'msg.userfront.error.detail_request',
|
)
|
||||||
fallback: '요청을 처리하는 중 문제가 발생했습니다.',
|
: tr(
|
||||||
)));
|
'msg.userfront.error.detail_request',
|
||||||
|
)));
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF7F8FA),
|
backgroundColor: const Color(0xFFF7F8FA),
|
||||||
@@ -94,7 +95,6 @@ class ErrorScreen extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.error.type',
|
'msg.userfront.error.type',
|
||||||
fallback: '오류 종류: {{type}}',
|
|
||||||
params: {'type': errorType},
|
params: {'type': errorType},
|
||||||
),
|
),
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
@@ -106,7 +106,6 @@ class ErrorScreen extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.error.id',
|
'msg.userfront.error.id',
|
||||||
fallback: '오류 ID: {{id}}',
|
|
||||||
params: {'id': errorId!},
|
params: {'id': errorId!},
|
||||||
),
|
),
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
@@ -124,27 +123,35 @@ class ErrorScreen extends StatelessWidget {
|
|||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: const Color(0xFF111827),
|
backgroundColor: const Color(0xFF111827),
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
tr('ui.userfront.error.go_login', fallback: '로그인으로 이동'),
|
tr(
|
||||||
|
'ui.userfront.error.go_login',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onPressed: () => context.go('/'),
|
onPressed: () => context.go('/'),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: const Color(0xFF111827),
|
foregroundColor: const Color(0xFF111827),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
side: const BorderSide(color: Color(0xFFCBD5F5)),
|
side: const BorderSide(color: Color(0xFFCBD5F5)),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
tr('ui.userfront.error.go_home', fallback: '홈으로 이동'),
|
tr('ui.userfront.error.go_home'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_drySendEnabled = _parseBoolParam(Uri.base.queryParameters['drySend']) && !AuthProxyService.isProdEnv;
|
_drySendEnabled =
|
||||||
|
_parseBoolParam(Uri.base.queryParameters['drySend']) &&
|
||||||
|
!AuthProxyService.isProdEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handlePasswordReset() async {
|
Future<void> _handlePasswordReset() async {
|
||||||
@@ -26,7 +28,6 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
|||||||
_showError(
|
_showError(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.forgot.input_required',
|
'msg.userfront.forgot.input_required',
|
||||||
fallback: '이메일 또는 휴대폰 번호를 입력해주세요.',
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -44,14 +45,16 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
|||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await AuthProxyService.initiatePasswordReset(loginId, drySend: _drySendEnabled);
|
await AuthProxyService.initiatePasswordReset(
|
||||||
|
loginId,
|
||||||
|
drySend: _drySendEnabled,
|
||||||
|
);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.forgot.sent',
|
'msg.userfront.forgot.sent',
|
||||||
fallback: '비밀번호 재설정 링크가 전송되었습니다. 이메일 또는 SMS를 확인해주세요.',
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
@@ -64,7 +67,6 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
|||||||
_showError(
|
_showError(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.forgot.error',
|
'msg.userfront.forgot.error',
|
||||||
fallback: '전송에 실패했습니다: {{error}}',
|
|
||||||
params: {'error': e.toString()},
|
params: {'error': e.toString()},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -94,7 +96,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(tr('ui.userfront.forgot.title', fallback: '비밀번호 재설정')),
|
title: Text(tr('ui.userfront.forgot.title')),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
),
|
),
|
||||||
body: Center(
|
body: Center(
|
||||||
@@ -106,17 +108,17 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
tr('ui.userfront.forgot.heading', fallback: '비밀번호를 잊으셨나요?'),
|
tr('ui.userfront.forgot.heading'),
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
if (_drySendEnabled) ...[
|
if (_drySendEnabled) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFFFF3CD),
|
color: const Color(0xFFFFF3CD),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
@@ -124,15 +126,20 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.warning_amber_rounded, color: Color(0xFF8A6D3B)),
|
const Icon(
|
||||||
|
Icons.warning_amber_rounded,
|
||||||
|
color: Color(0xFF8A6D3B),
|
||||||
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.forgot.dry_send',
|
'msg.userfront.forgot.dry_send',
|
||||||
fallback: 'drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.',
|
|
||||||
),
|
),
|
||||||
style: const TextStyle(color: Color(0xFF8A6D3B), fontSize: 12),
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF8A6D3B),
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -143,8 +150,6 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
|||||||
Text(
|
Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.forgot.description',
|
'msg.userfront.forgot.description',
|
||||||
fallback:
|
|
||||||
'계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다.',
|
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(color: Colors.grey),
|
style: const TextStyle(color: Colors.grey),
|
||||||
@@ -155,7 +160,6 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: tr(
|
labelText: tr(
|
||||||
'ui.userfront.forgot.input_label',
|
'ui.userfront.forgot.input_label',
|
||||||
fallback: '이메일 또는 휴대폰 번호',
|
|
||||||
),
|
),
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
prefixIcon: const Icon(Icons.person_outline),
|
prefixIcon: const Icon(Icons.person_outline),
|
||||||
@@ -172,13 +176,13 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
|||||||
? 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,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: Text(
|
: Text(
|
||||||
tr(
|
tr('ui.userfront.forgot.submit'),
|
||||||
'ui.userfront.forgot.submit',
|
|
||||||
fallback: '재설정 링크 전송',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,18 +14,21 @@ class LoginSuccessScreen extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
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: '로그인 완료'),
|
tr('ui.userfront.login_success.title'),
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
tr('msg.userfront.login_success.subtitle', fallback: '성공적으로 로그인되었습니다.'),
|
tr(
|
||||||
|
'msg.userfront.login_success.subtitle',
|
||||||
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(color: Colors.grey, fontSize: 16),
|
style: const TextStyle(color: Colors.grey, fontSize: 16),
|
||||||
),
|
),
|
||||||
@@ -38,12 +41,17 @@ class LoginSuccessScreen extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
icon: const Icon(Icons.camera_alt, size: 28),
|
icon: const Icon(Icons.camera_alt, size: 28),
|
||||||
label: Text(
|
label: Text(
|
||||||
tr('ui.userfront.login_success.qr', fallback: 'QR 인증 (카메라 켜기)'),
|
tr(
|
||||||
|
'ui.userfront.login_success.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,
|
||||||
textStyle: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
@@ -57,7 +65,6 @@ class LoginSuccessScreen extends StatelessWidget {
|
|||||||
child: Text(
|
child: Text(
|
||||||
tr(
|
tr(
|
||||||
'ui.userfront.login_success.later',
|
'ui.userfront.login_success.later',
|
||||||
fallback: '나중에 하기 (대시보드로 이동)',
|
|
||||||
),
|
),
|
||||||
style: const TextStyle(color: Colors.grey),
|
style: const TextStyle(color: Colors.grey),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -146,7 +146,6 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
|||||||
_isSuccess = true;
|
_isSuccess = true;
|
||||||
_resultMessage = tr(
|
_resultMessage = tr(
|
||||||
'msg.userfront.qr.approve_success',
|
'msg.userfront.qr.approve_success',
|
||||||
fallback: 'QR 승인 완료! PC 화면에서 로그인이 진행됩니다.',
|
|
||||||
);
|
);
|
||||||
_isProcessing = false;
|
_isProcessing = false;
|
||||||
});
|
});
|
||||||
@@ -158,7 +157,6 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
|||||||
_isSuccess = false;
|
_isSuccess = false;
|
||||||
_resultMessage = tr(
|
_resultMessage = tr(
|
||||||
'msg.userfront.qr.approve_error',
|
'msg.userfront.qr.approve_error',
|
||||||
fallback: 'QR 승인 실패: {{error}}',
|
|
||||||
params: {'error': '$e'},
|
params: {'error': '$e'},
|
||||||
);
|
);
|
||||||
_isProcessing = false;
|
_isProcessing = false;
|
||||||
@@ -193,7 +191,6 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
|||||||
content: Text(
|
content: Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.qr.permission_error',
|
'msg.userfront.qr.permission_error',
|
||||||
fallback: '카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요.',
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: Colors.red,
|
||||||
@@ -212,8 +209,8 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
|||||||
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_success')
|
||||||
: tr('ui.userfront.qr.result_failure', fallback: '승인 실패');
|
: tr('ui.userfront.qr.result_failure');
|
||||||
final message = _resultMessage ?? '';
|
final message = _resultMessage ?? '';
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
@@ -226,7 +223,11 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: color),
|
style: TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
@@ -238,12 +239,12 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
|||||||
if (!success)
|
if (!success)
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: _resetScan,
|
onPressed: _resetScan,
|
||||||
child: Text(tr('ui.userfront.qr.rescan', fallback: '다시 스캔')),
|
child: Text(tr('ui.userfront.qr.rescan')),
|
||||||
),
|
),
|
||||||
if (success)
|
if (success)
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => context.pop(),
|
onPressed: () => context.pop(),
|
||||||
child: Text(tr('ui.common.close', fallback: '닫기')),
|
child: Text(tr('ui.common.close')),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -268,7 +269,8 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
|||||||
controller: controller,
|
controller: controller,
|
||||||
onDetect: _onDetect,
|
onDetect: _onDetect,
|
||||||
errorBuilder: (context, error) {
|
errorBuilder: (context, error) {
|
||||||
final isPermissionDenied = error.errorCode ==
|
final isPermissionDenied =
|
||||||
|
error.errorCode ==
|
||||||
MobileScannerErrorCode.permissionDenied;
|
MobileScannerErrorCode.permissionDenied;
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -280,11 +282,9 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
|||||||
isPermissionDenied
|
isPermissionDenied
|
||||||
? tr(
|
? tr(
|
||||||
'msg.userfront.qr.permission_required',
|
'msg.userfront.qr.permission_required',
|
||||||
fallback: '카메라 권한이 필요합니다.',
|
|
||||||
)
|
)
|
||||||
: tr(
|
: tr(
|
||||||
'msg.userfront.qr.camera_error',
|
'msg.userfront.qr.camera_error',
|
||||||
fallback: '카메라 오류: {{error}}',
|
|
||||||
params: {'error': '${error.errorCode}'},
|
params: {'error': '${error.errorCode}'},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -295,10 +295,11 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
|||||||
: _requestCameraPermission,
|
: _requestCameraPermission,
|
||||||
child: Text(
|
child: Text(
|
||||||
_isRequestingCamera
|
_isRequestingCamera
|
||||||
? tr('ui.common.requesting', fallback: '요청 중...')
|
? tr(
|
||||||
|
'ui.common.requesting',
|
||||||
|
)
|
||||||
: tr(
|
: tr(
|
||||||
'ui.userfront.qr.request_permission',
|
'ui.userfront.qr.request_permission',
|
||||||
fallback: '카메라 권한 요청하기',
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ class ResetPasswordScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||||
final TextEditingController _passwordController = TextEditingController();
|
final TextEditingController _passwordController = TextEditingController();
|
||||||
final TextEditingController _confirmPasswordController = TextEditingController();
|
final TextEditingController _confirmPasswordController =
|
||||||
|
TextEditingController();
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _loginId;
|
String? _loginId;
|
||||||
@@ -31,13 +32,13 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
|
|
||||||
// 2. Fallback to URI query parameter if not available via router
|
// 2. Fallback to URI query parameter if not available via router
|
||||||
if (_loginId == null || _loginId!.isEmpty) {
|
if (_loginId == null || _loginId!.isEmpty) {
|
||||||
final uri = Uri.base;
|
final uri = Uri.base;
|
||||||
_loginId = uri.queryParameters['loginId'];
|
_loginId = uri.queryParameters['loginId'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 토큰도 함께 읽어놓는다.
|
// 토큰도 함께 읽어놓는다.
|
||||||
final uri = Uri.base;
|
final uri = Uri.base;
|
||||||
_token = uri.queryParameters['token'];
|
_token = uri.queryParameters['token'];
|
||||||
|
|
||||||
_loadPolicy();
|
_loadPolicy();
|
||||||
}
|
}
|
||||||
@@ -66,11 +67,11 @@ 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(
|
_showError(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.reset.invalid_link',
|
'msg.userfront.reset.invalid_link',
|
||||||
fallback: '유효하지 않은 재설정 링크입니다. (loginId/token 누락)',
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -91,7 +92,6 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
content: Text(
|
content: Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.reset.success',
|
'msg.userfront.reset.success',
|
||||||
fallback: '비밀번호가 성공적으로 변경되었습니다. 다시 로그인해주세요.',
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
@@ -104,7 +104,6 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
_showError(
|
_showError(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.reset.error.generic',
|
'msg.userfront.reset.error.generic',
|
||||||
fallback: '비밀번호 변경에 실패했습니다: {{error}}',
|
|
||||||
params: {'error': e.toString()},
|
params: {'error': e.toString()},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -126,7 +125,6 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
if (_isPolicyLoading) {
|
if (_isPolicyLoading) {
|
||||||
return tr(
|
return tr(
|
||||||
'msg.userfront.reset.policy_loading',
|
'msg.userfront.reset.policy_loading',
|
||||||
fallback: '비밀번호 정책을 불러오는 중입니다...',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final minLength = (_policy?['minLength'] as int?) ?? 12;
|
final minLength = (_policy?['minLength'] as int?) ?? 12;
|
||||||
@@ -139,7 +137,6 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
final parts = <String>[
|
final parts = <String>[
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.reset.policy.min_length',
|
'msg.userfront.reset.policy.min_length',
|
||||||
fallback: '최소 {{count}}자 이상',
|
|
||||||
params: {'count': '$minLength'},
|
params: {'count': '$minLength'},
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
@@ -147,29 +144,26 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
parts.add(
|
parts.add(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.reset.policy.min_types',
|
'msg.userfront.reset.policy.min_types',
|
||||||
fallback: '영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상',
|
|
||||||
params: {'count': '$minTypes'},
|
params: {'count': '$minTypes'},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (requiresLower) {
|
if (requiresLower) {
|
||||||
parts.add(
|
parts.add(
|
||||||
tr('msg.userfront.reset.policy.lowercase', fallback: '소문자 1개 이상'),
|
tr('msg.userfront.reset.policy.lowercase'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (requiresUpper) {
|
if (requiresUpper) {
|
||||||
parts.add(
|
parts.add(
|
||||||
tr('msg.userfront.reset.policy.uppercase', fallback: '대문자 1개 이상'),
|
tr('msg.userfront.reset.policy.uppercase'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (requiresNumber) {
|
if (requiresNumber) {
|
||||||
parts.add(
|
parts.add(tr('msg.userfront.reset.policy.number'));
|
||||||
tr('msg.userfront.reset.policy.number', fallback: '숫자 1개 이상'),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (requiresSymbol) {
|
if (requiresSymbol) {
|
||||||
parts.add(
|
parts.add(
|
||||||
tr('msg.userfront.reset.policy.symbol', fallback: '특수문자 1개 이상'),
|
tr('msg.userfront.reset.policy.symbol'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,16 +174,16 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(
|
title: Text(tr('ui.userfront.reset.title')),
|
||||||
tr('ui.userfront.reset.title', fallback: '새 비밀번호 설정'),
|
|
||||||
),
|
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
),
|
),
|
||||||
body: Center(
|
body: Center(
|
||||||
child: Container(
|
child: Container(
|
||||||
constraints: const BoxConstraints(maxWidth: 400),
|
constraints: const BoxConstraints(maxWidth: 400),
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: (_loginId == null || _loginId!.isEmpty) && (_token == null || _token!.isEmpty)
|
child:
|
||||||
|
(_loginId == null || _loginId!.isEmpty) &&
|
||||||
|
(_token == null || _token!.isEmpty)
|
||||||
? _buildInvalidTokenView()
|
? _buildInvalidTokenView()
|
||||||
: Form(
|
: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
@@ -200,7 +194,6 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
Text(
|
Text(
|
||||||
tr(
|
tr(
|
||||||
'ui.userfront.reset.subtitle',
|
'ui.userfront.reset.subtitle',
|
||||||
fallback: '새로운 비밀번호 설정',
|
|
||||||
),
|
),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
@@ -221,13 +214,14 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: tr(
|
labelText: tr(
|
||||||
'ui.userfront.reset.new_password',
|
'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(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
_isPasswordObscured ? Icons.visibility_off : Icons.visibility,
|
_isPasswordObscured
|
||||||
|
? Icons.visibility_off
|
||||||
|
: Icons.visibility,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -241,14 +235,13 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
if (val.isEmpty) {
|
if (val.isEmpty) {
|
||||||
return tr(
|
return tr(
|
||||||
'msg.userfront.reset.error.empty_password',
|
'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 tr(
|
return tr(
|
||||||
'msg.userfront.reset.error.min_length',
|
'msg.userfront.reset.error.min_length',
|
||||||
fallback: '비밀번호는 최소 {{count}}자 이상이어야 합니다.',
|
|
||||||
params: {'count': '$minLength'},
|
params: {'count': '$minLength'},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -262,12 +255,11 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
if (hasNumber) typeCount++;
|
if (hasNumber) typeCount++;
|
||||||
if (hasSymbol) typeCount++;
|
if (hasSymbol) typeCount++;
|
||||||
|
|
||||||
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 tr(
|
return tr(
|
||||||
'msg.userfront.reset.error.min_types',
|
'msg.userfront.reset.error.min_types',
|
||||||
fallback:
|
|
||||||
'비밀번호는 영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상 포함해야 합니다.',
|
|
||||||
params: {'count': '$minTypes'},
|
params: {'count': '$minTypes'},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -275,25 +267,22 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
if ((_policy?['lowercase'] ?? true) && !hasLower) {
|
if ((_policy?['lowercase'] ?? true) && !hasLower) {
|
||||||
return tr(
|
return tr(
|
||||||
'msg.userfront.reset.error.lowercase',
|
'msg.userfront.reset.error.lowercase',
|
||||||
fallback: '최소 1개 이상의 소문자를 포함해야 합니다.',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if ((_policy?['uppercase'] ?? false) && !hasUpper) {
|
if ((_policy?['uppercase'] ?? false) && !hasUpper) {
|
||||||
return tr(
|
return tr(
|
||||||
'msg.userfront.reset.error.uppercase',
|
'msg.userfront.reset.error.uppercase',
|
||||||
fallback: '최소 1개 이상의 대문자를 포함해야 합니다.',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if ((_policy?['number'] ?? true) && !hasNumber) {
|
if ((_policy?['number'] ?? true) && !hasNumber) {
|
||||||
return tr(
|
return tr(
|
||||||
'msg.userfront.reset.error.number',
|
'msg.userfront.reset.error.number',
|
||||||
fallback: '최소 1개 이상의 숫자를 포함해야 합니다.',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if ((_policy?['nonAlphanumeric'] ?? true) && !hasSymbol) {
|
if ((_policy?['nonAlphanumeric'] ?? true) &&
|
||||||
|
!hasSymbol) {
|
||||||
return tr(
|
return tr(
|
||||||
'msg.userfront.reset.error.symbol',
|
'msg.userfront.reset.error.symbol',
|
||||||
fallback: '최소 1개 이상의 특수문자를 포함해야 합니다.',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -306,17 +295,19 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: tr(
|
labelText: tr(
|
||||||
'ui.userfront.reset.confirm_password',
|
'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(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
_isConfirmPasswordObscured ? Icons.visibility_off : Icons.visibility,
|
_isConfirmPasswordObscured
|
||||||
|
? Icons.visibility_off
|
||||||
|
: Icons.visibility,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isConfirmPasswordObscured = !_isConfirmPasswordObscured;
|
_isConfirmPasswordObscured =
|
||||||
|
!_isConfirmPasswordObscured;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -325,7 +316,6 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
if (value != _passwordController.text) {
|
if (value != _passwordController.text) {
|
||||||
return tr(
|
return tr(
|
||||||
'msg.userfront.reset.error.mismatch',
|
'msg.userfront.reset.error.mismatch',
|
||||||
fallback: '비밀번호가 일치하지 않습니다.',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -349,7 +339,6 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
: Text(
|
: Text(
|
||||||
tr(
|
tr(
|
||||||
'ui.userfront.reset.submit',
|
'ui.userfront.reset.submit',
|
||||||
fallback: '비밀번호 변경',
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -369,8 +358,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
const Icon(Icons.error_outline, color: Colors.red, size: 60),
|
const Icon(Icons.error_outline, color: Colors.red, size: 60),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
tr('msg.userfront.reset.invalid_title',
|
tr('msg.userfront.reset.invalid_title'),
|
||||||
fallback: '유효하지 않은 링크입니다.'),
|
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -378,7 +366,6 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
Text(
|
Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.reset.invalid_body',
|
'msg.userfront.reset.invalid_body',
|
||||||
fallback: '비밀번호 재설정 링크가 만료되었거나 잘못되었습니다. 다시 시도해주세요.',
|
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -164,30 +164,36 @@ 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 = tr(
|
setState(
|
||||||
'msg.userfront.signup.email.invalid',
|
() => _emailError = tr(
|
||||||
fallback: '유효한 이메일 형식이 아닙니다.',
|
'msg.userfront.signup.email.invalid',
|
||||||
));
|
),
|
||||||
|
);
|
||||||
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 = tr(
|
setState(
|
||||||
'msg.userfront.signup.email.duplicate',
|
() => _emailError = tr(
|
||||||
fallback: '이미 가입된 이메일입니다.',
|
'msg.userfront.signup.email.duplicate',
|
||||||
));
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await AuthProxyService.sendSignupCode(email, 'email');
|
await AuthProxyService.sendSignupCode(email, 'email');
|
||||||
_startTimer('email');
|
_startTimer('email');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _emailError = tr(
|
setState(
|
||||||
'msg.userfront.signup.email.send_failed',
|
() => _emailError = tr(
|
||||||
fallback: '발송 실패: {{error}}',
|
'msg.userfront.signup.email.send_failed',
|
||||||
params: {'error': e.toString()},
|
params: {'error': e.toString()},
|
||||||
));
|
),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
@@ -197,7 +203,11 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
final code = _emailCodeController.text.trim();
|
final code = _emailCodeController.text.trim();
|
||||||
if (code.length != 6) return;
|
if (code.length != 6) return;
|
||||||
try {
|
try {
|
||||||
final success = await AuthProxyService.verifySignupCode(_emailController.text.trim(), 'email', code);
|
final success = await AuthProxyService.verifySignupCode(
|
||||||
|
_emailController.text.trim(),
|
||||||
|
'email',
|
||||||
|
code,
|
||||||
|
);
|
||||||
if (success) {
|
if (success) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isEmailVerified = true;
|
_isEmailVerified = true;
|
||||||
@@ -206,33 +216,39 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
_emailError = null;
|
_emailError = null;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setState(() => _emailError = tr(
|
setState(
|
||||||
'msg.userfront.signup.email.code_mismatch',
|
() => _emailError = tr(
|
||||||
fallback: '인증코드가 일치하지 않습니다.',
|
'msg.userfront.signup.email.code_mismatch',
|
||||||
));
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _emailError = tr(
|
setState(
|
||||||
'msg.userfront.signup.email.verify_failed',
|
() => _emailError = tr(
|
||||||
fallback: '인증 실패: {{error}}',
|
'msg.userfront.signup.email.verify_failed',
|
||||||
params: {'error': e.toString()},
|
params: {'error': e.toString()},
|
||||||
));
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _sendPhoneCode() async {
|
Future<void> _sendPhoneCode() async {
|
||||||
final phone = _phoneController.text.trim();
|
final phone = _phoneController.text.trim();
|
||||||
if (phone.isEmpty) return;
|
if (phone.isEmpty) return;
|
||||||
setState(() { _isLoading = true; _phoneError = null; });
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_phoneError = null;
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await AuthProxyService.sendSignupCode(phone, 'phone');
|
await AuthProxyService.sendSignupCode(phone, 'phone');
|
||||||
_startTimer('phone');
|
_startTimer('phone');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _phoneError = tr(
|
setState(
|
||||||
'msg.userfront.signup.phone.send_failed',
|
() => _phoneError = tr(
|
||||||
fallback: '발송 실패: {{error}}',
|
'msg.userfront.signup.phone.send_failed',
|
||||||
params: {'error': e.toString()},
|
params: {'error': e.toString()},
|
||||||
));
|
),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
@@ -242,7 +258,11 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
final code = _phoneCodeController.text.trim();
|
final code = _phoneCodeController.text.trim();
|
||||||
if (code.length != 6) return;
|
if (code.length != 6) return;
|
||||||
try {
|
try {
|
||||||
final success = await AuthProxyService.verifySignupCode(_phoneController.text.trim(), 'phone', code);
|
final success = await AuthProxyService.verifySignupCode(
|
||||||
|
_phoneController.text.trim(),
|
||||||
|
'phone',
|
||||||
|
code,
|
||||||
|
);
|
||||||
if (success) {
|
if (success) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isPhoneVerified = true;
|
_isPhoneVerified = true;
|
||||||
@@ -251,26 +271,29 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
_phoneError = null;
|
_phoneError = null;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setState(() => _phoneError = tr(
|
setState(
|
||||||
'msg.userfront.signup.phone.code_mismatch',
|
() => _phoneError = tr(
|
||||||
fallback: '인증코드가 일치하지 않습니다.',
|
'msg.userfront.signup.phone.code_mismatch',
|
||||||
));
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _phoneError = tr(
|
setState(
|
||||||
'msg.userfront.signup.phone.verify_failed',
|
() => _phoneError = tr(
|
||||||
fallback: '인증 실패: {{error}}',
|
'msg.userfront.signup.phone.verify_failed',
|
||||||
params: {'error': e.toString()},
|
params: {'error': e.toString()},
|
||||||
));
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleSignup() async {
|
Future<void> _handleSignup() async {
|
||||||
if (_passwordController.text != _confirmPasswordController.text) {
|
if (_passwordController.text != _confirmPasswordController.text) {
|
||||||
setState(() => _confirmPasswordError = tr(
|
setState(
|
||||||
'msg.userfront.signup.password.mismatch',
|
() => _confirmPasswordError = tr(
|
||||||
fallback: '비밀번호가 일치하지 않습니다.',
|
'msg.userfront.signup.password.mismatch',
|
||||||
));
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!_formKey.currentState!.validate()) return;
|
if (!_formKey.currentState!.validate()) return;
|
||||||
@@ -288,7 +311,9 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
phone: _phoneController.text.trim(),
|
phone: _phoneController.text.trim(),
|
||||||
affiliationType: _affiliationType,
|
affiliationType: _affiliationType,
|
||||||
companyCode: _affiliationType == 'AFFILIATE' ? _companyCode : null,
|
companyCode: _affiliationType == 'AFFILIATE' ? _companyCode : null,
|
||||||
department: _deptController.text.trim().isEmpty ? (_affiliationType == 'GENERAL' ? 'External' : '') : _deptController.text.trim(),
|
department: _deptController.text.trim().isEmpty
|
||||||
|
? (_affiliationType == 'GENERAL' ? 'External' : '')
|
||||||
|
: _deptController.text.trim(),
|
||||||
termsAccepted: true,
|
termsAccepted: true,
|
||||||
);
|
);
|
||||||
if (mounted) _showSuccessDialog();
|
if (mounted) _showSuccessDialog();
|
||||||
@@ -298,32 +323,26 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
if (eStr.contains('uppercase')) {
|
if (eStr.contains('uppercase')) {
|
||||||
_passwordError = tr(
|
_passwordError = tr(
|
||||||
'msg.userfront.signup.password.uppercase_required',
|
'msg.userfront.signup.password.uppercase_required',
|
||||||
fallback: '대문자가 최소 1개 이상 포함되어야 합니다.',
|
|
||||||
);
|
);
|
||||||
} else if (eStr.contains('lowercase')) {
|
} else if (eStr.contains('lowercase')) {
|
||||||
_passwordError = tr(
|
_passwordError = tr(
|
||||||
'msg.userfront.signup.password.lowercase_required',
|
'msg.userfront.signup.password.lowercase_required',
|
||||||
fallback: '소문자가 최소 1개 이상 포함되어야 합니다.',
|
|
||||||
);
|
);
|
||||||
} else if (eStr.contains('digit') || eStr.contains('number')) {
|
} else if (eStr.contains('digit') || eStr.contains('number')) {
|
||||||
_passwordError = tr(
|
_passwordError = tr(
|
||||||
'msg.userfront.signup.password.number_required',
|
'msg.userfront.signup.password.number_required',
|
||||||
fallback: '숫자가 최소 1개 이상 포함되어야 합니다.',
|
|
||||||
);
|
);
|
||||||
} else if (eStr.contains('symbol') || eStr.contains('special')) {
|
} else if (eStr.contains('symbol') || eStr.contains('special')) {
|
||||||
_passwordError = tr(
|
_passwordError = tr(
|
||||||
'msg.userfront.signup.password.symbol_required',
|
'msg.userfront.signup.password.symbol_required',
|
||||||
fallback: '특수문자가 최소 1개 이상 포함되어야 합니다.',
|
|
||||||
);
|
);
|
||||||
} else if (eStr.contains('length') || eStr.contains('12 characters')) {
|
} else if (eStr.contains('length') || eStr.contains('12 characters')) {
|
||||||
_passwordError = tr(
|
_passwordError = tr(
|
||||||
'msg.userfront.signup.password.length_required',
|
'msg.userfront.signup.password.length_required',
|
||||||
fallback: '비밀번호는 최소 12자 이상이어야 합니다.',
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
_passwordError = tr(
|
_passwordError = tr(
|
||||||
'msg.userfront.signup.failed',
|
'msg.userfront.signup.failed',
|
||||||
fallback: '가입 실패: {{error}}',
|
|
||||||
params: {'error': e.toString()},
|
params: {'error': e.toString()},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -339,16 +358,16 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: Text(
|
title: Text(
|
||||||
tr('msg.userfront.signup.success.title', fallback: '회원가입 완료'),
|
tr('msg.userfront.signup.success.title'),
|
||||||
),
|
),
|
||||||
content: Text(
|
content: Text(
|
||||||
tr('msg.userfront.signup.success.body', fallback: '성공적으로 가입되었습니다.'),
|
tr('msg.userfront.signup.success.body'),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => context.go('/signin'),
|
onPressed: () => context.go('/signin'),
|
||||||
child: Text(
|
child: Text(
|
||||||
tr('ui.userfront.signup.success.action', fallback: '로그인하기'),
|
tr('ui.userfront.signup.success.action'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -365,22 +384,22 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
children: [
|
children: [
|
||||||
_stepCircle(
|
_stepCircle(
|
||||||
1,
|
1,
|
||||||
tr('ui.userfront.signup.steps.agreement', fallback: '약관동의'),
|
tr('ui.userfront.signup.steps.agreement'),
|
||||||
),
|
),
|
||||||
_stepLine(1),
|
_stepLine(1),
|
||||||
_stepCircle(
|
_stepCircle(
|
||||||
2,
|
2,
|
||||||
tr('ui.userfront.signup.steps.verify', fallback: '본인인증'),
|
tr('ui.userfront.signup.steps.verify'),
|
||||||
),
|
),
|
||||||
_stepLine(2),
|
_stepLine(2),
|
||||||
_stepCircle(
|
_stepCircle(
|
||||||
3,
|
3,
|
||||||
tr('ui.userfront.signup.steps.profile', fallback: '정보입력'),
|
tr('ui.userfront.signup.steps.profile'),
|
||||||
),
|
),
|
||||||
_stepLine(3),
|
_stepLine(3),
|
||||||
_stepCircle(
|
_stepCircle(
|
||||||
4,
|
4,
|
||||||
tr('ui.userfront.signup.steps.password', fallback: '비밀번호'),
|
tr('ui.userfront.signup.steps.password'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -394,11 +413,28 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
radius: 12,
|
radius: 12,
|
||||||
backgroundColor: isDone ? Colors.green : (isCurrent ? Colors.black : Colors.grey[300]),
|
backgroundColor: isDone
|
||||||
child: isDone ? const Icon(Icons.check, size: 14, color: Colors.white) : Text('$step', style: TextStyle(color: isCurrent ? Colors.white : Colors.black54, fontSize: 10)),
|
? Colors.green
|
||||||
|
: (isCurrent ? Colors.black : Colors.grey[300]),
|
||||||
|
child: isDone
|
||||||
|
? const Icon(Icons.check, size: 14, color: Colors.white)
|
||||||
|
: Text(
|
||||||
|
'$step',
|
||||||
|
style: TextStyle(
|
||||||
|
color: isCurrent ? Colors.white : Colors.black54,
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(label, style: TextStyle(fontSize: 9, color: isCurrent ? Colors.black : Colors.grey, fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal)),
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 9,
|
||||||
|
color: isCurrent ? Colors.black : Colors.grey,
|
||||||
|
fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -420,7 +456,6 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
Text(
|
Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.signup.agreement.title',
|
'msg.userfront.signup.agreement.title',
|
||||||
fallback: '서비스 이용을 위해\n약관에 동의해주세요',
|
|
||||||
),
|
),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
@@ -438,10 +473,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
),
|
),
|
||||||
child: CheckboxListTile(
|
child: CheckboxListTile(
|
||||||
title: Text(
|
title: Text(
|
||||||
tr(
|
tr('ui.userfront.signup.agreement.all'),
|
||||||
'ui.userfront.signup.agreement.all',
|
|
||||||
fallback: '모두 동의합니다',
|
|
||||||
),
|
|
||||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
|
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
value: _termsAccepted && _privacyAccepted,
|
value: _termsAccepted && _privacyAccepted,
|
||||||
@@ -459,7 +491,6 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
_agreementSection(
|
_agreementSection(
|
||||||
title: tr(
|
title: tr(
|
||||||
'ui.userfront.signup.agreement.tos_title',
|
'ui.userfront.signup.agreement.tos_title',
|
||||||
fallback: '바론 소프트웨어 이용약관 (필수)',
|
|
||||||
),
|
),
|
||||||
content: _tosText,
|
content: _tosText,
|
||||||
value: _termsAccepted,
|
value: _termsAccepted,
|
||||||
@@ -469,7 +500,6 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
_agreementSection(
|
_agreementSection(
|
||||||
title: tr(
|
title: tr(
|
||||||
'ui.userfront.signup.agreement.privacy_title',
|
'ui.userfront.signup.agreement.privacy_title',
|
||||||
fallback: '개인정보 수집 및 이용 동의 (필수)',
|
|
||||||
),
|
),
|
||||||
content: _privacyText,
|
content: _privacyText,
|
||||||
value: _privacyAccepted,
|
value: _privacyAccepted,
|
||||||
@@ -488,8 +518,10 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
CheckboxListTile(
|
CheckboxListTile(
|
||||||
title: Text(title,
|
title: Text(
|
||||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
title,
|
||||||
|
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
value: value,
|
value: value,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
controlAffinity: ListTileControlAffinity.leading,
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
@@ -508,7 +540,11 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: Text(
|
child: Text(
|
||||||
content,
|
content,
|
||||||
style: const TextStyle(fontSize: 12, color: Colors.grey, height: 1.5),
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -517,8 +553,8 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static String get _tosText => tr(
|
static String get _tosText => tr(
|
||||||
'msg.userfront.signup.tos_full',
|
'msg.userfront.signup.tos_full',
|
||||||
fallback: """
|
fallback: """
|
||||||
바론 소프트웨어 이용약관
|
바론 소프트웨어 이용약관
|
||||||
|
|
||||||
제1장 총칙
|
제1장 총칙
|
||||||
@@ -589,11 +625,11 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
부칙
|
부칙
|
||||||
본 약관은 2024년 10월 1일부터 시행됩니다.
|
본 약관은 2024년 10월 1일부터 시행됩니다.
|
||||||
""",
|
""",
|
||||||
);
|
);
|
||||||
|
|
||||||
static String get _privacyText => tr(
|
static String get _privacyText => tr(
|
||||||
'msg.userfront.signup.privacy_full',
|
'msg.userfront.signup.privacy_full',
|
||||||
fallback: """
|
fallback: """
|
||||||
개인정보 수집 및 이용 동의
|
개인정보 수집 및 이용 동의
|
||||||
|
|
||||||
바론서비스 개인정보처리방침
|
바론서비스 개인정보처리방침
|
||||||
@@ -702,7 +738,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
제8조 (기타)
|
제8조 (기타)
|
||||||
본 방침에 명시되지 않은 사항은 회사의 내부 방침과 관련 법령에 따릅니다.
|
본 방침에 명시되지 않은 사항은 회사의 내부 방침과 관련 법령에 따릅니다.
|
||||||
""",
|
""",
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _buildStepAuth() {
|
Widget _buildStepAuth() {
|
||||||
return Column(
|
return Column(
|
||||||
@@ -711,7 +747,6 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
Text(
|
Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.signup.auth.title',
|
'msg.userfront.signup.auth.title',
|
||||||
fallback: '본인 확인을 위해\n인증을 진행해주세요',
|
|
||||||
),
|
),
|
||||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
@@ -719,7 +754,10 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
// 가족사 이메일 안내 문구
|
// 가족사 이메일 안내 문구
|
||||||
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: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.info_outline, size: 16, color: Colors.blue),
|
const Icon(Icons.info_outline, size: 16, color: Colors.blue),
|
||||||
@@ -728,9 +766,12 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
child: Text(
|
child: Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.signup.auth.affiliate_notice',
|
'msg.userfront.signup.auth.affiliate_notice',
|
||||||
fallback: '가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요.',
|
|
||||||
),
|
),
|
||||||
style: const TextStyle(fontSize: 12, color: Colors.blue, fontWeight: FontWeight.w500),
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.blue,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -738,7 +779,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
tr('ui.userfront.signup.auth.email.title', fallback: '이메일 인증'),
|
tr('ui.userfront.signup.auth.email.title'),
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -751,7 +792,6 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: tr(
|
labelText: tr(
|
||||||
'ui.userfront.signup.auth.email.label',
|
'ui.userfront.signup.auth.email.label',
|
||||||
fallback: '이메일 주소',
|
|
||||||
),
|
),
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
errorText: _emailError,
|
errorText: _emailError,
|
||||||
@@ -764,14 +804,19 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
height: 55,
|
height: 55,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: (_isEmailVerified || _isLoading) ? null : _sendEmailCode,
|
onPressed: (_isEmailVerified || _isLoading)
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[100], foregroundColor: Colors.black, elevation: 0),
|
? null
|
||||||
|
: _sendEmailCode,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.grey[100],
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
_emailSeconds > 0
|
_emailSeconds > 0
|
||||||
? tr('ui.common.resend', fallback: '재발송')
|
? tr('ui.common.resend')
|
||||||
: tr(
|
: tr(
|
||||||
'ui.userfront.signup.auth.request_code',
|
'ui.userfront.signup.auth.request_code',
|
||||||
fallback: '인증요청',
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -785,14 +830,18 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: tr(
|
labelText: tr(
|
||||||
'ui.userfront.signup.auth.code_label',
|
'ui.userfront.signup.auth.code_label',
|
||||||
fallback: '인증코드 6자리',
|
|
||||||
),
|
),
|
||||||
suffixText: _formatTime(_emailSeconds),
|
suffixText: _formatTime(_emailSeconds),
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)],
|
inputFormatters: [
|
||||||
onChanged: (val) { if(val.length == 6) _verifyEmailCode(); },
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
LengthLimitingTextInputFormatter(6),
|
||||||
|
],
|
||||||
|
onChanged: (val) {
|
||||||
|
if (val.length == 6) _verifyEmailCode();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (_isEmailVerified)
|
if (_isEmailVerified)
|
||||||
@@ -801,7 +850,6 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
child: Text(
|
child: Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.signup.email.verified',
|
'msg.userfront.signup.email.verified',
|
||||||
fallback: '✅ 이메일 인증 완료',
|
|
||||||
),
|
),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.green,
|
color: Colors.green,
|
||||||
@@ -812,7 +860,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
tr('ui.userfront.signup.phone.title', fallback: '휴대폰 인증'),
|
tr('ui.userfront.signup.phone.title'),
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -824,7 +872,6 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: tr(
|
labelText: tr(
|
||||||
'ui.userfront.signup.phone.label',
|
'ui.userfront.signup.phone.label',
|
||||||
fallback: '휴대폰 번호 (-없이)',
|
|
||||||
),
|
),
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
errorText: _phoneError,
|
errorText: _phoneError,
|
||||||
@@ -837,14 +884,19 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
height: 55,
|
height: 55,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: (_isPhoneVerified || _isLoading) ? null : _sendPhoneCode,
|
onPressed: (_isPhoneVerified || _isLoading)
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[100], foregroundColor: Colors.black, elevation: 0),
|
? null
|
||||||
|
: _sendPhoneCode,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.grey[100],
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
_phoneSeconds > 0
|
_phoneSeconds > 0
|
||||||
? tr('ui.common.resend', fallback: '재발송')
|
? tr('ui.common.resend')
|
||||||
: tr(
|
: tr(
|
||||||
'ui.userfront.signup.auth.request_code',
|
'ui.userfront.signup.auth.request_code',
|
||||||
fallback: '인증요청',
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -858,14 +910,18 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: tr(
|
labelText: tr(
|
||||||
'ui.userfront.signup.auth.code_label',
|
'ui.userfront.signup.auth.code_label',
|
||||||
fallback: '인증코드 6자리',
|
|
||||||
),
|
),
|
||||||
suffixText: _formatTime(_phoneSeconds),
|
suffixText: _formatTime(_phoneSeconds),
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)],
|
inputFormatters: [
|
||||||
onChanged: (val) { if(val.length == 6) _verifyPhoneCode(); },
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
LengthLimitingTextInputFormatter(6),
|
||||||
|
],
|
||||||
|
onChanged: (val) {
|
||||||
|
if (val.length == 6) _verifyPhoneCode();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (_isPhoneVerified)
|
if (_isPhoneVerified)
|
||||||
@@ -874,7 +930,6 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
child: Text(
|
child: Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.signup.phone.verified',
|
'msg.userfront.signup.phone.verified',
|
||||||
fallback: '✅ 휴대폰 인증 완료',
|
|
||||||
),
|
),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.green,
|
color: Colors.green,
|
||||||
@@ -894,7 +949,6 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
Text(
|
Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.signup.profile.title',
|
'msg.userfront.signup.profile.title',
|
||||||
fallback: '회원님의\n소속 정보를 알려주세요',
|
|
||||||
),
|
),
|
||||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
@@ -903,10 +957,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
controller: _nameController,
|
controller: _nameController,
|
||||||
onChanged: (_) => setState(() {}),
|
onChanged: (_) => setState(() {}),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: tr(
|
labelText: tr('ui.userfront.signup.profile.name'),
|
||||||
'ui.userfront.signup.profile.name',
|
|
||||||
fallback: '이름',
|
|
||||||
),
|
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -922,13 +973,11 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: tr(
|
labelText: tr(
|
||||||
'ui.userfront.signup.profile.affiliation_type',
|
'ui.userfront.signup.profile.affiliation_type',
|
||||||
fallback: '소속 유형',
|
|
||||||
),
|
),
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
helperText: _isAffiliateEmail
|
helperText: _isAffiliateEmail
|
||||||
? tr(
|
? tr(
|
||||||
'msg.userfront.signup.profile.affiliate_hint',
|
'msg.userfront.signup.profile.affiliate_hint',
|
||||||
fallback: '가족사 이메일 사용 시 자동으로 선택됩니다.',
|
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
@@ -936,19 +985,13 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'GENERAL',
|
value: 'GENERAL',
|
||||||
child: Text(
|
child: Text(
|
||||||
tr(
|
tr('domain.affiliation.general'),
|
||||||
'domain.affiliation.general',
|
|
||||||
fallback: '일반 사용자',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'AFFILIATE',
|
value: 'AFFILIATE',
|
||||||
child: Text(
|
child: Text(
|
||||||
tr(
|
tr('domain.affiliation.affiliate'),
|
||||||
'domain.affiliation.affiliate',
|
|
||||||
fallback: '가족사 임직원',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -978,46 +1021,33 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: tr(
|
labelText: tr(
|
||||||
'ui.userfront.signup.profile.company',
|
'ui.userfront.signup.profile.company',
|
||||||
fallback: '가족사 선택',
|
|
||||||
),
|
),
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
items: [
|
items: [
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'HANMAC',
|
value: 'HANMAC',
|
||||||
child: Text(
|
child: Text(tr('domain.company.hanmac')),
|
||||||
tr('domain.company.hanmac', fallback: '한맥'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'SAMAN',
|
value: 'SAMAN',
|
||||||
child: Text(
|
child: Text(tr('domain.company.saman')),
|
||||||
tr('domain.company.saman', fallback: '삼안'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'PTC',
|
value: 'PTC',
|
||||||
child: Text(
|
child: Text(tr('domain.company.ptc', fallback: 'PTC')),
|
||||||
tr('domain.company.ptc', fallback: 'PTC'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'JANGHEON',
|
value: 'JANGHEON',
|
||||||
child: Text(
|
child: Text(tr('domain.company.jangheon')),
|
||||||
tr('domain.company.jangheon', fallback: '장헌'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'BARON',
|
value: 'BARON',
|
||||||
child: Text(
|
child: Text(tr('domain.company.baron')),
|
||||||
tr('domain.company.baron', fallback: '바론'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'HALLA',
|
value: 'HALLA',
|
||||||
child: Text(
|
child: Text(tr('domain.company.halla')),
|
||||||
tr('domain.company.halla', fallback: '한라'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
onChanged: _isAffiliateEmail
|
onChanged: _isAffiliateEmail
|
||||||
@@ -1033,12 +1063,11 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
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')
|
||||||
: tr(
|
: tr(
|
||||||
'ui.userfront.signup.profile.department_optional',
|
'ui.userfront.signup.profile.department_optional',
|
||||||
fallback: '소속 정보 (선택)',
|
|
||||||
),
|
),
|
||||||
border: const OutlineInputBorder()
|
border: const OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1049,7 +1078,6 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
if (_isPolicyLoading) {
|
if (_isPolicyLoading) {
|
||||||
return tr(
|
return tr(
|
||||||
'msg.userfront.signup.policy.loading',
|
'msg.userfront.signup.policy.loading',
|
||||||
fallback: '비밀번호 정책을 불러오는 중입니다...',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final minLength = (_policy?['minLength'] as int?) ?? 12;
|
final minLength = (_policy?['minLength'] as int?) ?? 12;
|
||||||
@@ -1062,7 +1090,6 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
final parts = <String>[
|
final parts = <String>[
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.signup.policy.min_length',
|
'msg.userfront.signup.policy.min_length',
|
||||||
fallback: '최소 {{count}}자 이상',
|
|
||||||
params: {'count': minLength.toString()},
|
params: {'count': minLength.toString()},
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
@@ -1070,47 +1097,25 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
parts.add(
|
parts.add(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.signup.policy.min_types',
|
'msg.userfront.signup.policy.min_types',
|
||||||
fallback: '영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상',
|
|
||||||
params: {'count': minTypes.toString()},
|
params: {'count': minTypes.toString()},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (requiresUpper) {
|
if (requiresUpper) {
|
||||||
parts.add(
|
parts.add(tr('msg.userfront.signup.policy.uppercase'));
|
||||||
tr(
|
|
||||||
'msg.userfront.signup.policy.uppercase',
|
|
||||||
fallback: '대문자',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (requiresLower) {
|
if (requiresLower) {
|
||||||
parts.add(
|
parts.add(tr('msg.userfront.signup.policy.lowercase'));
|
||||||
tr(
|
|
||||||
'msg.userfront.signup.policy.lowercase',
|
|
||||||
fallback: '소문자',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (requiresNumber) {
|
if (requiresNumber) {
|
||||||
parts.add(
|
parts.add(tr('msg.userfront.signup.policy.number'));
|
||||||
tr(
|
|
||||||
'msg.userfront.signup.policy.number',
|
|
||||||
fallback: '숫자',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (requiresSymbol) {
|
if (requiresSymbol) {
|
||||||
parts.add(
|
parts.add(tr('msg.userfront.signup.policy.symbol'));
|
||||||
tr(
|
|
||||||
'msg.userfront.signup.policy.symbol',
|
|
||||||
fallback: '특수문자',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return tr(
|
return tr(
|
||||||
'msg.userfront.signup.policy.summary',
|
'msg.userfront.signup.policy.summary',
|
||||||
fallback: '보안 정책: {{rules}}',
|
|
||||||
params: {'rules': parts.join(', ')},
|
params: {'rules': parts.join(', ')},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1144,7 +1149,6 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
Text(
|
Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.signup.password.title',
|
'msg.userfront.signup.password.title',
|
||||||
fallback: '마지막으로\n비밀번호를 설정해주세요',
|
|
||||||
),
|
),
|
||||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
@@ -1152,7 +1156,10 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
// 비밀번호 정책 안내 박스
|
// 비밀번호 정책 안내 박스
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(color: Colors.blue[50], borderRadius: BorderRadius.circular(8)),
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blue[50],
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.security, size: 18, color: Colors.blue),
|
const Icon(Icons.security, size: 18, color: Colors.blue),
|
||||||
@@ -1160,7 +1167,11 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
_buildPolicyDescription(),
|
_buildPolicyDescription(),
|
||||||
style: TextStyle(fontSize: 12, color: Colors.blue[800], fontWeight: FontWeight.w500),
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.blue[800],
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1174,7 +1185,6 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: tr(
|
labelText: tr(
|
||||||
'ui.userfront.signup.password.label',
|
'ui.userfront.signup.password.label',
|
||||||
fallback: '비밀번호',
|
|
||||||
),
|
),
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
errorText: _passwordError,
|
errorText: _passwordError,
|
||||||
@@ -1187,7 +1197,6 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
_cryptoCheck(
|
_cryptoCheck(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.signup.password.rule.min_length',
|
'msg.userfront.signup.password.rule.min_length',
|
||||||
fallback: '{{count}}자 이상',
|
|
||||||
params: {'count': minLength.toString()},
|
params: {'count': minLength.toString()},
|
||||||
),
|
),
|
||||||
hasLength,
|
hasLength,
|
||||||
@@ -1196,7 +1205,6 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
_cryptoCheck(
|
_cryptoCheck(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.signup.password.rule.min_types',
|
'msg.userfront.signup.password.rule.min_types',
|
||||||
fallback: '문자 유형 {{count}}가지 이상',
|
|
||||||
params: {'count': minTypes.toString()},
|
params: {'count': minTypes.toString()},
|
||||||
),
|
),
|
||||||
hasTypeCount,
|
hasTypeCount,
|
||||||
@@ -1205,7 +1213,6 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
_cryptoCheck(
|
_cryptoCheck(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.signup.password.rule.uppercase',
|
'msg.userfront.signup.password.rule.uppercase',
|
||||||
fallback: '대문자',
|
|
||||||
),
|
),
|
||||||
hasUpper,
|
hasUpper,
|
||||||
),
|
),
|
||||||
@@ -1213,23 +1220,18 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
_cryptoCheck(
|
_cryptoCheck(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.signup.password.rule.lowercase',
|
'msg.userfront.signup.password.rule.lowercase',
|
||||||
fallback: '소문자',
|
|
||||||
),
|
),
|
||||||
hasLower,
|
hasLower,
|
||||||
),
|
),
|
||||||
if (requiresNumber)
|
if (requiresNumber)
|
||||||
_cryptoCheck(
|
_cryptoCheck(
|
||||||
tr(
|
tr('msg.userfront.signup.password.rule.number'),
|
||||||
'msg.userfront.signup.password.rule.number',
|
|
||||||
fallback: '숫자',
|
|
||||||
),
|
|
||||||
hasDigit,
|
hasDigit,
|
||||||
),
|
),
|
||||||
if (requiresSymbol)
|
if (requiresSymbol)
|
||||||
_cryptoCheck(
|
_cryptoCheck(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.signup.password.rule.symbol',
|
'msg.userfront.signup.password.rule.symbol',
|
||||||
fallback: '특수문자',
|
|
||||||
),
|
),
|
||||||
hasSpecial,
|
hasSpecial,
|
||||||
),
|
),
|
||||||
@@ -1244,7 +1246,6 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
_confirmPasswordError = (val != _passwordController.text)
|
_confirmPasswordError = (val != _passwordController.text)
|
||||||
? tr(
|
? tr(
|
||||||
'msg.userfront.signup.password.mismatch',
|
'msg.userfront.signup.password.mismatch',
|
||||||
fallback: '비밀번호가 일치하지 않습니다.',
|
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
});
|
});
|
||||||
@@ -1252,7 +1253,6 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: tr(
|
labelText: tr(
|
||||||
'ui.userfront.signup.password.confirm_label',
|
'ui.userfront.signup.password.confirm_label',
|
||||||
fallback: '비밀번호 확인',
|
|
||||||
),
|
),
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
errorText: _confirmPasswordError,
|
errorText: _confirmPasswordError,
|
||||||
@@ -1266,9 +1266,19 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
return Row(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(isValid ? Icons.check_circle : Icons.circle_outlined, size: 14, color: isValid ? Colors.green : Colors.grey),
|
Icon(
|
||||||
|
isValid ? Icons.check_circle : Icons.circle_outlined,
|
||||||
|
size: 14,
|
||||||
|
color: isValid ? Colors.green : Colors.grey,
|
||||||
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(label, style: TextStyle(fontSize: 11, color: isValid ? Colors.green : Colors.grey)),
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: isValid ? Colors.green : Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1276,8 +1286,10 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
bool canGoNext = false;
|
bool canGoNext = false;
|
||||||
if (_currentStep == 1 && _termsAccepted && _privacyAccepted) canGoNext = true;
|
if (_currentStep == 1 && _termsAccepted && _privacyAccepted)
|
||||||
if (_currentStep == 2 && _isEmailVerified && _isPhoneVerified) canGoNext = true;
|
canGoNext = true;
|
||||||
|
if (_currentStep == 2 && _isEmailVerified && _isPhoneVerified)
|
||||||
|
canGoNext = true;
|
||||||
if (_currentStep == 3) {
|
if (_currentStep == 3) {
|
||||||
final nameOk = _nameController.text.trim().isNotEmpty;
|
final nameOk = _nameController.text.trim().isNotEmpty;
|
||||||
if (_affiliationType == 'GENERAL') {
|
if (_affiliationType == 'GENERAL') {
|
||||||
@@ -1294,7 +1306,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(
|
title: Text(
|
||||||
tr('ui.userfront.signup.title', fallback: '회원가입'),
|
tr('ui.userfront.signup.title'),
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
@@ -1314,10 +1326,12 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
child: Form(
|
child: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: _currentStep == 1
|
child: _currentStep == 1
|
||||||
? _buildStepAgreement()
|
? _buildStepAgreement()
|
||||||
: (_currentStep == 2
|
: (_currentStep == 2
|
||||||
? _buildStepAuth()
|
? _buildStepAuth()
|
||||||
: (_currentStep == 3 ? _buildStepInfo() : _buildStepPassword())),
|
: (_currentStep == 3
|
||||||
|
? _buildStepInfo()
|
||||||
|
: _buildStepPassword())),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1329,9 +1343,12 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
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: Text(
|
child: Text(
|
||||||
tr('ui.common.prev', fallback: '이전'),
|
tr('ui.common.prev'),
|
||||||
style: const TextStyle(color: Colors.black),
|
style: const TextStyle(color: Colors.black),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1341,19 +1358,32 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: FilledButton(
|
child: FilledButton(
|
||||||
onPressed: _currentStep < 4
|
onPressed: _currentStep < 4
|
||||||
? (canGoNext ? () => setState(() => _currentStep++) : null)
|
? (canGoNext
|
||||||
: (_isLoading ? null : _handleSignup),
|
? () => setState(() => _currentStep++)
|
||||||
|
: null)
|
||||||
|
: (_isLoading ? null : _handleSignup),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
minimumSize: const Size.fromHeight(55),
|
minimumSize: const Size.fromHeight(55),
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
),
|
),
|
||||||
child: _isLoading
|
child: _isLoading
|
||||||
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
? const SizedBox(
|
||||||
: Text(
|
height: 20,
|
||||||
_currentStep < 4
|
width: 20,
|
||||||
? tr('ui.userfront.signup.next_step', fallback: '다음 단계')
|
child: CircularProgressIndicator(
|
||||||
: tr('ui.userfront.signup.complete', fallback: '가입 완료'),
|
color: Colors.white,
|
||||||
),
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
_currentStep < 4
|
||||||
|
? tr(
|
||||||
|
'ui.userfront.signup.next_step',
|
||||||
|
)
|
||||||
|
: tr(
|
||||||
|
'ui.userfront.signup.complete',
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -181,7 +181,6 @@ class AuthTimelineNotifier extends Notifier<AuthTimelineState> {
|
|||||||
isLoadingMore: false,
|
isLoadingMore: false,
|
||||||
error: tr(
|
error: tr(
|
||||||
'msg.userfront.dashboard.timeline.load_error',
|
'msg.userfront.dashboard.timeline.load_error',
|
||||||
fallback: '접속이력을 불러오지 못했습니다.',
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,7 @@ class ProfileRepository {
|
|||||||
final useCookie = AuthTokenStore.usesCookie();
|
final useCookie = AuthTokenStore.usesCookie();
|
||||||
if (token == null && !useCookie) {
|
if (token == null && !useCookie) {
|
||||||
throw Exception(
|
throw Exception(
|
||||||
tr('err.userfront.session.missing', fallback: '활성 세션이 없습니다.'),
|
tr('err.userfront.session.missing'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +46,6 @@ class ProfileRepository {
|
|||||||
throw Exception(
|
throw Exception(
|
||||||
tr(
|
tr(
|
||||||
'err.userfront.profile.load_failed',
|
'err.userfront.profile.load_failed',
|
||||||
fallback: '프로필을 불러오지 못했습니다: {{error}}',
|
|
||||||
params: {'error': response.body},
|
params: {'error': response.body},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -62,7 +61,7 @@ class ProfileRepository {
|
|||||||
final useCookie = AuthTokenStore.usesCookie();
|
final useCookie = AuthTokenStore.usesCookie();
|
||||||
if (token == null && !useCookie) {
|
if (token == null && !useCookie) {
|
||||||
throw Exception(
|
throw Exception(
|
||||||
tr('err.userfront.session.missing', fallback: '활성 세션이 없습니다.'),
|
tr('err.userfront.session.missing'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +88,6 @@ class ProfileRepository {
|
|||||||
throw Exception(
|
throw Exception(
|
||||||
tr(
|
tr(
|
||||||
'err.userfront.profile.update_failed',
|
'err.userfront.profile.update_failed',
|
||||||
fallback: '프로필 업데이트에 실패했습니다: {{error}}',
|
|
||||||
params: {'error': response.body},
|
params: {'error': response.body},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -101,7 +99,7 @@ class ProfileRepository {
|
|||||||
final useCookie = AuthTokenStore.usesCookie();
|
final useCookie = AuthTokenStore.usesCookie();
|
||||||
if (token == null && !useCookie) {
|
if (token == null && !useCookie) {
|
||||||
throw Exception(
|
throw Exception(
|
||||||
tr('err.userfront.session.missing', fallback: '활성 세션이 없습니다.'),
|
tr('err.userfront.session.missing'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +122,6 @@ class ProfileRepository {
|
|||||||
throw Exception(
|
throw Exception(
|
||||||
tr(
|
tr(
|
||||||
'err.userfront.profile.send_code_failed',
|
'err.userfront.profile.send_code_failed',
|
||||||
fallback: '인증번호 전송 실패: {{error}}',
|
|
||||||
params: {'error': response.body},
|
params: {'error': response.body},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -139,7 +136,7 @@ class ProfileRepository {
|
|||||||
final useCookie = AuthTokenStore.usesCookie();
|
final useCookie = AuthTokenStore.usesCookie();
|
||||||
if (token == null && !useCookie) {
|
if (token == null && !useCookie) {
|
||||||
throw Exception(
|
throw Exception(
|
||||||
tr('err.userfront.session.missing', fallback: '활성 세션이 없습니다.'),
|
tr('err.userfront.session.missing'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +162,6 @@ class ProfileRepository {
|
|||||||
throw Exception(
|
throw Exception(
|
||||||
tr(
|
tr(
|
||||||
'err.userfront.profile.password_change_failed',
|
'err.userfront.profile.password_change_failed',
|
||||||
fallback: '비밀번호 변경에 실패했습니다: {{error}}',
|
|
||||||
params: {'error': response.body},
|
params: {'error': response.body},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -177,7 +173,7 @@ class ProfileRepository {
|
|||||||
final useCookie = AuthTokenStore.usesCookie();
|
final useCookie = AuthTokenStore.usesCookie();
|
||||||
if (token == null && !useCookie) {
|
if (token == null && !useCookie) {
|
||||||
throw Exception(
|
throw Exception(
|
||||||
tr('err.userfront.session.missing', fallback: '활성 세션이 없습니다.'),
|
tr('err.userfront.session.missing'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,7 +196,6 @@ class ProfileRepository {
|
|||||||
throw Exception(
|
throw Exception(
|
||||||
tr(
|
tr(
|
||||||
'err.userfront.profile.verify_code_failed',
|
'err.userfront.profile.verify_code_failed',
|
||||||
fallback: '인증 실패: {{error}}',
|
|
||||||
params: {'error': response.body},
|
params: {'error': response.body},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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';
|
||||||
|
import '../../../../core/widgets/language_selector.dart';
|
||||||
import '../../data/models/user_profile_model.dart';
|
import '../../data/models/user_profile_model.dart';
|
||||||
import '../../domain/notifiers/profile_notifier.dart';
|
import '../../domain/notifiers/profile_notifier.dart';
|
||||||
|
|
||||||
@@ -140,7 +141,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
if (_editingField != 'name' && _nameController!.text != profile.name) {
|
if (_editingField != 'name' && _nameController!.text != profile.name) {
|
||||||
_nameController!.text = profile.name;
|
_nameController!.text = profile.name;
|
||||||
}
|
}
|
||||||
if (_editingField != 'department' && _departmentController!.text != profile.department) {
|
if (_editingField != 'department' &&
|
||||||
|
_departmentController!.text != profile.department) {
|
||||||
_departmentController!.text = profile.department;
|
_departmentController!.text = profile.department;
|
||||||
}
|
}
|
||||||
if (_editingField != 'phone' && _phoneController!.text != profile.phone) {
|
if (_editingField != 'phone' && _phoneController!.text != profile.phone) {
|
||||||
@@ -234,7 +236,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
content: Text(
|
content: Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.profile.phone.code_sent',
|
'msg.userfront.profile.phone.code_sent',
|
||||||
fallback: '인증번호가 전송되었습니다.',
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -248,7 +249,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
content: Text(
|
content: Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.profile.phone.send_failed',
|
'msg.userfront.profile.phone.send_failed',
|
||||||
fallback: '전송 실패: {{error}}',
|
|
||||||
params: {'error': e.toString()},
|
params: {'error': e.toString()},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -274,10 +274,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
tr(
|
tr('msg.userfront.profile.phone.verified'),
|
||||||
'msg.userfront.profile.phone.verified',
|
|
||||||
fallback: '인증되었습니다.',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -293,7 +290,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
content: Text(
|
content: Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.profile.phone.verify_failed',
|
'msg.userfront.profile.phone.verify_failed',
|
||||||
fallback: '인증 실패: {{error}}',
|
|
||||||
params: {'error': e.toString()},
|
params: {'error': e.toString()},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -310,24 +306,27 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
final confirmPassword = _confirmPasswordController?.text.trim() ?? '';
|
final confirmPassword = _confirmPasswordController?.text.trim() ?? '';
|
||||||
|
|
||||||
if (currentPassword.isEmpty) {
|
if (currentPassword.isEmpty) {
|
||||||
setState(() => _passwordError = tr(
|
setState(
|
||||||
'msg.userfront.profile.password.current_required',
|
() => _passwordError = tr(
|
||||||
fallback: '현재 비밀번호를 입력해 주세요.',
|
'msg.userfront.profile.password.current_required',
|
||||||
));
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (newPassword.isEmpty) {
|
if (newPassword.isEmpty) {
|
||||||
setState(() => _passwordError = tr(
|
setState(
|
||||||
'msg.userfront.profile.password.new_required',
|
() => _passwordError = tr(
|
||||||
fallback: '새 비밀번호를 입력해 주세요.',
|
'msg.userfront.profile.password.new_required',
|
||||||
));
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (newPassword != confirmPassword) {
|
if (newPassword != confirmPassword) {
|
||||||
setState(() => _passwordError = tr(
|
setState(
|
||||||
'msg.userfront.profile.password.mismatch',
|
() => _passwordError = tr(
|
||||||
fallback: '새 비밀번호가 일치하지 않습니다.',
|
'msg.userfront.profile.password.mismatch',
|
||||||
));
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,7 +337,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ref.read(profileRepositoryProvider).changePassword(
|
await ref
|
||||||
|
.read(profileRepositoryProvider)
|
||||||
|
.changePassword(
|
||||||
currentPassword: currentPassword,
|
currentPassword: currentPassword,
|
||||||
newPassword: newPassword,
|
newPassword: newPassword,
|
||||||
);
|
);
|
||||||
@@ -348,7 +349,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_passwordSuccess = tr(
|
_passwordSuccess = tr(
|
||||||
'msg.userfront.profile.password.changed',
|
'msg.userfront.profile.password.changed',
|
||||||
fallback: '비밀번호가 변경되었습니다.',
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -356,7 +356,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_passwordError = tr(
|
_passwordError = tr(
|
||||||
'msg.userfront.profile.password.change_failed',
|
'msg.userfront.profile.password.change_failed',
|
||||||
fallback: '비밀번호 변경 실패: {{error}}',
|
|
||||||
params: {'error': message},
|
params: {'error': message},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -434,10 +433,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
tr(
|
tr('msg.userfront.profile.name_required'),
|
||||||
'msg.userfront.profile.name_required',
|
|
||||||
fallback: '이름을 입력해주세요.',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -449,7 +445,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
content: Text(
|
content: Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.profile.department_required',
|
'msg.userfront.profile.department_required',
|
||||||
fallback: '소속을 입력해주세요.',
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -463,7 +458,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
content: Text(
|
content: Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.profile.phone_required',
|
'msg.userfront.profile.phone_required',
|
||||||
fallback: '휴대폰 번호를 입력해주세요.',
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -476,7 +470,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
content: Text(
|
content: Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.profile.phone_verify_required',
|
'msg.userfront.profile.phone_verify_required',
|
||||||
fallback: '휴대폰 번호 인증이 필요합니다.',
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -500,7 +493,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
_isSavingField = true;
|
_isSavingField = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ref.read(profileProvider.notifier).updateProfile(
|
await ref
|
||||||
|
.read(profileProvider.notifier)
|
||||||
|
.updateProfile(
|
||||||
name: nextName,
|
name: nextName,
|
||||||
phone: nextPhone,
|
phone: nextPhone,
|
||||||
department: nextDepartment,
|
department: nextDepartment,
|
||||||
@@ -520,7 +515,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
content: Text(
|
content: Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.profile.update_success',
|
'msg.userfront.profile.update_success',
|
||||||
fallback: '정보가 수정되었습니다.',
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -533,7 +527,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
content: Text(
|
content: Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.profile.update_failed',
|
'msg.userfront.profile.update_failed',
|
||||||
fallback: '수정 실패: {{error}}',
|
|
||||||
params: {'error': e.toString()},
|
params: {'error': e.toString()},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -546,38 +539,40 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSideMenu(BuildContext context) {
|
Widget _buildSideMenu(BuildContext context) {
|
||||||
return ListView(
|
return Column(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
Expanded(
|
||||||
leading: const Icon(Icons.home_outlined),
|
child: ListView(
|
||||||
title: Text(
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
tr('ui.userfront.nav.dashboard', fallback: '대시보드'),
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.home_outlined),
|
||||||
|
title: Text(tr('ui.userfront.nav.dashboard')),
|
||||||
|
onTap: () => context.go('/'),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.person_outline),
|
||||||
|
title: Text(tr('ui.userfront.nav.profile')),
|
||||||
|
selected: true,
|
||||||
|
onTap: () => context.go('/profile'),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.qr_code_scanner),
|
||||||
|
title: Text(tr('ui.userfront.nav.qr_scan')),
|
||||||
|
onTap: () => context.go('/scan'),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.logout),
|
||||||
|
title: Text(tr('ui.userfront.nav.logout')),
|
||||||
|
onTap: _logout,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
onTap: () => context.go('/'),
|
|
||||||
),
|
),
|
||||||
ListTile(
|
const Padding(
|
||||||
leading: const Icon(Icons.person_outline),
|
padding: EdgeInsets.only(bottom: 16),
|
||||||
title: Text(
|
child: LanguageSelector(compact: true),
|
||||||
tr('ui.userfront.nav.profile', fallback: '내 정보'),
|
|
||||||
),
|
|
||||||
selected: true,
|
|
||||||
onTap: () => context.go('/profile'),
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.qr_code_scanner),
|
|
||||||
title: Text(
|
|
||||||
tr('ui.userfront.nav.qr_scan', fallback: 'QR 스캔'),
|
|
||||||
),
|
|
||||||
onTap: () => context.go('/scan'),
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.logout),
|
|
||||||
title: Text(
|
|
||||||
tr('ui.userfront.nav.logout', fallback: '로그아웃'),
|
|
||||||
),
|
|
||||||
onTap: _logout,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -589,13 +584,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: _ink),
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: _ink,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Text(
|
Text(subtitle, style: TextStyle(fontSize: 13, color: Colors.grey[600])),
|
||||||
subtitle,
|
|
||||||
style: TextStyle(fontSize: 13, color: Colors.grey[600]),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -615,7 +611,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: const TextStyle(fontSize: 12, color: _ink, fontWeight: FontWeight.w600),
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: _ink,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -624,13 +624,13 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
|
|
||||||
Widget _buildHeaderCard(UserProfile profile) {
|
Widget _buildHeaderCard(UserProfile profile) {
|
||||||
final name = profile.name.isEmpty
|
final name = profile.name.isEmpty
|
||||||
? tr('msg.userfront.profile.name_missing', fallback: '이름 없음')
|
? tr('msg.userfront.profile.name_missing')
|
||||||
: profile.name;
|
: profile.name;
|
||||||
final email = profile.email.isEmpty
|
final email = profile.email.isEmpty
|
||||||
? tr('msg.userfront.profile.email_missing', fallback: '이메일 없음')
|
? tr('msg.userfront.profile.email_missing')
|
||||||
: profile.email;
|
: profile.email;
|
||||||
final department = profile.department.isEmpty
|
final department = profile.department.isEmpty
|
||||||
? tr('msg.userfront.profile.department_missing', fallback: '소속 정보 없음')
|
? tr('msg.userfront.profile.department_missing')
|
||||||
: profile.department;
|
: profile.department;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
@@ -650,10 +650,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const CircleAvatar(
|
const CircleAvatar(radius: 32, child: Icon(Icons.person, size: 32)),
|
||||||
radius: 32,
|
|
||||||
child: Icon(Icons.person, size: 32),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -662,7 +659,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
Text(
|
Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.profile.greeting',
|
'msg.userfront.profile.greeting',
|
||||||
fallback: '안녕하세요, {{name}}님',
|
|
||||||
params: {'name': name},
|
params: {'name': name},
|
||||||
),
|
),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
@@ -672,7 +668,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
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),
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
@@ -680,9 +679,12 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
children: [
|
children: [
|
||||||
_buildInfoChip(
|
_buildInfoChip(
|
||||||
Icons.badge_outlined,
|
Icons.badge_outlined,
|
||||||
tr('ui.userfront.profile.manage', fallback: '프로필 관리'),
|
tr('ui.userfront.profile.manage'),
|
||||||
|
),
|
||||||
|
_buildInfoChip(
|
||||||
|
Icons.apartment,
|
||||||
|
profile.tenant?.name ?? department,
|
||||||
),
|
),
|
||||||
_buildInfoChip(Icons.apartment, profile.tenant?.name ?? department),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -720,7 +722,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: '읽기 전용'),
|
tr('ui.common.read_only'),
|
||||||
style: TextStyle(color: Colors.grey[500], fontSize: 12),
|
style: TextStyle(color: Colors.grey[500], fontSize: 12),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -744,7 +746,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: Text(tr('ui.common.edit', fallback: '수정')),
|
child: Text(tr('ui.common.edit')),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -772,7 +774,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: Text(tr('ui.common.cancel', fallback: '취소')),
|
child: Text(tr('ui.common.cancel')),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -787,13 +789,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
if (!isEditing) {
|
if (!isEditing) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
title: Text(
|
title: Text(tr('ui.userfront.profile.phone.title')),
|
||||||
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: Text(tr('ui.common.edit', fallback: '수정')),
|
child: Text(tr('ui.common.edit')),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -802,7 +802,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
tr('ui.userfront.profile.phone.title', fallback: '전화번호'),
|
tr('ui.userfront.profile.phone.title'),
|
||||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -832,17 +832,16 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
onPressed: _isVerifying ? null : _sendCode,
|
onPressed: _isVerifying ? null : _sendCode,
|
||||||
child: Text(
|
child: Text(
|
||||||
_isCodeSent
|
_isCodeSent
|
||||||
? tr('ui.common.resend', fallback: '재전송')
|
? tr('ui.common.resend')
|
||||||
: tr(
|
: tr(
|
||||||
'ui.userfront.profile.phone.request_code',
|
'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: Text(tr('ui.common.cancel', fallback: '취소')),
|
child: Text(tr('ui.common.cancel')),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -862,7 +861,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
hintText: tr(
|
hintText: tr(
|
||||||
'ui.userfront.profile.phone.code_hint',
|
'ui.userfront.profile.phone.code_hint',
|
||||||
fallback: '인증번호 6자리',
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -870,7 +868,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: _isVerifying ? null : () => _verifyCode(profile),
|
onPressed: _isVerifying ? null : () => _verifyCode(profile),
|
||||||
child: Text(tr('ui.common.confirm', fallback: '확인')),
|
child: Text(tr('ui.common.confirm')),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -881,7 +879,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
child: Text(
|
child: Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.profile.phone.verify_notice',
|
'msg.userfront.profile.phone.verify_notice',
|
||||||
fallback: '휴대폰 번호를 변경하려면 SMS 인증이 필요합니다.',
|
|
||||||
),
|
),
|
||||||
style: const TextStyle(color: Colors.orange, fontSize: 12),
|
style: const TextStyle(color: Colors.orange, fontSize: 12),
|
||||||
),
|
),
|
||||||
@@ -896,14 +893,13 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
tr('ui.userfront.profile.password.title', fallback: '비밀번호 변경'),
|
tr('ui.userfront.profile.password.title'),
|
||||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.profile.password.subtitle',
|
'msg.userfront.profile.password.subtitle',
|
||||||
fallback: '현재 비밀번호 확인 후 새 비밀번호로 변경합니다.',
|
|
||||||
),
|
),
|
||||||
style: const TextStyle(color: Color(0xFF6B7280)),
|
style: const TextStyle(color: Color(0xFF6B7280)),
|
||||||
),
|
),
|
||||||
@@ -914,11 +910,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: tr(
|
labelText: tr(
|
||||||
'ui.userfront.profile.password.current',
|
'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,
|
||||||
|
),
|
||||||
onPressed: () => setState(() {
|
onPressed: () => setState(() {
|
||||||
_showCurrentPassword = !_showCurrentPassword;
|
_showCurrentPassword = !_showCurrentPassword;
|
||||||
}),
|
}),
|
||||||
@@ -932,11 +931,12 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: tr(
|
labelText: tr(
|
||||||
'ui.userfront.profile.password.new',
|
'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,
|
||||||
|
),
|
||||||
onPressed: () => setState(() {
|
onPressed: () => setState(() {
|
||||||
_showNewPassword = !_showNewPassword;
|
_showNewPassword = !_showNewPassword;
|
||||||
}),
|
}),
|
||||||
@@ -950,11 +950,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: tr(
|
labelText: tr(
|
||||||
'ui.userfront.profile.password.confirm',
|
'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,
|
||||||
|
),
|
||||||
onPressed: () => setState(() {
|
onPressed: () => setState(() {
|
||||||
_showConfirmPassword = !_showConfirmPassword;
|
_showConfirmPassword = !_showConfirmPassword;
|
||||||
}),
|
}),
|
||||||
@@ -963,10 +966,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
if (_passwordError != null) ...[
|
if (_passwordError != null) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(_passwordError!, style: const TextStyle(color: Colors.red)),
|
||||||
_passwordError!,
|
|
||||||
style: const TextStyle(color: Colors.red),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
if (_passwordSuccess != null) ...[
|
if (_passwordSuccess != null) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -989,7 +989,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
: Text(
|
: Text(
|
||||||
tr(
|
tr(
|
||||||
'ui.userfront.profile.password.change',
|
'ui.userfront.profile.password.change',
|
||||||
fallback: '비밀번호 변경',
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -999,7 +998,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
child: Text(
|
child: Text(
|
||||||
tr(
|
tr(
|
||||||
'ui.userfront.profile.password.forgot',
|
'ui.userfront.profile.password.forgot',
|
||||||
fallback: '비밀번호를 잊으셨나요?',
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1025,10 +1023,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
_buildHeaderCard(profile),
|
_buildHeaderCard(profile),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
_buildSectionTitle(
|
_buildSectionTitle(
|
||||||
tr('ui.userfront.profile.section.basic', fallback: '기본 정보'),
|
tr('ui.userfront.profile.section.basic'),
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.profile.section.basic',
|
'msg.userfront.profile.section.basic',
|
||||||
fallback: '계정 기본 정보를 관리합니다.',
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -1037,7 +1034,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
children: [
|
children: [
|
||||||
_buildEditableTile(
|
_buildEditableTile(
|
||||||
field: 'name',
|
field: 'name',
|
||||||
label: tr('ui.userfront.profile.field.name', fallback: '이름'),
|
label: tr(
|
||||||
|
'ui.userfront.profile.field.name',
|
||||||
|
),
|
||||||
value: profile.name,
|
value: profile.name,
|
||||||
profile: profile,
|
profile: profile,
|
||||||
isUpdating: isUpdating,
|
isUpdating: isUpdating,
|
||||||
@@ -1045,7 +1044,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
const Divider(height: 24),
|
const Divider(height: 24),
|
||||||
_buildReadOnlyTile(
|
_buildReadOnlyTile(
|
||||||
tr('ui.userfront.profile.field.email', fallback: '이메일'),
|
tr(
|
||||||
|
'ui.userfront.profile.field.email',
|
||||||
|
),
|
||||||
profile.email,
|
profile.email,
|
||||||
),
|
),
|
||||||
const Divider(height: 24),
|
const Divider(height: 24),
|
||||||
@@ -1055,10 +1056,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
_buildSectionTitle(
|
_buildSectionTitle(
|
||||||
tr('ui.userfront.profile.section.organization', fallback: '조직 정보'),
|
tr(
|
||||||
|
'ui.userfront.profile.section.organization',
|
||||||
|
),
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.profile.section.organization',
|
'msg.userfront.profile.section.organization',
|
||||||
fallback: '소속 및 구분 정보입니다.',
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -1067,7 +1069,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
children: [
|
children: [
|
||||||
_buildEditableTile(
|
_buildEditableTile(
|
||||||
field: 'department',
|
field: 'department',
|
||||||
label: tr('ui.userfront.profile.field.department', fallback: '소속'),
|
label: tr(
|
||||||
|
'ui.userfront.profile.field.department',
|
||||||
|
),
|
||||||
value: profile.department,
|
value: profile.department,
|
||||||
profile: profile,
|
profile: profile,
|
||||||
isUpdating: isUpdating,
|
isUpdating: isUpdating,
|
||||||
@@ -1075,7 +1079,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
const Divider(height: 24),
|
const Divider(height: 24),
|
||||||
_buildReadOnlyTile(
|
_buildReadOnlyTile(
|
||||||
tr('ui.userfront.profile.field.affiliation', fallback: '구분'),
|
tr(
|
||||||
|
'ui.userfront.profile.field.affiliation',
|
||||||
|
),
|
||||||
profile.affiliationType,
|
profile.affiliationType,
|
||||||
),
|
),
|
||||||
if (profile.tenant != null) ...[
|
if (profile.tenant != null) ...[
|
||||||
@@ -1083,7 +1089,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
_buildReadOnlyTile(
|
_buildReadOnlyTile(
|
||||||
tr(
|
tr(
|
||||||
'ui.userfront.profile.field.tenant',
|
'ui.userfront.profile.field.tenant',
|
||||||
fallback: '소속 테넌트',
|
|
||||||
),
|
),
|
||||||
profile.tenant!.name,
|
profile.tenant!.name,
|
||||||
),
|
),
|
||||||
@@ -1091,7 +1096,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
if (profile.companyCode.isNotEmpty) ...[
|
if (profile.companyCode.isNotEmpty) ...[
|
||||||
const Divider(height: 24),
|
const Divider(height: 24),
|
||||||
_buildReadOnlyTile(
|
_buildReadOnlyTile(
|
||||||
tr('ui.userfront.profile.field.company_code', fallback: '회사코드'),
|
tr(
|
||||||
|
'ui.userfront.profile.field.company_code',
|
||||||
|
),
|
||||||
profile.companyCode,
|
profile.companyCode,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1100,10 +1107,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
_buildSectionTitle(
|
_buildSectionTitle(
|
||||||
tr('ui.userfront.profile.section.security', fallback: '보안'),
|
tr('ui.userfront.profile.section.security'),
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.profile.section.security',
|
'msg.userfront.profile.section.security',
|
||||||
fallback: '비밀번호를 안전하게 관리합니다.',
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -1132,7 +1138,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
if (profile == null) {
|
if (profile == null) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(tr('ui.userfront.nav.profile', fallback: '내 정보')),
|
title: Text(tr('ui.userfront.nav.profile')),
|
||||||
),
|
),
|
||||||
body: profileState.isLoading
|
body: profileState.isLoading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
@@ -1143,13 +1149,13 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
Text(
|
Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.profile.load_failed',
|
'msg.userfront.profile.load_failed',
|
||||||
fallback: '정보를 불러올 수 없습니다.',
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => ref.read(profileProvider.notifier).loadProfile(),
|
onPressed: () =>
|
||||||
child: Text(tr('ui.common.retry', fallback: '재시도')),
|
ref.read(profileProvider.notifier).loadProfile(),
|
||||||
|
child: Text(tr('ui.common.retry')),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -1166,7 +1172,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
backgroundColor: _subtle,
|
backgroundColor: _subtle,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(
|
title: Text(
|
||||||
tr('ui.userfront.app_title', fallback: 'Baron 로그인'),
|
tr('ui.userfront.app_title'),
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
@@ -1175,17 +1181,17 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.home_outlined),
|
icon: const Icon(Icons.home_outlined),
|
||||||
tooltip: tr('ui.userfront.nav.dashboard', fallback: '대시보드'),
|
tooltip: tr('ui.userfront.nav.dashboard'),
|
||||||
onPressed: () => context.go('/'),
|
onPressed: () => context.go('/'),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.qr_code_scanner),
|
icon: const Icon(Icons.qr_code_scanner),
|
||||||
tooltip: tr('ui.userfront.nav.qr_scan', fallback: 'QR 스캔'),
|
tooltip: tr('ui.userfront.nav.qr_scan'),
|
||||||
onPressed: () => context.push('/scan'),
|
onPressed: () => context.push('/scan'),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.logout),
|
icon: const Icon(Icons.logout),
|
||||||
tooltip: tr('ui.userfront.nav.logout', fallback: '로그아웃'),
|
tooltip: tr('ui.userfront.nav.logout'),
|
||||||
onPressed: _logout,
|
onPressed: _logout,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1193,11 +1199,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
drawer: isWide ? null : Drawer(child: _buildSideMenu(context)),
|
drawer: isWide ? null : Drawer(child: _buildSideMenu(context)),
|
||||||
body: Row(
|
body: Row(
|
||||||
children: [
|
children: [
|
||||||
if (isWide)
|
if (isWide) SizedBox(width: 240, child: _buildSideMenu(context)),
|
||||||
SizedBox(
|
|
||||||
width: 240,
|
|
||||||
child: _buildSideMenu(context),
|
|
||||||
),
|
|
||||||
Expanded(child: _buildContent(profile, isUpdating)),
|
Expanded(child: _buildContent(profile, isUpdating)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,40 +1,18 @@
|
|||||||
import 'dart:ui';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
|
||||||
import 'i18n_data.dart';
|
final _koreanPattern = RegExp(r'[가-힣]');
|
||||||
|
|
||||||
const _defaultLocale = 'ko';
|
String tr(String key, {String? fallback, Map<String, String>? params}) {
|
||||||
const _supportedLocales = ['ko', 'en'];
|
try {
|
||||||
|
if (fallback != null && _koreanPattern.hasMatch(fallback)) {
|
||||||
String _resolveLocale() {
|
fallback = null;
|
||||||
final locale = PlatformDispatcher.instance.locale;
|
}
|
||||||
final code = locale.languageCode.toLowerCase();
|
final translated = key.tr(namedArgs: params);
|
||||||
if (_supportedLocales.contains(code)) {
|
if (translated == key && fallback != null && fallback.isNotEmpty) {
|
||||||
return code;
|
return fallback;
|
||||||
|
}
|
||||||
|
return translated;
|
||||||
|
} catch (_) {
|
||||||
|
return fallback ?? key;
|
||||||
}
|
}
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
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:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart' hide tr;
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_web_plugins/url_strategy.dart';
|
import 'package:flutter_web_plugins/url_strategy.dart';
|
||||||
@@ -19,6 +20,9 @@ import 'core/services/auth_proxy_service.dart';
|
|||||||
import 'core/services/auth_token_store.dart';
|
import 'core/services/auth_token_store.dart';
|
||||||
import 'core/services/logger_service.dart';
|
import 'core/services/logger_service.dart';
|
||||||
import 'core/notifiers/auth_notifier.dart';
|
import 'core/notifiers/auth_notifier.dart';
|
||||||
|
import 'core/i18n/locale_gate.dart';
|
||||||
|
import 'core/i18n/locale_utils.dart';
|
||||||
|
import 'core/i18n/toml_asset_loader.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';
|
import 'i18n.dart';
|
||||||
@@ -40,13 +44,16 @@ Future<void> _loadBundledFonts() async {
|
|||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
usePathUrlStrategy();
|
usePathUrlStrategy();
|
||||||
|
await EasyLocalization.ensureInitialized();
|
||||||
|
|
||||||
// 1. Global Error Handling
|
// 1. Global Error Handling
|
||||||
FlutterError.onError = (details) {
|
FlutterError.onError = (details) {
|
||||||
FlutterError.presentError(details);
|
FlutterError.presentError(details);
|
||||||
_log.severe("FLUTTER_ERROR", details.exception, details.stack);
|
_log.severe("FLUTTER_ERROR", details.exception, details.stack);
|
||||||
// Also send to backend if needed
|
// Also send to backend if needed
|
||||||
AuthProxyService.logError("FLUTTER_ERROR: ${details.exception}\n${details.stack}");
|
AuthProxyService.logError(
|
||||||
|
"FLUTTER_ERROR: ${details.exception}\n${details.stack}",
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
PlatformDispatcher.instance.onError = (error, stack) {
|
PlatformDispatcher.instance.onError = (error, stack) {
|
||||||
@@ -68,7 +75,22 @@ void main() async {
|
|||||||
// 폰트를 먼저 로딩해서 렌더링 깨짐(FOIT/FOUT) 최소화
|
// 폰트를 먼저 로딩해서 렌더링 깨짐(FOIT/FOUT) 최소화
|
||||||
await _loadBundledFonts();
|
await _loadBundledFonts();
|
||||||
|
|
||||||
runApp(const ProviderScope(child: BaronSSOApp()));
|
runApp(
|
||||||
|
// URL(/en, /ko)이 있으면 우선 적용해서 첫 렌더부터 올바른 언어로 시작합니다.
|
||||||
|
() {
|
||||||
|
final initialLocaleCode =
|
||||||
|
extractLocaleFromPath(Uri.base) ?? resolvePreferredLocaleCode();
|
||||||
|
return EasyLocalization(
|
||||||
|
supportedLocales: const [Locale('en'), Locale('ko')],
|
||||||
|
fallbackLocale: const Locale('en'),
|
||||||
|
startLocale: Locale(initialLocaleCode),
|
||||||
|
saveLocale: false,
|
||||||
|
path: 'assets/translations',
|
||||||
|
assetLoader: const TomlAssetLoader(),
|
||||||
|
child: const ProviderScope(child: BaronSSOApp()),
|
||||||
|
);
|
||||||
|
}(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Router Configuration
|
// Router Configuration
|
||||||
@@ -79,190 +101,227 @@ final _router = GoRouter(
|
|||||||
debugLogDiagnostics: !kReleaseMode,
|
debugLogDiagnostics: !kReleaseMode,
|
||||||
refreshListenable: AuthNotifier.instance,
|
refreshListenable: AuthNotifier.instance,
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
ShellRoute(
|
||||||
path: '/',
|
builder: (context, state, child) {
|
||||||
builder: (context, state) {
|
final localeCode =
|
||||||
_routerLogger.info("Navigating to root (DashboardScreen)");
|
extractLocaleFromPath(state.uri) ?? resolvePreferredLocaleCode();
|
||||||
return const DashboardScreen();
|
return LocaleGate(localeCode: localeCode, child: child);
|
||||||
},
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/profile',
|
|
||||||
builder: (context, state) => const ProfilePage(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/signin',
|
|
||||||
builder: (context, state) {
|
|
||||||
final loginChallenge = state.uri.queryParameters['login_challenge'];
|
|
||||||
_routerLogger.info("Navigating to /signin with login_challenge: $loginChallenge");
|
|
||||||
return LoginScreen(key: state.pageKey, loginChallenge: loginChallenge);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/login',
|
|
||||||
builder: (context, state) {
|
|
||||||
_routerLogger.info("Navigating to /login");
|
|
||||||
return LoginScreen(key: state.pageKey);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/consent',
|
|
||||||
builder: (BuildContext context, GoRouterState state) {
|
|
||||||
final consentChallenge = state.uri.queryParameters['consent_challenge'];
|
|
||||||
if (consentChallenge == null) {
|
|
||||||
_routerLogger.warning("Consent screen loaded without a challenge.");
|
|
||||||
return const Scaffold(body: Center(child: Text('Error: Consent challenge is missing.')));
|
|
||||||
}
|
|
||||||
_routerLogger.info("Navigating to /consent with challenge.");
|
|
||||||
return ConsentScreen(consentChallenge: consentChallenge);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/signup',
|
|
||||||
builder: (context, state) {
|
|
||||||
_routerLogger.info("Navigating to /signup");
|
|
||||||
return const SignupScreen();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/registration',
|
|
||||||
builder: (context, state) {
|
|
||||||
_routerLogger.info("Navigating to /registration");
|
|
||||||
return const SignupScreen();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/verify',
|
|
||||||
builder: (context, state) {
|
|
||||||
_routerLogger.info("Navigating to /verify (query)");
|
|
||||||
return LoginScreen(key: state.pageKey);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/verify/:token',
|
|
||||||
builder: (context, state) {
|
|
||||||
final token = state.pathParameters['token'];
|
|
||||||
_routerLogger.info("Navigating to /verify with token: $token");
|
|
||||||
return LoginScreen(key: state.pageKey, verificationToken: token);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/verification',
|
|
||||||
builder: (context, state) {
|
|
||||||
_routerLogger.info("Navigating to /verification");
|
|
||||||
return LoginScreen(key: state.pageKey);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/l/:shortCode',
|
|
||||||
builder: (context, state) {
|
|
||||||
final shortCode = state.pathParameters['shortCode'];
|
|
||||||
_routerLogger.info("Navigating to /l with code: $shortCode");
|
|
||||||
return LoginScreen(key: state.pageKey);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/forgot-password',
|
|
||||||
builder: (context, state) {
|
|
||||||
_routerLogger.info("Navigating to /forgot-password");
|
|
||||||
return const ForgotPasswordScreen();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/recovery',
|
|
||||||
builder: (context, state) {
|
|
||||||
_routerLogger.info("Navigating to /recovery");
|
|
||||||
return const ForgotPasswordScreen();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
// Supports both /reset-password and /reset-password?token=...
|
|
||||||
path: '/reset-password',
|
|
||||||
builder: (context, state) {
|
|
||||||
// For deep linking, you might pass the token in the path, e.g., /reset-password/:token
|
|
||||||
// final token = state.pathParameters['token'];
|
|
||||||
_routerLogger.info("Navigating to /reset-password");
|
|
||||||
return const ResetPasswordScreen();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/error',
|
|
||||||
builder: (context, state) {
|
|
||||||
_routerLogger.info("Navigating to /error");
|
|
||||||
final params = state.uri.queryParameters;
|
|
||||||
return ErrorScreen(
|
|
||||||
errorId: params['id'],
|
|
||||||
errorCode: params['error'],
|
|
||||||
description: params['error_description'] ?? params['message'],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/settings',
|
|
||||||
builder: (context, state) {
|
|
||||||
_routerLogger.info("Navigating to /settings (disabled)");
|
|
||||||
return ErrorScreen(
|
|
||||||
errorCode: 'settings_disabled',
|
|
||||||
description: tr(
|
|
||||||
'msg.userfront.settings.disabled',
|
|
||||||
fallback: '현재 계정 설정 화면은 준비 중입니다.',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/approve',
|
|
||||||
builder: (context, state) {
|
|
||||||
final ref = state.uri.queryParameters['ref'];
|
|
||||||
_routerLogger.info("Navigating to /approve with ref: $ref");
|
|
||||||
return ApproveQrScreen(pendingRef: ref);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/ql/:ref',
|
|
||||||
builder: (context, state) {
|
|
||||||
final ref = state.pathParameters['ref'];
|
|
||||||
_routerLogger.info("Navigating to /ql with ref: $ref");
|
|
||||||
return ApproveQrScreen(pendingRef: ref);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/scan',
|
|
||||||
builder: (context, state) {
|
|
||||||
_routerLogger.info("Navigating to /scan");
|
|
||||||
return const QRScanScreen();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/admin/users',
|
|
||||||
builder: (context, state) {
|
|
||||||
_routerLogger.info("Navigating to /admin/users");
|
|
||||||
return const UserManagementScreen();
|
|
||||||
},
|
},
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/:locale',
|
||||||
|
builder: (context, state) {
|
||||||
|
_routerLogger.info("Navigating to root (DashboardScreen)");
|
||||||
|
return const DashboardScreen();
|
||||||
|
},
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: 'profile',
|
||||||
|
builder: (context, state) => const ProfilePage(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'signin',
|
||||||
|
builder: (context, state) {
|
||||||
|
final loginChallenge =
|
||||||
|
state.uri.queryParameters['login_challenge'];
|
||||||
|
final redirectUrl =
|
||||||
|
state.uri.queryParameters['redirect_uri'] ??
|
||||||
|
state.uri.queryParameters['redirect_url'];
|
||||||
|
_routerLogger.info(
|
||||||
|
"Navigating to /signin with login_challenge: $loginChallenge, redirect: $redirectUrl",
|
||||||
|
);
|
||||||
|
return LoginScreen(
|
||||||
|
key: state.pageKey,
|
||||||
|
loginChallenge: loginChallenge,
|
||||||
|
redirectUrl: redirectUrl,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'login',
|
||||||
|
builder: (context, state) {
|
||||||
|
final redirectUrl =
|
||||||
|
state.uri.queryParameters['redirect_uri'] ??
|
||||||
|
state.uri.queryParameters['redirect_url'];
|
||||||
|
_routerLogger.info("Navigating to /login, redirect: $redirectUrl");
|
||||||
|
return LoginScreen(key: state.pageKey, redirectUrl: redirectUrl);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'consent',
|
||||||
|
builder: (BuildContext context, GoRouterState state) {
|
||||||
|
final consentChallenge =
|
||||||
|
state.uri.queryParameters['consent_challenge'];
|
||||||
|
if (consentChallenge == null) {
|
||||||
|
_routerLogger.warning("Consent screen loaded without a challenge.");
|
||||||
|
return const Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: Text('Error: Consent challenge is missing.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_routerLogger.info("Navigating to /consent with challenge.");
|
||||||
|
return ConsentScreen(consentChallenge: consentChallenge);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'signup',
|
||||||
|
builder: (context, state) {
|
||||||
|
_routerLogger.info("Navigating to /signup");
|
||||||
|
return const SignupScreen();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'registration',
|
||||||
|
builder: (context, state) {
|
||||||
|
_routerLogger.info("Navigating to /registration");
|
||||||
|
return const SignupScreen();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'verify',
|
||||||
|
builder: (context, state) {
|
||||||
|
_routerLogger.info("Navigating to /verify (query)");
|
||||||
|
return LoginScreen(key: state.pageKey);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'verify/:token',
|
||||||
|
builder: (context, state) {
|
||||||
|
final token = state.pathParameters['token'];
|
||||||
|
_routerLogger.info("Navigating to /verify with token: $token");
|
||||||
|
return LoginScreen(
|
||||||
|
key: state.pageKey,
|
||||||
|
verificationToken: token,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'verification',
|
||||||
|
builder: (context, state) {
|
||||||
|
_routerLogger.info("Navigating to /verification");
|
||||||
|
return LoginScreen(key: state.pageKey);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'l/:shortCode',
|
||||||
|
builder: (context, state) {
|
||||||
|
final shortCode = state.pathParameters['shortCode'];
|
||||||
|
_routerLogger.info("Navigating to /l with code: $shortCode");
|
||||||
|
return LoginScreen(key: state.pageKey);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'forgot-password',
|
||||||
|
builder: (context, state) {
|
||||||
|
_routerLogger.info("Navigating to /forgot-password");
|
||||||
|
return const ForgotPasswordScreen();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'recovery',
|
||||||
|
builder: (context, state) {
|
||||||
|
_routerLogger.info("Navigating to /recovery");
|
||||||
|
return const ForgotPasswordScreen();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
// Supports both /reset-password and /reset-password?token=...
|
||||||
|
path: 'reset-password',
|
||||||
|
builder: (context, state) {
|
||||||
|
_routerLogger.info("Navigating to /reset-password");
|
||||||
|
return const ResetPasswordScreen();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'error',
|
||||||
|
builder: (context, state) {
|
||||||
|
_routerLogger.info("Navigating to /error");
|
||||||
|
final params = state.uri.queryParameters;
|
||||||
|
return ErrorScreen(
|
||||||
|
errorId: params['id'],
|
||||||
|
errorCode: params['error'],
|
||||||
|
description:
|
||||||
|
params['error_description'] ?? params['message'],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'settings',
|
||||||
|
builder: (context, state) {
|
||||||
|
_routerLogger.info("Navigating to /settings (disabled)");
|
||||||
|
return ErrorScreen(
|
||||||
|
errorCode: 'settings_disabled',
|
||||||
|
description: tr(
|
||||||
|
'msg.userfront.settings.disabled',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'approve',
|
||||||
|
builder: (context, state) {
|
||||||
|
final ref = state.uri.queryParameters['ref'];
|
||||||
|
_routerLogger.info("Navigating to /approve with ref: $ref");
|
||||||
|
return ApproveQrScreen(pendingRef: ref);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'ql/:ref',
|
||||||
|
builder: (context, state) {
|
||||||
|
final ref = state.pathParameters['ref'];
|
||||||
|
_routerLogger.info("Navigating to /ql with ref: $ref");
|
||||||
|
return ApproveQrScreen(pendingRef: ref);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'scan',
|
||||||
|
builder: (context, state) {
|
||||||
|
_routerLogger.info("Navigating to /scan");
|
||||||
|
return const QRScanScreen();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'admin/users',
|
||||||
|
builder: (context, state) {
|
||||||
|
_routerLogger.info("Navigating to /admin/users");
|
||||||
|
return const UserManagementScreen();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
redirect: (context, state) {
|
redirect: (context, state) {
|
||||||
|
final requestedLocale = extractLocaleFromPath(state.uri);
|
||||||
|
final preferredLocale = resolvePreferredLocaleCode();
|
||||||
|
if (requestedLocale == null) {
|
||||||
|
return buildLocalizedPath(preferredLocale, state.uri);
|
||||||
|
}
|
||||||
|
|
||||||
final hasStoredToken = AuthTokenStore.getToken() != null;
|
final hasStoredToken = AuthTokenStore.getToken() != null;
|
||||||
final hasCookieSession = AuthTokenStore.usesCookie();
|
final hasCookieSession = AuthTokenStore.usesCookie();
|
||||||
final isLoggedIn = hasStoredToken || hasCookieSession;
|
final isLoggedIn = hasStoredToken || hasCookieSession;
|
||||||
final path = state.uri.path;
|
final path = stripLocalePath(state.uri);
|
||||||
|
|
||||||
// Public paths that don't require login
|
// Public paths that don't require login
|
||||||
final isPublicPath = path == '/signin' ||
|
final isPublicPath =
|
||||||
path == '/signup' ||
|
path == '/signin' ||
|
||||||
path == '/login' ||
|
path == '/signup' ||
|
||||||
path == '/registration' ||
|
path == '/login' ||
|
||||||
path == '/verify' ||
|
path == '/registration' ||
|
||||||
path == '/verification' ||
|
path == '/verify' ||
|
||||||
path.startsWith('/verify/') ||
|
path == '/verification' ||
|
||||||
path == '/approve' ||
|
path.startsWith('/verify/') ||
|
||||||
path.startsWith('/ql/') ||
|
path == '/approve' ||
|
||||||
path == '/forgot-password' ||
|
path.startsWith('/ql/') ||
|
||||||
path == '/recovery' ||
|
path == '/forgot-password' ||
|
||||||
path == '/reset-password' ||
|
path == '/recovery' ||
|
||||||
path == '/error' ||
|
path == '/reset-password' ||
|
||||||
path == '/settings' ||
|
path == '/error' ||
|
||||||
path == '/consent'; // Consent page is public
|
path == '/settings' ||
|
||||||
|
path == '/consent'; // Consent page is public
|
||||||
|
|
||||||
_routerLogger.fine("Redirect check - Path: $path, IsLoggedIn: $isLoggedIn");
|
_routerLogger.fine("Redirect check - Path: $path, IsLoggedIn: $isLoggedIn");
|
||||||
|
|
||||||
@@ -273,13 +332,14 @@ final _router = GoRouter(
|
|||||||
|
|
||||||
// If not logged in and trying to access a protected page, redirect to /signin
|
// If not logged in and trying to access a protected page, redirect to /signin
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
_routerLogger.info("Not logged in, redirecting to /signin");
|
_routerLogger.info("Not logged in, redirecting to /signin");
|
||||||
// Preserve OIDC challenge if present
|
// Preserve OIDC challenge if present
|
||||||
final loginChallenge = state.uri.queryParameters['login_challenge'];
|
final loginChallenge = state.uri.queryParameters['login_challenge'];
|
||||||
|
final locale = requestedLocale;
|
||||||
if (loginChallenge != null) {
|
if (loginChallenge != null) {
|
||||||
return '/signin?login_challenge=$loginChallenge';
|
return '/$locale/signin?login_challenge=$loginChallenge';
|
||||||
}
|
}
|
||||||
return '/signin';
|
return '/$locale/signin';
|
||||||
}
|
}
|
||||||
|
|
||||||
// If logged in and trying to access login page, redirect to root (dashboard)
|
// If logged in and trying to access login page, redirect to root (dashboard)
|
||||||
@@ -299,7 +359,10 @@ class BaronSSOApp extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
title: tr('ui.userfront.app_title', fallback: 'Baron 로그인'),
|
title: tr('ui.userfront.app_title'),
|
||||||
|
localizationsDelegates: context.localizationDelegates,
|
||||||
|
supportedLocales: context.supportedLocales,
|
||||||
|
locale: context.locale,
|
||||||
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
|
||||||
|
|||||||
@@ -105,6 +105,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.8"
|
version: "1.0.8"
|
||||||
|
easy_localization:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: easy_localization
|
||||||
|
sha256: "2ccdf9db8fe4d9c5a75c122e6275674508fd0f0d49c827354967b8afcc56bbed"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.8"
|
||||||
|
easy_logger:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: easy_logger
|
||||||
|
sha256: c764a6e024846f33405a2342caf91c62e357c24b02c04dbc712ef232bf30ffb7
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.0.2"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -113,6 +129,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.3"
|
version: "1.3.3"
|
||||||
|
ffi:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: ffi
|
||||||
|
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
file:
|
file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -134,6 +158,11 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.0.0"
|
||||||
|
flutter_driver:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -142,6 +171,11 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.0.0"
|
||||||
|
flutter_localizations:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
flutter_riverpod:
|
flutter_riverpod:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -168,6 +202,11 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.0"
|
version: "4.0.0"
|
||||||
|
fuchsia_remote_debug_protocol:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
glob:
|
glob:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -208,6 +247,19 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
integration_test:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
intl:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: intl
|
||||||
|
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.20.2"
|
||||||
io:
|
io:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -336,6 +388,46 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.1"
|
||||||
|
path_provider_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_linux
|
||||||
|
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.1"
|
||||||
|
path_provider_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_platform_interface
|
||||||
|
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.2"
|
||||||
|
path_provider_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_windows
|
||||||
|
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.0"
|
||||||
|
petitparser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: petitparser
|
||||||
|
sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.1.0"
|
||||||
|
platform:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: platform
|
||||||
|
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.6"
|
||||||
plugin_platform_interface:
|
plugin_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -352,6 +444,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.2"
|
version: "1.5.2"
|
||||||
|
process:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: process
|
||||||
|
sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.0.5"
|
||||||
pub_semver:
|
pub_semver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -384,6 +484,62 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.0"
|
version: "3.2.0"
|
||||||
|
shared_preferences:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences
|
||||||
|
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.4"
|
||||||
|
shared_preferences_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_android
|
||||||
|
sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.20"
|
||||||
|
shared_preferences_foundation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_foundation
|
||||||
|
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.6"
|
||||||
|
shared_preferences_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_linux
|
||||||
|
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
|
shared_preferences_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_platform_interface
|
||||||
|
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
|
shared_preferences_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_web
|
||||||
|
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.3"
|
||||||
|
shared_preferences_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_windows
|
||||||
|
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
shelf:
|
shelf:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -477,6 +633,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.1"
|
||||||
|
sync_http:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sync_http
|
||||||
|
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.1"
|
||||||
term_glyph:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -509,6 +673,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.12"
|
version: "0.6.12"
|
||||||
|
toml:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: toml
|
||||||
|
sha256: "9968de24e45b632bf1a654fe1ac7b6fe5261c349243df83fd262397799c45a2d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.15.0"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -629,6 +801,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.3"
|
version: "3.0.3"
|
||||||
|
webdriver:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: webdriver
|
||||||
|
sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.0"
|
||||||
webkit_inspection_protocol:
|
webkit_inspection_protocol:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -637,6 +817,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.1"
|
version: "1.2.1"
|
||||||
|
xdg_directories:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xdg_directories
|
||||||
|
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
yaml:
|
yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -646,5 +834,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.10.4 <4.0.0"
|
dart: ">=3.10.0 <4.0.0"
|
||||||
flutter: ">=3.38.0"
|
flutter: ">=3.38.0"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
|||||||
version: 1.0.0+1
|
version: 1.0.0+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.4
|
sdk: ">=3.10.0-0 <4.0.0"
|
||||||
|
|
||||||
# Dependencies specify other packages that your package needs in order to work.
|
# Dependencies specify other packages that your package needs in order to work.
|
||||||
# To automatically upgrade your package dependencies to the latest versions
|
# To automatically upgrade your package dependencies to the latest versions
|
||||||
@@ -45,10 +45,14 @@ dependencies:
|
|||||||
logger: ^2.0.0
|
logger: ^2.0.0
|
||||||
qr_flutter: ^4.1.0
|
qr_flutter: ^4.1.0
|
||||||
mobile_scanner: ^7.1.4
|
mobile_scanner: ^7.1.4
|
||||||
|
easy_localization: ^3.0.7
|
||||||
|
toml: ^0.15.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
integration_test:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
# The "flutter_lints" package below contains a set of recommended lints to
|
# The "flutter_lints" package below contains a set of recommended lints to
|
||||||
# encourage good coding practices. The lint set provided by the package is
|
# encourage good coding practices. The lint set provided by the package is
|
||||||
@@ -70,6 +74,8 @@ flutter:
|
|||||||
# To add assets to your application, add an assets section, like this:
|
# To add assets to your application, add an assets section, like this:
|
||||||
# assets:
|
# assets:
|
||||||
# - .env
|
# - .env
|
||||||
|
assets:
|
||||||
|
- assets/translations/
|
||||||
|
|
||||||
# An image asset can refer to one or more resolution-specific "variants", see
|
# An image asset can refer to one or more resolution-specific "variants", see
|
||||||
# https://flutter.dev/to/resolution-aware-images
|
# https://flutter.dev/to/resolution-aware-images
|
||||||
|
|||||||
5
userfront/test/helpers/web_storage.dart
Normal file
5
userfront/test/helpers/web_storage.dart
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import 'web_storage_stub.dart'
|
||||||
|
if (dart.library.html) 'web_storage_web.dart';
|
||||||
|
|
||||||
|
export 'web_storage_stub.dart'
|
||||||
|
if (dart.library.html) 'web_storage_web.dart';
|
||||||
21
userfront/test/helpers/web_storage_stub.dart
Normal file
21
userfront/test/helpers/web_storage_stub.dart
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
class WebStorage {
|
||||||
|
bool get isWeb => false;
|
||||||
|
|
||||||
|
String? get(String key) => null;
|
||||||
|
|
||||||
|
void set(String key, String value) {}
|
||||||
|
|
||||||
|
String? getSession(String key) => null;
|
||||||
|
|
||||||
|
void setSession(String key, String value) {}
|
||||||
|
|
||||||
|
void removeSession(String key) {}
|
||||||
|
|
||||||
|
void clearSession() {}
|
||||||
|
|
||||||
|
void remove(String key) {}
|
||||||
|
|
||||||
|
void clear() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
final webStorage = WebStorage();
|
||||||
37
userfront/test/helpers/web_storage_web.dart
Normal file
37
userfront/test/helpers/web_storage_web.dart
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// ignore_for_file: avoid_web_libraries_in_flutter
|
||||||
|
|
||||||
|
import 'dart:html' as html;
|
||||||
|
|
||||||
|
class WebStorage {
|
||||||
|
bool get isWeb => true;
|
||||||
|
|
||||||
|
String? get(String key) => html.window.localStorage[key];
|
||||||
|
|
||||||
|
void set(String key, String value) {
|
||||||
|
html.window.localStorage[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? getSession(String key) => html.window.sessionStorage[key];
|
||||||
|
|
||||||
|
void setSession(String key, String value) {
|
||||||
|
html.window.sessionStorage[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeSession(String key) {
|
||||||
|
html.window.sessionStorage.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearSession() {
|
||||||
|
html.window.sessionStorage.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void remove(String key) {
|
||||||
|
html.window.localStorage.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
html.window.localStorage.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final webStorage = WebStorage();
|
||||||
88
userfront/test/locale_storage_web_test.dart
Normal file
88
userfront/test/locale_storage_web_test.dart
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:userfront/core/i18n/locale_storage.dart';
|
||||||
|
import 'package:userfront/core/i18n/locale_storage_web.dart' as locale_web;
|
||||||
|
|
||||||
|
import 'helpers/web_storage.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUp(() {
|
||||||
|
locale_web.LocaleStorageImpl.forceMemoryStorageForTests(false);
|
||||||
|
locale_web.LocaleStorageImpl.forceSessionStorageForTests(false);
|
||||||
|
if (webStorage.isWeb) {
|
||||||
|
webStorage.clear();
|
||||||
|
webStorage.clearSession();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
locale_web.LocaleStorageImpl.forceMemoryStorageForTests(false);
|
||||||
|
locale_web.LocaleStorageImpl.forceSessionStorageForTests(false);
|
||||||
|
if (webStorage.isWeb) {
|
||||||
|
webStorage.clear();
|
||||||
|
webStorage.clearSession();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'localStorage write/read (웹)',
|
||||||
|
() {
|
||||||
|
if (!webStorage.isWeb) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LocaleStorage.write('ko');
|
||||||
|
expect(webStorage.get('locale'), 'ko');
|
||||||
|
expect(LocaleStorage.read(), 'ko');
|
||||||
|
},
|
||||||
|
skip: !webStorage.isWeb,
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'legacy key에서 locale로 마이그레이션 (웹)',
|
||||||
|
() {
|
||||||
|
if (!webStorage.isWeb) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
webStorage.set('baron_locale', 'en');
|
||||||
|
expect(LocaleStorage.read(), 'en');
|
||||||
|
expect(webStorage.get('locale'), 'en');
|
||||||
|
expect(webStorage.get('baron_locale'), isNull);
|
||||||
|
},
|
||||||
|
skip: !webStorage.isWeb,
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'localStorage 접근이 차단되면 메모리 fallback (웹)',
|
||||||
|
() {
|
||||||
|
if (!webStorage.isWeb) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
locale_web.LocaleStorageImpl.forceMemoryStorageForTests(true);
|
||||||
|
|
||||||
|
LocaleStorage.write('en');
|
||||||
|
expect(webStorage.get('locale'), isNull);
|
||||||
|
expect(webStorage.getSession('locale'), isNull);
|
||||||
|
expect(LocaleStorage.read(), 'en');
|
||||||
|
},
|
||||||
|
skip: !webStorage.isWeb,
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'localStorage 접근이 차단되면 sessionStorage로 fallback (웹)',
|
||||||
|
() {
|
||||||
|
if (!webStorage.isWeb) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
locale_web.LocaleStorageImpl.forceSessionStorageForTests(true);
|
||||||
|
|
||||||
|
LocaleStorage.write('ko');
|
||||||
|
expect(webStorage.get('locale'), isNull);
|
||||||
|
expect(webStorage.getSession('locale'), 'ko');
|
||||||
|
expect(LocaleStorage.read(), 'ko');
|
||||||
|
},
|
||||||
|
skip: !webStorage.isWeb,
|
||||||
|
);
|
||||||
|
}
|
||||||
66
userfront/test/locale_utils_test.dart
Normal file
66
userfront/test/locale_utils_test.dart
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:userfront/core/i18n/locale_utils.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('locale_utils', () {
|
||||||
|
test('normalizeLocaleCode handles supported locales', () {
|
||||||
|
expect(normalizeLocaleCode('ko'), 'ko');
|
||||||
|
expect(normalizeLocaleCode('ko-KR'), 'ko');
|
||||||
|
expect(normalizeLocaleCode('en'), 'en');
|
||||||
|
expect(normalizeLocaleCode('en-US'), 'en');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeLocaleCode falls back to default', () {
|
||||||
|
expect(normalizeLocaleCode('ja'), defaultLocaleCode);
|
||||||
|
expect(normalizeLocaleCode(null), defaultLocaleCode);
|
||||||
|
expect(normalizeLocaleCode(''), defaultLocaleCode);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extractLocaleFromPath picks locale when present', () {
|
||||||
|
expect(extractLocaleFromPath(Uri.parse('/ko/signin')), 'ko');
|
||||||
|
expect(extractLocaleFromPath(Uri.parse('/en/profile')), 'en');
|
||||||
|
expect(extractLocaleFromPath(Uri.parse('/ko')), 'ko');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extractLocaleFromPath returns null when missing', () {
|
||||||
|
expect(extractLocaleFromPath(Uri.parse('/signin')), isNull);
|
||||||
|
expect(extractLocaleFromPath(Uri.parse('/zz/signin')), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stripLocalePath removes locale segment', () {
|
||||||
|
expect(stripLocalePath(Uri.parse('/ko/signin')), '/signin');
|
||||||
|
expect(stripLocalePath(Uri.parse('/en/profile')), '/profile');
|
||||||
|
expect(stripLocalePath(Uri.parse('/ko')), '/');
|
||||||
|
expect(stripLocalePath(Uri.parse('/en/')), '/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stripLocalePath keeps path without locale', () {
|
||||||
|
expect(stripLocalePath(Uri.parse('/signin')), '/signin');
|
||||||
|
expect(stripLocalePath(Uri.parse('/auth/callback')), '/auth/callback');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildLocalizedPath applies locale', () {
|
||||||
|
expect(buildLocalizedPath('ko', Uri.parse('/signin')), '/ko/signin');
|
||||||
|
expect(buildLocalizedPath('en', Uri.parse('/signin')), '/en/signin');
|
||||||
|
expect(buildLocalizedPath('ko', Uri.parse('/')), '/ko');
|
||||||
|
expect(buildLocalizedPath('en', Uri.parse('/')), '/en');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildLocalizedPath preserves query parameters', () {
|
||||||
|
final uri = Uri.parse('/signin?redirect_uri=https://example.com');
|
||||||
|
expect(
|
||||||
|
buildLocalizedPath('ko', uri),
|
||||||
|
'/ko/signin?redirect_uri=https%3A%2F%2Fexample.com',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildLocalizedPath replaces existing locale', () {
|
||||||
|
expect(buildLocalizedPath('en', Uri.parse('/ko/signin')), '/en/signin');
|
||||||
|
expect(buildLocalizedPath('ko', Uri.parse('/en/profile')), '/ko/profile');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildLocalizedPath drops unknown 2-letter prefix', () {
|
||||||
|
expect(buildLocalizedPath('ko', Uri.parse('/zz/signin')), '/ko/signin');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,15 +5,36 @@
|
|||||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||||
// tree, read text, and verify that the values of widget properties are correct.
|
// tree, read text, and verify that the values of widget properties are correct.
|
||||||
|
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
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/main.dart' show BaronSSOApp;
|
import 'package:userfront/main.dart' show BaronSSOApp;
|
||||||
|
|
||||||
|
class _TestAssetLoader extends AssetLoader {
|
||||||
|
const _TestAssetLoader();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, dynamic>> load(String path, Locale locale) async {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('BaronSSOApp builds', (WidgetTester tester) async {
|
testWidgets('BaronSSOApp builds', (WidgetTester tester) async {
|
||||||
// runApp에서 ProviderScope로 감싸서 쓰고 있으니 테스트도 동일하게 감쌈
|
// runApp에서 ProviderScope로 감싸서 쓰고 있으니 테스트도 동일하게 감쌈
|
||||||
await tester.pumpWidget(const ProviderScope(child: BaronSSOApp()));
|
await tester.pumpWidget(
|
||||||
|
EasyLocalization(
|
||||||
|
supportedLocales: const [Locale('en'), Locale('ko')],
|
||||||
|
fallbackLocale: const Locale('en'),
|
||||||
|
startLocale: const Locale('en'),
|
||||||
|
path: 'assets/translations',
|
||||||
|
assetLoader: const _TestAssetLoader(),
|
||||||
|
child: const ProviderScope(child: BaronSSOApp()),
|
||||||
|
),
|
||||||
|
);
|
||||||
await tester.pump(); // 한 프레임 더
|
await tester.pump(); // 한 프레임 더
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user