1
0
forked from baron/baron-sso

Merge feature/i18n into dev (userfront only)

This commit is contained in:
Lectom C Han
2026-02-12 21:53:42 +09:00
55 changed files with 3982 additions and 1104 deletions

18
.dockerignore Normal file
View 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

View File

@@ -1,118 +1,125 @@
name: Code Check
on:
workflow_dispatch:
inputs:
run_lint:
description: "Run linters for Go and Flutter"
required: true
type: boolean
default: true
run_backend_tests:
description: "Run backend Go tests"
required: true
type: boolean
default: true
run_userfront_tests:
description: "Run userfront Flutter tests"
required: true
type: boolean
default: true
pull_request:
branches:
- dev
workflow_dispatch:
inputs:
run_lint:
description: "Run linters for Go and Flutter"
required: true
type: boolean
default: true
run_backend_tests:
description: "Run backend Go tests"
required: true
type: boolean
default: true
run_userfront_tests:
description: "Run userfront Flutter tests"
required: true
type: boolean
default: true
jobs:
lint:
if: ${{ inputs.run_lint == true }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
lint:
if: ${{ github.event_name != 'workflow_dispatch' || inputs.run_lint == true }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: i18n resource check
run: |
node tools/i18n-scanner/index.js
- name: i18n resource check
run: |
mkdir -p reports
node tools/i18n-scanner/index.js
node tools/i18n-scanner/report.js
cat reports/i18n-report.txt
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.25"
cache-dependency-path: backend/go.sum
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.25"
cache-dependency-path: backend/go.sum
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
cache: true
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
cache: true
- name: Lint Go backend
uses: golangci/golangci-lint-action@v6
with:
version: v1.59
working-directory: backend
args: --enable-only=gofmt,gofumpt
- name: Lint Go backend
uses: golangci/golangci-lint-action@v6
with:
version: v1.59
working-directory: backend
args: --enable-only=gofmt,gofumpt
- name: Analyze Flutter userfront
run: |
cd userfront
flutter analyze --no-fatal-warnings --no-fatal-infos
- name: Analyze Flutter userfront
run: |
cd userfront
flutter analyze --no-fatal-warnings --no-fatal-infos
backend-tests:
needs: lint
if: ${{ inputs.run_backend_tests == true }}
runs-on: ubuntu-latest
services:
redis:
image: redis:7-alpine
options: >
--health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
clickhouse:
image: clickhouse/clickhouse-server:24.6
options: >
--health-cmd "wget -qO- 'http://localhost:8123/ping'" --health-interval 10s --health-timeout 5s --health-retries 5
backend-tests:
needs: lint
if: ${{ github.event_name != 'workflow_dispatch' || inputs.run_backend_tests == true }}
runs-on: ubuntu-latest
services:
redis:
image: redis:7-alpine
options: >
--health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
clickhouse:
image: clickhouse/clickhouse-server:24.6
options: >
--health-cmd "wget -qO- 'http://localhost:8123/ping'" --health-interval 10s --health-timeout 5s --health-retries 5
env:
REDIS_ADDR: redis:6379
CLICKHOUSE_HOST: clickhouse
CLICKHOUSE_PORT_NATIVE: 9000
env:
REDIS_ADDR: redis:6379
CLICKHOUSE_HOST: clickhouse
CLICKHOUSE_PORT_NATIVE: 9000
steps:
- name: Checkout code
uses: actions/checkout@v4
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.25"
cache-dependency-path: backend/go.sum
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.25"
cache-dependency-path: backend/go.sum
- name: Run backend tests
run: |
cd backend
go test -v ./...
- name: Run backend tests
run: |
cd backend
go test -v ./...
userfront-tests:
needs: lint
if: ${{ inputs.run_userfront_tests == true }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
userfront-tests:
needs: lint
if: ${{ github.event_name != 'workflow_dispatch' || inputs.run_userfront_tests == true }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
cache: true
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
cache: true
- name: Run userfront tests
run: |
cd userfront
if [ -d test ]; then
flutter test
else
echo "No userfront tests: skipping (test/ directory not found)."
fi
- name: Run userfront tests
run: |
cd userfront
if [ -d test ]; then
flutter test
flutter test --platform chrome test/locale_storage_web_test.dart
else
echo "No userfront tests: skipping (test/ directory not found)."
fi

View File

@@ -55,7 +55,7 @@ flowchart
- userfront가 바라보는 backend
### 2. UserFront(Flutter Web/App)
- **Framework**: Flutter 3.32+
- **Framework**: Flutter 3.38.0+
- **Key Packages**: `flutter_riverpod`, `go_router`
- **Features**:
- 탭 기반 로그인 UI (비밀번호 기반 / 링크 기반 / QR 기반 등)
@@ -147,7 +147,7 @@ Kratos가 사용자 SoT이며 Hydra는 순수 OIDC 토큰 엔진입니다. 비
### 사전 요구사항 (Prerequisites)
- Docker & Docker Compose
- Flutter SDK (로컬 개발용)
- Flutter SDK (로컬 개발용, 3.38.0+)
- Go (로컬 백엔드 개발용)
### 환경 설정 (Environment Setup)

View File

@@ -6,7 +6,7 @@ It leverages **Descope** for secure, passwordless authentication (Enchanted Link
## 🏗 Architecture
### 1. Frontend (Flutter Web)
- **Framework**: Flutter 3.32+
- **Framework**: Flutter 3.38.0+
- **Organization**: `kr.co.baroncs`
- **Key Packages**: `descope`, `flutter_riverpod`, `go_router`
- **Features**:
@@ -32,7 +32,7 @@ It leverages **Descope** for secure, passwordless authentication (Enchanted Link
### Prerequisites
- Docker & Docker Compose
- Flutter SDK (for local development)
- Flutter SDK (for local development, 3.38.0+)
- Go (for local backend development)
### Environment Setup

View 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;

View File

@@ -16,6 +16,7 @@ import {
import { useEffect, useState } from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { t } from "../../lib/i18n";
import LanguageSelector from "../common/LanguageSelector";
import RoleSwitcher from "./RoleSwitcher";
const navItems = [
@@ -170,6 +171,7 @@ function AppLayout() {
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<LanguageSelector />
<button
type="button"
onClick={toggleTheme}

View 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;

View File

@@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
import { useAuth } from "react-oidc-context";
import { NavLink, Outlet } from "react-router-dom";
import { t } from "../../lib/i18n";
import LanguageSelector from "../common/LanguageSelector";
import { Toaster } from "../ui/toaster";
const navItems = [
@@ -134,6 +135,7 @@ function AppLayout() {
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<LanguageSelector />
<button
type="button"
onClick={toggleTheme}

View File

@@ -11,8 +11,8 @@ services:
- 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}
- 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}
@@ -86,13 +86,13 @@ services:
userfront:
build:
context: ./userfront
dockerfile: Dockerfile
context: .
dockerfile: userfront/Dockerfile
container_name: baron_userfront
env_file:
- .env
environment:
- BACKEND_URL=${BACKEND_URL}
- BACKEND_URL=${BACKEND_URL:-}
- USERFRONT_URL=${USERFRONT_URL}
- APP_ENV=${APP_ENV}
networks:

View File

@@ -1,135 +1,136 @@
services:
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
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}"]'
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-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}"]'
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}"]'
volumes:
- ./docker/ory/kratos:/etc/config/kratos
command: serve -c /etc/config/kratos/kratos.yml
depends_on:
kratos-migrate:
condition: service_completed_successfully
networks:
- ory-net
- kratosnet
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}"]'
volumes:
- ./docker/ory/kratos:/etc/config/kratos
command: serve -c /etc/config/kratos/kratos.yml
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-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
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
# [수정됨] Oathkeeper 서비스 추가 (Backend 연결 문제 해결)
oathkeeper:
image: oryd/oathkeeper:${OATHKEEPER_VERSION:-v0.40.6}
container_name: oathkeeper
restart: unless-stopped
depends_on:
kratos:
condition: service_started
environment:
- LOG_LEVEL=debug
command: serve proxy --config /etc/config/oathkeeper/oathkeeper.yml
volumes:
- ./docker/ory/oathkeeper:/etc/config/oathkeeper
networks:
- ory-net
- baron_net # Backend가 통신하기 위해 필수
- public_net
ports:
- "4455:4455" # Proxy
- "4456:4456" # API (Backend 헬스체크용)
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4456/health/ready"]
interval: 5s
timeout: 5s
retries: 5
# [수정됨] Oathkeeper 서비스 추가 (Backend 연결 문제 해결)
oathkeeper:
image: oryd/oathkeeper:${OATHKEEPER_VERSION:-v0.40.6}
container_name: oathkeeper
restart: unless-stopped
depends_on:
kratos:
condition: service_started
environment:
- LOG_LEVEL=debug
command: serve proxy --config /etc/config/oathkeeper/oathkeeper.yml
volumes:
- ./docker/ory/oathkeeper:/etc/config/oathkeeper
- oathkeeper_logs:/var/log/oathkeeper
networks:
- ory-net
- baron_net # Backend가 통신하기 위해 필수
- public_net
ports:
- "4455:4455" # Proxy
- "4456:4456" # API (Backend 헬스체크용)
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4456/health/ready"]
interval: 5s
timeout: 5s
retries: 5
volumes:
ory_postgres_data:
ory_postgres_data:
networks:
ory-net:
external: true
name: ory-net
hydranet:
external: true
name: hydranet
kratosnet:
external: true
name: kratosnet
public_net:
external: true
name: public_net
# [수정됨] Baron Net 추가 정의 (Oathkeeper 연결용)
baron_net:
external: true
name: baron_net
ory-net:
external: true
name: ory-net
hydranet:
external: true
name: hydranet
kratosnet:
external: true
name: kratosnet
public_net:
external: true
name: public_net
# [수정됨] Baron Net 추가 정의 (Oathkeeper 연결용)
baron_net:
external: true
name: baron_net

View File

@@ -64,7 +64,7 @@ services:
env_file:
- .env
environment:
- BACKEND_URL=${BACKEND_URL}
- BACKEND_URL=${BACKEND_URL:-}
- USERFRONT_URL=${USERFRONT_URL}
- APP_ENV=stage
networks:
@@ -75,7 +75,7 @@ services:
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 \"BACKEND_URL=${BACKEND_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 &&
cp /usr/share/nginx/html/assets/.env /usr/share/nginx/html/.env &&

View File

@@ -37,7 +37,11 @@ mkdir -p "$LOG_DIR"
if ! touch "$LOG_FILE" 2>/dev/null; then
echo "[oathkeeper] log file not writable: $LOG_FILE"
ls -ld "$LOG_DIR" || true
exit 1
LOG_FILE=""
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"

View 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)합니다.

View File

@@ -79,6 +79,7 @@ TOML에서는 `[Section]`을 사용하여 계층을 표현합니다.
* `ko` 계열이면 `/ko/dashboard`로 리다이렉트합니다.
* 그 외에는 `/en/dashboard`로 리다이렉트합니다.
* *단, 이전에 언어를 선택한 쿠키나 로컬스토리지 값이 있다면 그 값을 우선합니다.*
* 로컬스토리지 키는 **`locale`**을 사용합니다.
2. **언어 변경 시**:
* 모든 화면에는 **언어 선택기(Language Selector)**가 노출되어야 합니다.
* 변경 시 해당 언어의 URL로 이동(`window.location` 변경 혹은 Router Push)하며, 선택된 언어를 로컬스토리지에 저장합니다.

View 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`

View File

@@ -355,7 +355,6 @@ title_with_code = "Title With Code"
type = "Type"
[msg.userfront.error.whitelist]
$normalizedCode = "$NormalizedCode"
settings_disabled = "Account settings are currently unavailable."
[msg.userfront.forgot]
@@ -372,10 +371,10 @@ link_failed = "Link Failed"
link_send_failed = "Link Send Failed"
link_sent_email = "Link Sent Email"
link_sent_phone = "Link Sent Phone"
link_timeout = "Link Timeout"
no_account = "No Account"
link_timeout = "Time expired."
no_account = "New to Baron?"
oidc_failed = "OIDC Failed"
qr_expired = "QR Expired"
qr_expired = "Time expired."
qr_init_failed = "QR Init Failed"
qr_login_required = "QR Login Required"
token_missing = "Token Missing"
@@ -383,7 +382,7 @@ verification_failed = "Verification Failed"
[msg.userfront.login.link]
approved = "Approved"
helper = "Helper"
helper = "Sending you a login link"
missing_login_id = "Missing Login Id"
missing_phone = "Missing Phone"
resend_wait = "Resend Wait"
@@ -871,6 +870,9 @@ retry = "Retry"
save = "Save"
search = "Search"
show_more = "Show More"
language = "Language"
language_ko = "한국어"
language_en = "English"
theme_dark = "Dark"
theme_light = "Light"
theme_toggle = "Theme Toggle"
@@ -1091,7 +1093,7 @@ subtitle = "Manage your applications"
[ui.userfront]
app_title = "App Title"
app_title = "Baron SW Portal"
[ui.userfront.app_label]
admin_console = "Admin Console"
@@ -1161,7 +1163,7 @@ signup = "Signup"
submit = "Submit"
[ui.userfront.login.field]
login_id = "Login Id"
login_id = "Emain or Phone Number"
password = "Password"
[ui.userfront.login.link]
@@ -1175,7 +1177,7 @@ title = "Title"
[ui.userfront.login.qr]
expired = "Expired"
refresh = "Refresh"
remaining = "Remaining"
remaining = "Remaining: {{time}}"
[ui.userfront.login.short_code]
digits = "Digits"
@@ -1184,9 +1186,9 @@ prefix = "Prefix"
submit = "Submit"
[ui.userfront.login.tabs]
link = "Link"
link = "Link/Code"
password = "Password"
qr = "QR"
qr = "QR Code"
[ui.userfront.login.unregistered]
action = "Action"

View File

@@ -355,7 +355,6 @@ title_with_code = "오류: {{code}}"
type = "오류 종류: {{type}}"
[msg.userfront.error.whitelist]
$normalizedCode = "에러가 계속되면 관리자에게 문의해주세요"
settings_disabled = "현재 계정 설정 화면은 준비 중입니다."
[msg.userfront.forgot]
@@ -372,10 +371,10 @@ link_failed = "오류: {{error}}"
link_send_failed = "전송 실패: {{error}}"
link_sent_email = "입력하신 이메일로 로그인 링크를 보냈습니다."
link_sent_phone = "입력하신 번호로 로그인 링크를 보냈습니다."
link_timeout = "로그인 요청 시간이 과되었습니다."
link_timeout = "시간이 과되었습니다."
no_account = "계정이 없으신가요?"
oidc_failed = "OIDC 로그인 처리에 실패했습니다. 다시 시도해 주세요."
qr_expired = "QR 세션이 만료되었습니다."
qr_expired = "시간이 경과되었습니다."
qr_init_failed = "QR 초기화에 실패했습니다: {{error}}"
qr_login_required = "로그인 한 상태여야 QR 스캔으로 로그인 할 수 있습니다"
token_missing = "로그인 토큰을 확인할 수 없습니다."
@@ -871,6 +870,9 @@ retry = "다시 시도"
save = "저장"
search = "검색"
show_more = "+ 더보기"
language = "언어"
language_ko = "한국어"
language_en = "English"
theme_dark = "Dark"
theme_light = "Light"
theme_toggle = "테마 전환"
@@ -1091,7 +1093,7 @@ subtitle = "Manage your applications"
[ui.userfront]
app_title = "Baron 로그인"
app_title = "Baron SW 포탈"
[ui.userfront.app_label]
admin_console = "Admin Console"

View File

@@ -355,7 +355,6 @@ title_with_code = ""
type = ""
[msg.userfront.error.whitelist]
$normalizedCode = ""
settings_disabled = ""
[msg.userfront.forgot]
@@ -883,6 +882,9 @@ retry = ""
save = ""
search = ""
show_more = ""
language = ""
language_ko = ""
language_en = ""
theme_dark = ""
theme_light = ""
theme_toggle = ""
@@ -1187,7 +1189,7 @@ title = ""
[ui.userfront.login.qr]
expired = ""
refresh = ""
remaining = ""
remaining = "Remaining: {{time}}"
[ui.userfront.login.short_code]
digits = ""

View 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"

View 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();

View File

@@ -1,10 +1,11 @@
# 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
WORKDIR /app
COPY . .
# Get dependencies and build for web
RUN /bin/sh ./scripts/sync_userfront_locales.sh
WORKDIR /app/userfront
RUN flutter pub get
RUN touch .env
RUN flutter build web --wasm --release --no-tree-shake-icons
@@ -12,9 +13,9 @@ RUN flutter build web --wasm --release --no-tree-shake-icons
# Stage 2: Serve with Nginx
FROM nginx:alpine
# 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 nginx.conf /etc/nginx/conf.d/default.conf
COPY userfront/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 5000
CMD ["nginx", "-g", "daemon off;"]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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 = ""

View 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;
}

View 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);
}

View File

@@ -0,0 +1,11 @@
class LocaleStorageImpl {
String? _locale;
String? read() => _locale;
void write(String locale) {
_locale = locale;
}
}
final localeStorage = LocaleStorageImpl();

View 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();

View 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();
}

View 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 {};
}
}
}

View File

@@ -11,11 +11,25 @@ class AuthProxyService {
if (!dotenv.isInitialized) {
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 {
final rawUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
final rawUrl = _envOrDefault('BACKEND_URL', _fallbackOrigin());
// 배포 환경에서 $ 기호나 공백이 섞여 들어오는 경우를 방지하기 위해 정제합니다.
return rawUrl.replaceAll(r'$', '').trim().replaceAll(RegExp(r'/$'), '');
}
@@ -103,7 +117,7 @@ class AuthProxyService {
bool? drySend,
}) async {
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>{'loginId': loginId, 'uri': userfrontUrl};
if (_shouldSendDrySend(drySend)) {
@@ -269,7 +283,6 @@ class AuthProxyService {
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.login_failed',
fallback: '로그인에 실패했습니다.',
),
);
}
@@ -294,7 +307,6 @@ class AuthProxyService {
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.consent_fetch',
fallback: '동의 정보를 가져오지 못했습니다.',
),
);
}
@@ -324,7 +336,6 @@ class AuthProxyService {
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.consent_accept',
fallback: '동의 처리에 실패했습니다.',
),
);
}
@@ -350,7 +361,6 @@ class AuthProxyService {
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.consent_reject',
fallback: '동의 거부에 실패했습니다.',
),
);
}
@@ -381,7 +391,6 @@ class AuthProxyService {
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.oidc_accept',
fallback: 'OIDC 로그인 승인에 실패했습니다.',
),
);
}
@@ -412,7 +421,6 @@ class AuthProxyService {
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.password_reset_init',
fallback: '비밀번호 재설정을 시작하지 못했습니다.',
),
);
}
@@ -447,7 +455,6 @@ class AuthProxyService {
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.password_reset_complete',
fallback: '비밀번호 재설정에 실패했습니다.',
),
);
}
@@ -780,7 +787,6 @@ class AuthProxyService {
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.linked_app_revoke',
fallback: '연동 해지에 실패했습니다.',
),
);
}

View File

@@ -1,4 +1,6 @@
class WebWindow {
void setTitle(String title) {}
void redirectTo(String url) {}
void alert(String message) {}

View File

@@ -3,6 +3,10 @@
import 'dart:html' as html;
class WebWindow {
void setTitle(String title) {
html.document.title = title;
}
void redirectTo(String url) {
html.window.location.href = url;
}

View 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,
],
),
),
);
}
}

View File

@@ -30,16 +30,14 @@ class ErrorScreen extends StatelessWidget {
? (isWhitelisted && hasCode ? normalizedCode : 'unknown_error')
: (hasCode ? normalizedCode : 'unknown_error');
final title = isProd
? tr('msg.userfront.error.title', fallback: '인증 과정에서 오류가 발생했습니다')
? tr('msg.userfront.error.title')
: (hasCode
? tr(
'msg.userfront.error.title_with_code',
fallback: '오류: {{code}}',
params: {'code': normalizedCode},
)
: tr(
'msg.userfront.error.title_generic',
fallback: '오류가 발생했습니다',
));
final detail = isProd
? (isWhitelisted
@@ -49,18 +47,15 @@ class ErrorScreen extends StatelessWidget {
)
: tr(
'msg.userfront.error.detail_contact',
fallback: '에러가 계속되면 관리자에게 문의해주세요',
))
: ((description?.isNotEmpty == true)
? description!
: (hasCode
? tr(
'msg.userfront.error.detail_generic',
fallback: '오류가 발생했습니다.',
)
: tr(
'msg.userfront.error.detail_request',
fallback: '요청을 처리하는 중 문제가 발생했습니다.',
)));
return Scaffold(
@@ -100,7 +95,6 @@ class ErrorScreen extends StatelessWidget {
Text(
tr(
'msg.userfront.error.type',
fallback: '오류 종류: {{type}}',
params: {'type': errorType},
),
style: theme.textTheme.bodySmall?.copyWith(
@@ -112,7 +106,6 @@ class ErrorScreen extends StatelessWidget {
Text(
tr(
'msg.userfront.error.id',
fallback: '오류 ID: {{id}}',
params: {'id': errorId!},
),
style: theme.textTheme.bodySmall?.copyWith(
@@ -141,7 +134,6 @@ class ErrorScreen extends StatelessWidget {
child: Text(
tr(
'ui.userfront.error.go_login',
fallback: '로그인으로 이동',
),
),
),
@@ -159,7 +151,7 @@ class ErrorScreen extends StatelessWidget {
),
),
child: Text(
tr('ui.userfront.error.go_home', fallback: '홈으로 이동'),
tr('ui.userfront.error.go_home'),
),
),
],

View File

@@ -28,7 +28,6 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
_showError(
tr(
'msg.userfront.forgot.input_required',
fallback: '이메일 또는 휴대폰 번호를 입력해주세요.',
),
);
return;
@@ -56,7 +55,6 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
content: Text(
tr(
'msg.userfront.forgot.sent',
fallback: '비밀번호 재설정 링크가 전송되었습니다. 이메일 또는 SMS를 확인해주세요.',
),
),
backgroundColor: Colors.green,
@@ -69,7 +67,6 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
_showError(
tr(
'msg.userfront.forgot.error',
fallback: '전송에 실패했습니다: {{error}}',
params: {'error': e.toString()},
),
);
@@ -99,7 +96,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(tr('ui.userfront.forgot.title', fallback: '비밀번호 재설정')),
title: Text(tr('ui.userfront.forgot.title')),
centerTitle: true,
),
body: Center(
@@ -111,7 +108,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
tr('ui.userfront.forgot.heading', fallback: '비밀번호를 잊으셨나요?'),
tr('ui.userfront.forgot.heading'),
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
@@ -138,7 +135,6 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
child: Text(
tr(
'msg.userfront.forgot.dry_send',
fallback: 'drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.',
),
style: const TextStyle(
color: Color(0xFF8A6D3B),
@@ -154,8 +150,6 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
Text(
tr(
'msg.userfront.forgot.description',
fallback:
'계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다.',
),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey),
@@ -166,7 +160,6 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
decoration: InputDecoration(
labelText: tr(
'ui.userfront.forgot.input_label',
fallback: '이메일 또는 휴대폰 번호',
),
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.person_outline),
@@ -189,7 +182,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
),
)
: Text(
tr('ui.userfront.forgot.submit', fallback: '재설정 링크 전송'),
tr('ui.userfront.forgot.submit'),
),
),
],

File diff suppressed because it is too large Load Diff

View File

@@ -21,14 +21,13 @@ class LoginSuccessScreen extends StatelessWidget {
),
const SizedBox(height: 24),
Text(
tr('ui.userfront.login_success.title', fallback: '로그인 완료'),
tr('ui.userfront.login_success.title'),
style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Text(
tr(
'msg.userfront.login_success.subtitle',
fallback: '성공적으로 로그인되었습니다.',
),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey, fontSize: 16),
@@ -44,7 +43,6 @@ class LoginSuccessScreen extends StatelessWidget {
label: Text(
tr(
'ui.userfront.login_success.qr',
fallback: 'QR 인증 (카메라 켜기)',
),
),
style: FilledButton.styleFrom(
@@ -67,7 +65,6 @@ class LoginSuccessScreen extends StatelessWidget {
child: Text(
tr(
'ui.userfront.login_success.later',
fallback: '나중에 하기 (대시보드로 이동)',
),
style: const TextStyle(color: Colors.grey),
),

View File

@@ -146,7 +146,6 @@ class _QRScanScreenState extends State<QRScanScreen> {
_isSuccess = true;
_resultMessage = tr(
'msg.userfront.qr.approve_success',
fallback: 'QR 승인 완료! PC 화면에서 로그인이 진행됩니다.',
);
_isProcessing = false;
});
@@ -158,7 +157,6 @@ class _QRScanScreenState extends State<QRScanScreen> {
_isSuccess = false;
_resultMessage = tr(
'msg.userfront.qr.approve_error',
fallback: 'QR 승인 실패: {{error}}',
params: {'error': '$e'},
);
_isProcessing = false;
@@ -193,7 +191,6 @@ class _QRScanScreenState extends State<QRScanScreen> {
content: Text(
tr(
'msg.userfront.qr.permission_error',
fallback: '카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요.',
),
),
backgroundColor: Colors.red,
@@ -212,8 +209,8 @@ class _QRScanScreenState extends State<QRScanScreen> {
final icon = success ? Icons.check_circle_outline : Icons.error_outline;
final color = success ? Colors.green : Colors.red;
final title = success
? tr('ui.userfront.qr.result_success', fallback: '승인 완료')
: tr('ui.userfront.qr.result_failure', fallback: '승인 실패');
? tr('ui.userfront.qr.result_success')
: tr('ui.userfront.qr.result_failure');
final message = _resultMessage ?? '';
return Center(
@@ -242,12 +239,12 @@ class _QRScanScreenState extends State<QRScanScreen> {
if (!success)
FilledButton(
onPressed: _resetScan,
child: Text(tr('ui.userfront.qr.rescan', fallback: '다시 스캔')),
child: Text(tr('ui.userfront.qr.rescan')),
),
if (success)
FilledButton(
onPressed: () => context.pop(),
child: Text(tr('ui.common.close', fallback: '닫기')),
child: Text(tr('ui.common.close')),
),
],
),
@@ -285,11 +282,9 @@ class _QRScanScreenState extends State<QRScanScreen> {
isPermissionDenied
? tr(
'msg.userfront.qr.permission_required',
fallback: '카메라 권한이 필요합니다.',
)
: tr(
'msg.userfront.qr.camera_error',
fallback: '카메라 오류: {{error}}',
params: {'error': '${error.errorCode}'},
),
),
@@ -302,11 +297,9 @@ class _QRScanScreenState extends State<QRScanScreen> {
_isRequestingCamera
? tr(
'ui.common.requesting',
fallback: '요청 중...',
)
: tr(
'ui.userfront.qr.request_permission',
fallback: '카메라 권한 요청하기',
),
),
),

View File

@@ -72,7 +72,6 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
_showError(
tr(
'msg.userfront.reset.invalid_link',
fallback: '유효하지 않은 재설정 링크입니다. (loginId/token 누락)',
),
);
return;
@@ -93,7 +92,6 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
content: Text(
tr(
'msg.userfront.reset.success',
fallback: '비밀번호가 성공적으로 변경되었습니다. 다시 로그인해주세요.',
),
),
backgroundColor: Colors.green,
@@ -106,7 +104,6 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
_showError(
tr(
'msg.userfront.reset.error.generic',
fallback: '비밀번호 변경에 실패했습니다: {{error}}',
params: {'error': e.toString()},
),
);
@@ -128,7 +125,6 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
if (_isPolicyLoading) {
return tr(
'msg.userfront.reset.policy_loading',
fallback: '비밀번호 정책을 불러오는 중입니다...',
);
}
final minLength = (_policy?['minLength'] as int?) ?? 12;
@@ -141,7 +137,6 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
final parts = <String>[
tr(
'msg.userfront.reset.policy.min_length',
fallback: '최소 {{count}}자 이상',
params: {'count': '$minLength'},
),
];
@@ -149,27 +144,26 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
parts.add(
tr(
'msg.userfront.reset.policy.min_types',
fallback: '영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상',
params: {'count': '$minTypes'},
),
);
}
if (requiresLower) {
parts.add(
tr('msg.userfront.reset.policy.lowercase', fallback: '소문자 1개 이상'),
tr('msg.userfront.reset.policy.lowercase'),
);
}
if (requiresUpper) {
parts.add(
tr('msg.userfront.reset.policy.uppercase', fallback: '대문자 1개 이상'),
tr('msg.userfront.reset.policy.uppercase'),
);
}
if (requiresNumber) {
parts.add(tr('msg.userfront.reset.policy.number', fallback: '숫자 1개 이상'));
parts.add(tr('msg.userfront.reset.policy.number'));
}
if (requiresSymbol) {
parts.add(
tr('msg.userfront.reset.policy.symbol', fallback: '특수문자 1개 이상'),
tr('msg.userfront.reset.policy.symbol'),
);
}
@@ -180,7 +174,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(tr('ui.userfront.reset.title', fallback: '새 비밀번호 설정')),
title: Text(tr('ui.userfront.reset.title')),
centerTitle: true,
),
body: Center(
@@ -200,7 +194,6 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
Text(
tr(
'ui.userfront.reset.subtitle',
fallback: '새로운 비밀번호 설정',
),
style: TextStyle(
fontSize: 28,
@@ -221,7 +214,6 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
decoration: InputDecoration(
labelText: tr(
'ui.userfront.reset.new_password',
fallback: '새 비밀번호',
),
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock_outline),
@@ -243,7 +235,6 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
if (val.isEmpty) {
return tr(
'msg.userfront.reset.error.empty_password',
fallback: '비밀번호를 입력해주세요.',
);
}
final minLength =
@@ -251,7 +242,6 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
if (val.length < minLength) {
return tr(
'msg.userfront.reset.error.min_length',
fallback: '비밀번호는 최소 {{count}}자 이상이어야 합니다.',
params: {'count': '$minLength'},
);
}
@@ -270,8 +260,6 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
if (minTypes > 0 && typeCount < minTypes) {
return tr(
'msg.userfront.reset.error.min_types',
fallback:
'비밀번호는 영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상 포함해야 합니다.',
params: {'count': '$minTypes'},
);
}
@@ -279,26 +267,22 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
if ((_policy?['lowercase'] ?? true) && !hasLower) {
return tr(
'msg.userfront.reset.error.lowercase',
fallback: '최소 1개 이상의 소문자를 포함해야 합니다.',
);
}
if ((_policy?['uppercase'] ?? false) && !hasUpper) {
return tr(
'msg.userfront.reset.error.uppercase',
fallback: '최소 1개 이상의 대문자를 포함해야 합니다.',
);
}
if ((_policy?['number'] ?? true) && !hasNumber) {
return tr(
'msg.userfront.reset.error.number',
fallback: '최소 1개 이상의 숫자를 포함해야 합니다.',
);
}
if ((_policy?['nonAlphanumeric'] ?? true) &&
!hasSymbol) {
return tr(
'msg.userfront.reset.error.symbol',
fallback: '최소 1개 이상의 특수문자를 포함해야 합니다.',
);
}
return null;
@@ -311,7 +295,6 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
decoration: InputDecoration(
labelText: tr(
'ui.userfront.reset.confirm_password',
fallback: '새 비밀번호 확인',
),
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock_outline),
@@ -333,7 +316,6 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
if (value != _passwordController.text) {
return tr(
'msg.userfront.reset.error.mismatch',
fallback: '비밀번호가 일치하지 않습니다.',
);
}
return null;
@@ -357,7 +339,6 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
: Text(
tr(
'ui.userfront.reset.submit',
fallback: '비밀번호 변경',
),
),
),
@@ -377,7 +358,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
const Icon(Icons.error_outline, color: Colors.red, size: 60),
const SizedBox(height: 16),
Text(
tr('msg.userfront.reset.invalid_title', fallback: '유효하지 않은 링크입니다.'),
tr('msg.userfront.reset.invalid_title'),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
@@ -385,7 +366,6 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
Text(
tr(
'msg.userfront.reset.invalid_body',
fallback: '비밀번호 재설정 링크가 만료되었거나 잘못되었습니다. 다시 시도해주세요.',
),
textAlign: TextAlign.center,
),

View File

@@ -167,7 +167,6 @@ class _SignupScreenState extends State<SignupScreen> {
setState(
() => _emailError = tr(
'msg.userfront.signup.email.invalid',
fallback: '유효한 이메일 형식이 아닙니다.',
),
);
return;
@@ -182,7 +181,6 @@ class _SignupScreenState extends State<SignupScreen> {
setState(
() => _emailError = tr(
'msg.userfront.signup.email.duplicate',
fallback: '이미 가입된 이메일입니다.',
),
);
return;
@@ -193,7 +191,6 @@ class _SignupScreenState extends State<SignupScreen> {
setState(
() => _emailError = tr(
'msg.userfront.signup.email.send_failed',
fallback: '발송 실패: {{error}}',
params: {'error': e.toString()},
),
);
@@ -222,7 +219,6 @@ class _SignupScreenState extends State<SignupScreen> {
setState(
() => _emailError = tr(
'msg.userfront.signup.email.code_mismatch',
fallback: '인증코드가 일치하지 않습니다.',
),
);
}
@@ -230,7 +226,6 @@ class _SignupScreenState extends State<SignupScreen> {
setState(
() => _emailError = tr(
'msg.userfront.signup.email.verify_failed',
fallback: '인증 실패: {{error}}',
params: {'error': e.toString()},
),
);
@@ -251,7 +246,6 @@ class _SignupScreenState extends State<SignupScreen> {
setState(
() => _phoneError = tr(
'msg.userfront.signup.phone.send_failed',
fallback: '발송 실패: {{error}}',
params: {'error': e.toString()},
),
);
@@ -280,7 +274,6 @@ class _SignupScreenState extends State<SignupScreen> {
setState(
() => _phoneError = tr(
'msg.userfront.signup.phone.code_mismatch',
fallback: '인증코드가 일치하지 않습니다.',
),
);
}
@@ -288,7 +281,6 @@ class _SignupScreenState extends State<SignupScreen> {
setState(
() => _phoneError = tr(
'msg.userfront.signup.phone.verify_failed',
fallback: '인증 실패: {{error}}',
params: {'error': e.toString()},
),
);
@@ -300,7 +292,6 @@ class _SignupScreenState extends State<SignupScreen> {
setState(
() => _confirmPasswordError = tr(
'msg.userfront.signup.password.mismatch',
fallback: '비밀번호가 일치하지 않습니다.',
),
);
return;
@@ -332,32 +323,26 @@ class _SignupScreenState extends State<SignupScreen> {
if (eStr.contains('uppercase')) {
_passwordError = tr(
'msg.userfront.signup.password.uppercase_required',
fallback: '대문자가 최소 1개 이상 포함되어야 합니다.',
);
} else if (eStr.contains('lowercase')) {
_passwordError = tr(
'msg.userfront.signup.password.lowercase_required',
fallback: '소문자가 최소 1개 이상 포함되어야 합니다.',
);
} else if (eStr.contains('digit') || eStr.contains('number')) {
_passwordError = tr(
'msg.userfront.signup.password.number_required',
fallback: '숫자가 최소 1개 이상 포함되어야 합니다.',
);
} else if (eStr.contains('symbol') || eStr.contains('special')) {
_passwordError = tr(
'msg.userfront.signup.password.symbol_required',
fallback: '특수문자가 최소 1개 이상 포함되어야 합니다.',
);
} else if (eStr.contains('length') || eStr.contains('12 characters')) {
_passwordError = tr(
'msg.userfront.signup.password.length_required',
fallback: '비밀번호는 최소 12자 이상이어야 합니다.',
);
} else {
_passwordError = tr(
'msg.userfront.signup.failed',
fallback: '가입 실패: {{error}}',
params: {'error': e.toString()},
);
}
@@ -373,16 +358,16 @@ class _SignupScreenState extends State<SignupScreen> {
barrierDismissible: false,
builder: (context) => AlertDialog(
title: Text(
tr('msg.userfront.signup.success.title', fallback: '회원가입 완료'),
tr('msg.userfront.signup.success.title'),
),
content: Text(
tr('msg.userfront.signup.success.body', fallback: '성공적으로 가입되었습니다.'),
tr('msg.userfront.signup.success.body'),
),
actions: [
TextButton(
onPressed: () => context.go('/signin'),
child: Text(
tr('ui.userfront.signup.success.action', fallback: '로그인하기'),
tr('ui.userfront.signup.success.action'),
),
),
],
@@ -399,22 +384,22 @@ class _SignupScreenState extends State<SignupScreen> {
children: [
_stepCircle(
1,
tr('ui.userfront.signup.steps.agreement', fallback: '약관동의'),
tr('ui.userfront.signup.steps.agreement'),
),
_stepLine(1),
_stepCircle(
2,
tr('ui.userfront.signup.steps.verify', fallback: '본인인증'),
tr('ui.userfront.signup.steps.verify'),
),
_stepLine(2),
_stepCircle(
3,
tr('ui.userfront.signup.steps.profile', fallback: '정보입력'),
tr('ui.userfront.signup.steps.profile'),
),
_stepLine(3),
_stepCircle(
4,
tr('ui.userfront.signup.steps.password', fallback: '비밀번호'),
tr('ui.userfront.signup.steps.password'),
),
],
),
@@ -471,7 +456,6 @@ class _SignupScreenState extends State<SignupScreen> {
Text(
tr(
'msg.userfront.signup.agreement.title',
fallback: '서비스 이용을 위해\n약관에 동의해주세요',
),
style: const TextStyle(
fontSize: 20,
@@ -489,7 +473,7 @@ class _SignupScreenState extends State<SignupScreen> {
),
child: CheckboxListTile(
title: Text(
tr('ui.userfront.signup.agreement.all', fallback: '모두 동의합니다'),
tr('ui.userfront.signup.agreement.all'),
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
),
value: _termsAccepted && _privacyAccepted,
@@ -507,7 +491,6 @@ class _SignupScreenState extends State<SignupScreen> {
_agreementSection(
title: tr(
'ui.userfront.signup.agreement.tos_title',
fallback: '바론 소프트웨어 이용약관 (필수)',
),
content: _tosText,
value: _termsAccepted,
@@ -517,7 +500,6 @@ class _SignupScreenState extends State<SignupScreen> {
_agreementSection(
title: tr(
'ui.userfront.signup.agreement.privacy_title',
fallback: '개인정보 수집 및 이용 동의 (필수)',
),
content: _privacyText,
value: _privacyAccepted,
@@ -765,7 +747,6 @@ class _SignupScreenState extends State<SignupScreen> {
Text(
tr(
'msg.userfront.signup.auth.title',
fallback: '본인 확인을 위해\n인증을 진행해주세요',
),
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
@@ -785,7 +766,6 @@ class _SignupScreenState extends State<SignupScreen> {
child: Text(
tr(
'msg.userfront.signup.auth.affiliate_notice',
fallback: '가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요.',
),
style: const TextStyle(
fontSize: 12,
@@ -799,7 +779,7 @@ class _SignupScreenState extends State<SignupScreen> {
),
const SizedBox(height: 24),
Text(
tr('ui.userfront.signup.auth.email.title', fallback: '이메일 인증'),
tr('ui.userfront.signup.auth.email.title'),
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
@@ -812,7 +792,6 @@ class _SignupScreenState extends State<SignupScreen> {
decoration: InputDecoration(
labelText: tr(
'ui.userfront.signup.auth.email.label',
fallback: '이메일 주소',
),
border: const OutlineInputBorder(),
errorText: _emailError,
@@ -835,10 +814,9 @@ class _SignupScreenState extends State<SignupScreen> {
),
child: Text(
_emailSeconds > 0
? tr('ui.common.resend', fallback: '재발송')
? tr('ui.common.resend')
: tr(
'ui.userfront.signup.auth.request_code',
fallback: '인증요청',
),
),
),
@@ -852,7 +830,6 @@ class _SignupScreenState extends State<SignupScreen> {
decoration: InputDecoration(
labelText: tr(
'ui.userfront.signup.auth.code_label',
fallback: '인증코드 6자리',
),
suffixText: _formatTime(_emailSeconds),
border: const OutlineInputBorder(),
@@ -873,7 +850,6 @@ class _SignupScreenState extends State<SignupScreen> {
child: Text(
tr(
'msg.userfront.signup.email.verified',
fallback: '✅ 이메일 인증 완료',
),
style: const TextStyle(
color: Colors.green,
@@ -884,7 +860,7 @@ class _SignupScreenState extends State<SignupScreen> {
),
const SizedBox(height: 24),
Text(
tr('ui.userfront.signup.phone.title', fallback: '휴대폰 인증'),
tr('ui.userfront.signup.phone.title'),
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
@@ -896,7 +872,6 @@ class _SignupScreenState extends State<SignupScreen> {
decoration: InputDecoration(
labelText: tr(
'ui.userfront.signup.phone.label',
fallback: '휴대폰 번호 (-없이)',
),
border: const OutlineInputBorder(),
errorText: _phoneError,
@@ -919,10 +894,9 @@ class _SignupScreenState extends State<SignupScreen> {
),
child: Text(
_phoneSeconds > 0
? tr('ui.common.resend', fallback: '재발송')
? tr('ui.common.resend')
: tr(
'ui.userfront.signup.auth.request_code',
fallback: '인증요청',
),
),
),
@@ -936,7 +910,6 @@ class _SignupScreenState extends State<SignupScreen> {
decoration: InputDecoration(
labelText: tr(
'ui.userfront.signup.auth.code_label',
fallback: '인증코드 6자리',
),
suffixText: _formatTime(_phoneSeconds),
border: const OutlineInputBorder(),
@@ -957,7 +930,6 @@ class _SignupScreenState extends State<SignupScreen> {
child: Text(
tr(
'msg.userfront.signup.phone.verified',
fallback: '✅ 휴대폰 인증 완료',
),
style: const TextStyle(
color: Colors.green,
@@ -977,7 +949,6 @@ class _SignupScreenState extends State<SignupScreen> {
Text(
tr(
'msg.userfront.signup.profile.title',
fallback: '회원님의\n소속 정보를 알려주세요',
),
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
@@ -986,7 +957,7 @@ class _SignupScreenState extends State<SignupScreen> {
controller: _nameController,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
labelText: tr('ui.userfront.signup.profile.name', fallback: '이름'),
labelText: tr('ui.userfront.signup.profile.name'),
border: const OutlineInputBorder(),
),
),
@@ -1002,13 +973,11 @@ class _SignupScreenState extends State<SignupScreen> {
decoration: InputDecoration(
labelText: tr(
'ui.userfront.signup.profile.affiliation_type',
fallback: '소속 유형',
),
border: const OutlineInputBorder(),
helperText: _isAffiliateEmail
? tr(
'msg.userfront.signup.profile.affiliate_hint',
fallback: '가족사 이메일 사용 시 자동으로 선택됩니다.',
)
: null,
),
@@ -1016,13 +985,13 @@ class _SignupScreenState extends State<SignupScreen> {
DropdownMenuItem(
value: 'GENERAL',
child: Text(
tr('domain.affiliation.general', fallback: '일반 사용자'),
tr('domain.affiliation.general'),
),
),
DropdownMenuItem(
value: 'AFFILIATE',
child: Text(
tr('domain.affiliation.affiliate', fallback: '가족사 임직원'),
tr('domain.affiliation.affiliate'),
),
),
],
@@ -1052,18 +1021,17 @@ class _SignupScreenState extends State<SignupScreen> {
decoration: InputDecoration(
labelText: tr(
'ui.userfront.signup.profile.company',
fallback: '가족사 선택',
),
border: const OutlineInputBorder(),
),
items: [
DropdownMenuItem(
value: 'HANMAC',
child: Text(tr('domain.company.hanmac', fallback: '한맥')),
child: Text(tr('domain.company.hanmac')),
),
DropdownMenuItem(
value: 'SAMAN',
child: Text(tr('domain.company.saman', fallback: '삼안')),
child: Text(tr('domain.company.saman')),
),
DropdownMenuItem(
value: 'PTC',
@@ -1071,15 +1039,15 @@ class _SignupScreenState extends State<SignupScreen> {
),
DropdownMenuItem(
value: 'JANGHEON',
child: Text(tr('domain.company.jangheon', fallback: '장헌')),
child: Text(tr('domain.company.jangheon')),
),
DropdownMenuItem(
value: 'BARON',
child: Text(tr('domain.company.baron', fallback: '바론')),
child: Text(tr('domain.company.baron')),
),
DropdownMenuItem(
value: 'HALLA',
child: Text(tr('domain.company.halla', fallback: '한라')),
child: Text(tr('domain.company.halla')),
),
],
onChanged: _isAffiliateEmail
@@ -1095,10 +1063,9 @@ class _SignupScreenState extends State<SignupScreen> {
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
labelText: _affiliationType == 'AFFILIATE'
? tr('ui.userfront.signup.profile.department', fallback: '부서명')
? tr('ui.userfront.signup.profile.department')
: tr(
'ui.userfront.signup.profile.department_optional',
fallback: '소속 정보 (선택)',
),
border: const OutlineInputBorder(),
),
@@ -1111,7 +1078,6 @@ class _SignupScreenState extends State<SignupScreen> {
if (_isPolicyLoading) {
return tr(
'msg.userfront.signup.policy.loading',
fallback: '비밀번호 정책을 불러오는 중입니다...',
);
}
final minLength = (_policy?['minLength'] as int?) ?? 12;
@@ -1124,7 +1090,6 @@ class _SignupScreenState extends State<SignupScreen> {
final parts = <String>[
tr(
'msg.userfront.signup.policy.min_length',
fallback: '최소 {{count}}자 이상',
params: {'count': minLength.toString()},
),
];
@@ -1132,27 +1097,25 @@ class _SignupScreenState extends State<SignupScreen> {
parts.add(
tr(
'msg.userfront.signup.policy.min_types',
fallback: '영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상',
params: {'count': minTypes.toString()},
),
);
}
if (requiresUpper) {
parts.add(tr('msg.userfront.signup.policy.uppercase', fallback: '대문자'));
parts.add(tr('msg.userfront.signup.policy.uppercase'));
}
if (requiresLower) {
parts.add(tr('msg.userfront.signup.policy.lowercase', fallback: '소문자'));
parts.add(tr('msg.userfront.signup.policy.lowercase'));
}
if (requiresNumber) {
parts.add(tr('msg.userfront.signup.policy.number', fallback: '숫자'));
parts.add(tr('msg.userfront.signup.policy.number'));
}
if (requiresSymbol) {
parts.add(tr('msg.userfront.signup.policy.symbol', fallback: '특수문자'));
parts.add(tr('msg.userfront.signup.policy.symbol'));
}
return tr(
'msg.userfront.signup.policy.summary',
fallback: '보안 정책: {{rules}}',
params: {'rules': parts.join(', ')},
);
}
@@ -1186,7 +1149,6 @@ class _SignupScreenState extends State<SignupScreen> {
Text(
tr(
'msg.userfront.signup.password.title',
fallback: '마지막으로\n비밀번호를 설정해주세요',
),
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
@@ -1223,7 +1185,6 @@ class _SignupScreenState extends State<SignupScreen> {
decoration: InputDecoration(
labelText: tr(
'ui.userfront.signup.password.label',
fallback: '비밀번호',
),
border: const OutlineInputBorder(),
errorText: _passwordError,
@@ -1236,7 +1197,6 @@ class _SignupScreenState extends State<SignupScreen> {
_cryptoCheck(
tr(
'msg.userfront.signup.password.rule.min_length',
fallback: '{{count}}자 이상',
params: {'count': minLength.toString()},
),
hasLength,
@@ -1245,7 +1205,6 @@ class _SignupScreenState extends State<SignupScreen> {
_cryptoCheck(
tr(
'msg.userfront.signup.password.rule.min_types',
fallback: '문자 유형 {{count}}가지 이상',
params: {'count': minTypes.toString()},
),
hasTypeCount,
@@ -1254,7 +1213,6 @@ class _SignupScreenState extends State<SignupScreen> {
_cryptoCheck(
tr(
'msg.userfront.signup.password.rule.uppercase',
fallback: '대문자',
),
hasUpper,
),
@@ -1262,20 +1220,18 @@ class _SignupScreenState extends State<SignupScreen> {
_cryptoCheck(
tr(
'msg.userfront.signup.password.rule.lowercase',
fallback: '소문자',
),
hasLower,
),
if (requiresNumber)
_cryptoCheck(
tr('msg.userfront.signup.password.rule.number', fallback: '숫자'),
tr('msg.userfront.signup.password.rule.number'),
hasDigit,
),
if (requiresSymbol)
_cryptoCheck(
tr(
'msg.userfront.signup.password.rule.symbol',
fallback: '특수문자',
),
hasSpecial,
),
@@ -1290,7 +1246,6 @@ class _SignupScreenState extends State<SignupScreen> {
_confirmPasswordError = (val != _passwordController.text)
? tr(
'msg.userfront.signup.password.mismatch',
fallback: '비밀번호가 일치하지 않습니다.',
)
: null;
});
@@ -1298,7 +1253,6 @@ class _SignupScreenState extends State<SignupScreen> {
decoration: InputDecoration(
labelText: tr(
'ui.userfront.signup.password.confirm_label',
fallback: '비밀번호 확인',
),
border: const OutlineInputBorder(),
errorText: _confirmPasswordError,
@@ -1352,7 +1306,7 @@ class _SignupScreenState extends State<SignupScreen> {
backgroundColor: Colors.white,
appBar: AppBar(
title: Text(
tr('ui.userfront.signup.title', fallback: '회원가입'),
tr('ui.userfront.signup.title'),
style: const TextStyle(fontWeight: FontWeight.bold),
),
elevation: 0,
@@ -1394,7 +1348,7 @@ class _SignupScreenState extends State<SignupScreen> {
side: const BorderSide(color: Colors.black),
),
child: Text(
tr('ui.common.prev', fallback: '이전'),
tr('ui.common.prev'),
style: const TextStyle(color: Colors.black),
),
),
@@ -1425,11 +1379,9 @@ class _SignupScreenState extends State<SignupScreen> {
_currentStep < 4
? tr(
'ui.userfront.signup.next_step',
fallback: '다음 단계',
)
: tr(
'ui.userfront.signup.complete',
fallback: '가입 완료',
),
),
),

View File

@@ -174,7 +174,6 @@ class AuthTimelineNotifier extends Notifier<AuthTimelineState> {
isLoadingMore: false,
error: tr(
'msg.userfront.dashboard.timeline.load_error',
fallback: '접속이력을 불러오지 못했습니다.',
),
);
}

View File

@@ -9,6 +9,8 @@ import '../domain/providers/linked_rps_provider.dart';
import '../../../../core/notifiers/auth_notifier.dart';
import '../../../../core/services/auth_token_store.dart';
import '../../../../core/services/http_client.dart';
import '../../../../core/i18n/locale_utils.dart';
import '../../../../core/widgets/language_selector.dart';
import '../../../../core/ui/layout_breakpoints.dart';
import '../../profile/domain/notifiers/profile_notifier.dart';
import '../domain/dashboard_providers.dart';
@@ -35,6 +37,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
bool _auditLoading = false;
bool _auditLoadingMore = false;
bool _isRevoking = false;
bool _redirectingToSignin = false;
bool _showAllActivities = false;
final Set<String> _revokedClientIds = {};
@@ -43,7 +46,13 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
void initState() {
super.initState();
_pageScrollController.addListener(_onPageScroll);
_loadAuditLogs(reset: true);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_isLoggedIn()) {
_redirectToSignin();
return;
}
_loadAuditLogs(reset: true);
});
}
@override
@@ -63,19 +72,18 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
context: context,
builder: (context) => AlertDialog(
title: Text(
tr('ui.userfront.dashboard.revoke.title', fallback: '연동 해지'),
tr('ui.userfront.dashboard.revoke.title'),
),
content: Text(
tr(
'msg.userfront.dashboard.revoke.confirm',
fallback: '{{app}} 앱과의 연동을 해지하시겠습니까?\\n해지하면 다음 로그인 시 다시 동의가 필요합니다.',
params: {'app': appName},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(tr('ui.common.cancel', fallback: '취소')),
child: Text(tr('ui.common.cancel')),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
@@ -83,7 +91,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
child: Text(
tr(
'ui.userfront.dashboard.revoke.confirm_button',
fallback: '해지하기',
),
),
),
@@ -102,7 +109,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
content: Text(
tr(
'msg.userfront.dashboard.revoke.success',
fallback: '{{app}} 연동이 해지되었습니다.',
params: {'app': appName},
),
),
@@ -120,7 +126,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
content: Text(
tr(
'msg.userfront.dashboard.revoke.error',
fallback: '해지 실패: {{error}}',
params: {'error': '$e'},
),
),
@@ -163,7 +168,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Text(
tr(
'ui.userfront.dashboard.scopes.title',
fallback: '권한 (Scopes)',
),
style: const TextStyle(fontWeight: FontWeight.bold),
),
@@ -172,7 +176,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Text(
tr(
'msg.userfront.dashboard.scopes.empty',
fallback: '요청된 권한이 없습니다.',
),
style: const TextStyle(color: Colors.grey),
)
@@ -198,7 +201,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Text(
tr(
'ui.userfront.dashboard.status_history',
fallback: '상태 이력',
),
style: const TextStyle(fontWeight: FontWeight.bold),
),
@@ -209,7 +211,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Text(
tr(
'msg.userfront.dashboard.last_auth',
fallback: '최근 인증: {{value}}',
params: {'value': item.lastAuthAt},
),
),
@@ -217,15 +218,13 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Builder(
builder: (context) {
final statusLabel = item.status == 'active'
? tr('ui.common.status.active', fallback: '활성')
? tr('ui.common.status.active')
: tr(
'ui.userfront.dashboard.status.revoked',
fallback: '해지됨',
);
return Text(
tr(
'msg.userfront.dashboard.current_status',
fallback: '현재 상태: {{status}}',
params: {'status': statusLabel},
),
style: TextStyle(
@@ -244,7 +243,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(tr('ui.common.close', fallback: '닫기')),
child: Text(tr('ui.common.close')),
),
],
);
@@ -255,50 +254,60 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Widget _buildSideMenu(BuildContext context, {required bool closeOnTap}) {
return SafeArea(
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
children: [
ListTile(
leading: const Icon(Icons.home_outlined),
title: Text(tr('ui.userfront.nav.dashboard', fallback: '대시보드')),
selected: true,
onTap: () {
if (closeOnTap) {
Navigator.of(context).pop();
}
context.go('/');
},
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 12),
children: [
ListTile(
leading: const Icon(Icons.home_outlined),
title: Text(tr('ui.userfront.nav.dashboard')),
selected: true,
onTap: () {
if (closeOnTap) {
Navigator.of(context).pop();
}
context.go('/');
},
),
ListTile(
leading: const Icon(Icons.person_outline),
title: Text(tr('ui.userfront.nav.profile')),
onTap: () {
if (closeOnTap) {
Navigator.of(context).pop();
}
context.push('/profile');
},
),
ListTile(
leading: const Icon(Icons.qr_code_scanner),
title: Text(tr('ui.userfront.nav.qr_scan')),
onTap: () {
if (closeOnTap) {
Navigator.of(context).pop();
}
_onScanQR();
},
),
const Divider(),
ListTile(
leading: const Icon(Icons.logout),
title: Text(tr('ui.userfront.nav.logout')),
onTap: () async {
if (closeOnTap) {
Navigator.of(context).pop();
}
await _logout();
},
),
],
),
),
ListTile(
leading: const Icon(Icons.person_outline),
title: Text(tr('ui.userfront.nav.profile', fallback: '내 정보')),
onTap: () {
if (closeOnTap) {
Navigator.of(context).pop();
}
context.push('/profile');
},
),
ListTile(
leading: const Icon(Icons.qr_code_scanner),
title: Text(tr('ui.userfront.nav.qr_scan', fallback: 'QR 스캔')),
onTap: () {
if (closeOnTap) {
Navigator.of(context).pop();
}
_onScanQR();
},
),
const Divider(),
ListTile(
leading: const Icon(Icons.logout),
title: Text(tr('ui.userfront.nav.logout', fallback: '로그아웃')),
onTap: () async {
if (closeOnTap) {
Navigator.of(context).pop();
}
await _logout();
},
const Padding(
padding: EdgeInsets.only(bottom: 16),
child: LanguageSelector(compact: true),
),
],
),
@@ -306,6 +315,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
}
Future<void> _refreshAll() async {
if (!_isLoggedIn()) {
_redirectToSignin();
return;
}
await ref.read(profileProvider.notifier).loadProfile();
setState(() {
_revokedClientIds.clear();
@@ -367,6 +380,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
}
Future<void> _loadAuditLogs({bool reset = false}) async {
if (!_isLoggedIn()) {
return;
}
if (_auditLoading || _auditLoadingMore) {
return;
}
@@ -443,15 +459,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
String _authMethodLabel() {
if (AuthTokenStore.usesCookie()) {
return tr('ui.userfront.auth_method.ory', fallback: 'Ory 세션');
return tr('ui.userfront.auth_method.ory');
}
final provider = AuthTokenStore.getProvider();
if (provider == null || provider.isEmpty) {
return tr('ui.userfront.auth_method.session', fallback: '세션');
return tr('ui.userfront.auth_method.session');
}
final lower = provider.toLowerCase();
if (lower.contains('ory')) {
return tr('ui.userfront.auth_method.ory', fallback: 'Ory 세션');
return tr('ui.userfront.auth_method.ory');
}
return provider;
}
@@ -494,12 +510,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final tooltip = [
tr(
'msg.userfront.dashboard.approved_device',
fallback: '승인 기기: {{device}}',
params: {'device': deviceLabel},
),
tr(
'msg.userfront.dashboard.approved_ip',
fallback: '승인 IP: {{ip}}',
params: {'ip': approvedIp.isEmpty ? '-' : approvedIp},
),
].join('\n');
@@ -522,21 +536,17 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final tooltipLabel = isOidc
? tr(
'ui.userfront.dashboard.approved_session.userfront',
fallback: '승인한 Userfront 세션 ID',
)
: tr(
'ui.userfront.dashboard.approved_session.default',
fallback: '승인한 세션 ID',
);
final tooltip = approvedSessionId.isEmpty
? tr(
'msg.userfront.dashboard.approved_session.none',
fallback: '{{label}} 없음',
params: {'label': tooltipLabel},
)
: tr(
'msg.userfront.dashboard.approved_session.copy_click',
fallback: '{{label}}: {{id}}\\n클릭하면 복사됩니다.',
params: {'label': tooltipLabel, 'id': approvedSessionId},
);
return InkWell(
@@ -550,7 +560,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
content: Text(
tr(
'msg.userfront.dashboard.session_id_copied',
fallback: '세션 ID가 복사되었습니다.',
),
),
),
@@ -584,7 +593,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
return _selectableText(
tr(
'msg.userfront.dashboard.auth_method',
fallback: '인증수단: {{method}}',
params: {'method': authMethod},
),
);
@@ -593,12 +601,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final tooltip = [
tr(
'msg.userfront.dashboard.approved_device',
fallback: '승인 기기: {{device}}',
params: {'device': deviceLabel},
),
tr(
'msg.userfront.dashboard.approved_ip',
fallback: '승인 IP: {{ip}}',
params: {'ip': approvedIp.isEmpty ? '-' : approvedIp},
),
].join('\n');
@@ -607,7 +613,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
child: _selectableText(
tr(
'msg.userfront.dashboard.auth_method',
fallback: '인증수단: {{method}}',
params: {'method': authMethod},
),
style: const TextStyle(
@@ -625,11 +630,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final tooltipLabel = isOidc
? tr(
'ui.userfront.dashboard.approved_session.userfront',
fallback: '승인한 Userfront 세션 ID',
)
: tr(
'ui.userfront.dashboard.approved_session.default',
fallback: '승인한 세션 ID',
);
return InkWell(
onTap: approvedSessionId.isEmpty
@@ -642,7 +645,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
content: Text(
tr(
'msg.userfront.dashboard.session_id_copied',
fallback: '세션 ID가 복사되었습니다.',
),
),
),
@@ -653,18 +655,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
message: approvedSessionId.isEmpty
? tr(
'msg.userfront.dashboard.approved_session.none',
fallback: '{{label}} 없음',
params: {'label': tooltipLabel},
)
: tr(
'msg.userfront.dashboard.approved_session.copy_tap',
fallback: '{{label}}: {{id}}\\n탭하면 복사됩니다.',
params: {'label': tooltipLabel, 'id': approvedSessionId},
),
child: Text(
tr(
'msg.userfront.dashboard.auth_method',
fallback: '인증수단: {{method}}',
params: {
'method': isOidc
? authMethod
@@ -695,7 +694,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final tooltip = clientId.isEmpty
? tr(
'msg.userfront.dashboard.client_id_missing',
fallback: 'Client ID 없음',
)
: tr(
'msg.userfront.dashboard.client_id',
@@ -717,10 +715,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
String _appLabelForPath(String path) {
if (path.startsWith('/api/v1/auth')) {
return tr('ui.userfront.app_label.baron', fallback: 'Baron 로그인');
return tr('ui.userfront.app_label.baron');
}
if (path.startsWith('/api/v1/user')) {
return tr('ui.userfront.app_label.baron', fallback: 'Baron 로그인');
return tr('ui.userfront.app_label.baron');
}
if (path.startsWith('/api/v1/dev')) {
return tr('ui.userfront.app_label.dev_console', fallback: 'Dev Console');
@@ -731,11 +729,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
fallback: 'Admin Console',
);
}
return tr('ui.userfront.app_label.baron', fallback: 'Baron 로그인');
return tr('ui.userfront.app_label.baron');
}
@override
Widget build(BuildContext context) {
if (!_isLoggedIn()) {
_redirectToSignin();
return const SizedBox.shrink();
}
final isWide = MediaQuery.of(context).size.width >= sideMenuBreakpoint;
final profileState = ref.watch(profileProvider);
final profile = profileState.value;
@@ -747,14 +749,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
tr('ui.userfront.profile.user_fallback', fallback: 'User');
final department = profile?.department.isNotEmpty == true
? profile!.department
: tr('ui.userfront.profile.department_empty', fallback: '소속 정보 없음');
: tr('ui.userfront.profile.department_empty');
final sessionIssuedAt = _getJwtIssuedAt();
return Scaffold(
backgroundColor: _subtle,
appBar: AppBar(
title: Text(
tr('ui.userfront.app_title', fallback: 'Baron 로그인'),
tr('ui.userfront.app_title'),
style: TextStyle(fontWeight: FontWeight.bold),
),
elevation: 0,
@@ -763,17 +765,17 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
actions: [
IconButton(
icon: const Icon(Icons.person_outline),
tooltip: tr('ui.userfront.nav.profile', fallback: '내 정보'),
tooltip: tr('ui.userfront.nav.profile'),
onPressed: () => context.push('/profile'),
),
IconButton(
icon: const Icon(Icons.qr_code_scanner),
tooltip: tr('ui.userfront.nav.qr_scan', fallback: 'QR 스캔'),
tooltip: tr('ui.userfront.nav.qr_scan'),
onPressed: _onScanQR,
),
IconButton(
icon: const Icon(Icons.logout),
tooltip: tr('ui.userfront.nav.logout', fallback: '로그아웃'),
tooltip: tr('ui.userfront.nav.logout'),
onPressed: _logout,
),
],
@@ -814,21 +816,18 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
_buildSectionTitle(
tr(
'ui.userfront.sections.apps',
fallback: '나의 App 현황',
),
tr(
'msg.userfront.sections.apps_subtitle',
fallback: '현재 연결된 앱과 최근 인증 상태입니다.',
),
),
const SizedBox(height: 12),
_buildActivitySection(isMobile),
const SizedBox(height: 28),
_buildSectionTitle(
tr('ui.userfront.sections.audit', fallback: '접속이력'),
tr('ui.userfront.sections.audit'),
tr(
'msg.userfront.sections.audit_subtitle',
fallback: 'Baron 로그인 기준의 최근 접근 기록입니다.',
),
),
const SizedBox(height: 12),
@@ -853,14 +852,13 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
) {
final sessionLabel = issuedAt != null
? _formatDateTime(issuedAt)
: tr('ui.userfront.session.unknown', fallback: '알 수 없음');
: tr('ui.userfront.session.unknown');
final infoColumn = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tr(
'msg.userfront.greeting',
fallback: '안녕하세요, {{name}}님',
params: {'name': userName},
),
style: const TextStyle(
@@ -881,7 +879,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
children: [
_buildInfoChip(
Icons.verified_user,
tr('ui.userfront.session.active', fallback: '세션 활성'),
tr('ui.userfront.session.active'),
),
_buildInfoChip(Icons.lock_outline, _authMethodLabel()),
_buildInfoChip(Icons.access_time, sessionLabel),
@@ -967,7 +965,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Text(
tr(
'msg.userfront.dashboard.activities.empty',
fallback: '연동된 앱이 없습니다.',
),
style: TextStyle(
fontSize: 14,
@@ -979,7 +976,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Text(
tr(
'msg.userfront.dashboard.activities.empty_detail',
fallback: '앱을 연동하면 최근 활동과 상태가 표시됩니다.',
),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
@@ -998,14 +994,13 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Text(
tr(
'msg.userfront.dashboard.activities.error',
fallback: '연동 정보를 불러오지 못했습니다.',
),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
const SizedBox(height: 8),
TextButton(
onPressed: () => ref.read(linkedRpsProvider.notifier).refresh(),
child: Text(tr('ui.common.retry', fallback: '다시 시도')),
child: Text(tr('ui.common.retry')),
),
],
),
@@ -1023,7 +1018,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final lastAuthLabel = rp.lastAuthenticatedAt != null
? _formatDateTime(rp.lastAuthenticatedAt!)
: tr('ui.userfront.dashboard.activity.linked', fallback: '연동됨');
: tr('ui.userfront.dashboard.activity.linked');
final statusCode = isRevoked ? 'revoked' : 'active';
final name = rp.name.isNotEmpty ? rp.name : rp.id;
@@ -1128,8 +1123,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
label: Text(
_showAllActivities
? tr('ui.common.collapse', fallback: '접기')
: tr('ui.common.show_more', fallback: '+ 더보기'),
? tr('ui.common.collapse')
: tr('ui.common.show_more'),
style: TextStyle(
color: _showAllActivities
? Colors.grey
@@ -1193,19 +1188,18 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: statusColor,
color: statusColor.withValues(alpha: 31),
borderRadius: BorderRadius.circular(999),
),
child: Text(
item.status == 'active'
? tr('ui.common.status.active', fallback: '활성')
? tr('ui.common.status.active')
: tr(
'ui.userfront.dashboard.status.revoked',
fallback: '해지됨',
),
style: const TextStyle(
style: TextStyle(
fontSize: 11,
color: Colors.white,
color: statusColor,
fontWeight: FontWeight.w600,
),
),
@@ -1214,7 +1208,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
const SizedBox(height: 12),
Text(
tr('ui.userfront.dashboard.last_auth_label', fallback: '최근 인증'),
tr('ui.userfront.dashboard.last_auth_label'),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
const SizedBox(height: 4),
@@ -1238,7 +1232,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
padding: const EdgeInsets.symmetric(vertical: 8),
),
child: Text(
tr('ui.common.details', fallback: '상세정보'),
tr('ui.common.details'),
style: const TextStyle(fontSize: 13),
),
),
@@ -1272,11 +1266,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
item.isRevoked
? tr(
'ui.userfront.dashboard.status.revoked',
fallback: '해지됨',
)
: tr(
'ui.userfront.dashboard.revoke.title',
fallback: '연동 해지',
),
style: const TextStyle(fontSize: 13),
),
@@ -1314,7 +1306,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
content: Text(
tr(
'msg.userfront.dashboard.link_open_error',
fallback: '해당 링크를 열 수 없습니다.',
),
),
),
@@ -1326,7 +1317,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
content: Text(
tr(
'msg.userfront.dashboard.link_missing',
fallback: '이동할 페이지 주소(Client URI)가 설정되지 않았습니다.',
),
),
),
@@ -1357,14 +1347,13 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Text(
tr(
'msg.userfront.dashboard.audit_load_error',
fallback: '접속이력을 불러오지 못했습니다.',
),
),
const SizedBox(height: 8),
TextButton(
onPressed: () =>
ref.read(authTimelineProvider.notifier).refresh(),
child: Text(tr('ui.common.retry', fallback: '다시 시도')),
child: Text(tr('ui.common.retry')),
),
],
),
@@ -1378,7 +1367,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
child: Text(
tr(
'msg.userfront.dashboard.audit_empty',
fallback: '최근 접속 이력이 없습니다.',
),
style: TextStyle(color: Colors.grey[600]),
),
@@ -1429,14 +1417,13 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
DataColumn(
label: Text(
tr('ui.userfront.audit.table.date', fallback: '접속일자'),
tr('ui.userfront.audit.table.date'),
),
),
DataColumn(
label: Text(
tr(
'ui.userfront.audit.table.app',
fallback: '애플리케이션',
),
),
),
@@ -1449,7 +1436,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
label: Text(
tr(
'ui.userfront.audit.table.device',
fallback: '접속환경',
),
),
),
@@ -1457,7 +1443,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
label: Text(
tr(
'ui.userfront.audit.table.auth_method',
fallback: '인증수단',
),
),
),
@@ -1465,20 +1450,19 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
label: Text(
tr(
'ui.userfront.audit.table.result',
fallback: '인증결과',
),
),
),
DataColumn(
label: Text(
tr('ui.userfront.audit.table.status', fallback: '현황'),
tr('ui.userfront.audit.table.status'),
),
),
],
rows: state.items.map((log) {
final statusLabel = log.status == 'success'
? tr('ui.common.status.success', fallback: '성공')
: tr('ui.common.status.failure', fallback: '실패');
? tr('ui.common.status.success')
: tr('ui.common.status.failure');
final statusColor = log.status == 'success'
? Colors.green
: Colors.redAccent;
@@ -1523,7 +1507,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
_selectableText(
tr(
'ui.userfront.audit.table.pending',
fallback: '(준비중)',
),
style: const TextStyle(color: Colors.grey),
),
@@ -1571,8 +1554,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
_selectableText(
log.status == 'success'
? tr('ui.common.status.success', fallback: '성공')
: tr('ui.common.status.failure', fallback: '실패'),
? tr('ui.common.status.success')
: tr('ui.common.status.failure'),
style: TextStyle(
color: log.status == 'success'
? Colors.green
@@ -1597,14 +1580,12 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
_selectableText(
tr(
'msg.userfront.audit.date',
fallback: '접속일자: {{value}}',
params: {'value': _formatDateTime(log.timestamp)},
),
),
_selectableText(
tr(
'msg.userfront.audit.ip',
fallback: '접속 IP: {{value}}',
params: {
'value': log.ipAddress.isEmpty
? tr('ui.common.hyphen', fallback: '-')
@@ -1615,7 +1596,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
_selectableText(
tr(
'msg.userfront.audit.device',
fallback: '접속환경: {{value}}',
params: {
'value': _deviceLabelFromUserAgent(log.userAgent),
},
@@ -1630,16 +1610,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
_selectableText(
tr(
'msg.userfront.audit.result',
fallback: '인증결과: {{value}}',
params: {
'value': log.status == 'success'
? tr('ui.common.status.success', fallback: '성공')
: tr('ui.common.status.failure', fallback: '실패'),
? tr('ui.common.status.success')
: tr('ui.common.status.failure'),
},
),
),
_selectableText(
tr('msg.userfront.audit.status', fallback: '현황: (준비중)'),
tr('msg.userfront.audit.status'),
style: TextStyle(color: Colors.grey[600]),
),
],
@@ -1667,13 +1646,12 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Text(
tr(
'msg.userfront.audit.load_more_error',
fallback: '더 불러오지 못했습니다.',
),
),
TextButton(
onPressed: () =>
ref.read(authTimelineProvider.notifier).loadMore(),
child: Text(tr('ui.common.retry', fallback: '재시도')),
child: Text(tr('ui.common.retry')),
),
],
),
@@ -1683,13 +1661,34 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
tr('msg.userfront.audit.end', fallback: '더 이상 항목이 없습니다.'),
tr('msg.userfront.audit.end'),
style: TextStyle(color: Colors.grey[600], fontSize: 12),
),
);
}
return const SizedBox.shrink();
}
bool _isLoggedIn() {
return AuthTokenStore.getToken() != null || AuthTokenStore.usesCookie();
}
void _redirectToSignin() {
if (!mounted || _redirectingToSignin) {
return;
}
_redirectingToSignin = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
final uri = GoRouterState.of(context).uri;
final localeCode =
extractLocaleFromPath(uri) ?? resolvePreferredLocaleCode();
context.go('/$localeCode/signin');
_redirectingToSignin = false;
});
}
}
class _ActivityItem {

View File

@@ -26,7 +26,7 @@ class ProfileRepository {
final useCookie = AuthTokenStore.usesCookie();
if (token == null && !useCookie) {
throw Exception(
tr('err.userfront.session.missing', fallback: '활성 세션이 없습니다.'),
tr('err.userfront.session.missing'),
);
}
@@ -45,7 +45,6 @@ class ProfileRepository {
throw Exception(
tr(
'err.userfront.profile.load_failed',
fallback: '프로필을 불러오지 못했습니다: {{error}}',
params: {'error': response.body},
),
);
@@ -61,7 +60,7 @@ class ProfileRepository {
final useCookie = AuthTokenStore.usesCookie();
if (token == null && !useCookie) {
throw Exception(
tr('err.userfront.session.missing', fallback: '활성 세션이 없습니다.'),
tr('err.userfront.session.missing'),
);
}
@@ -86,7 +85,6 @@ class ProfileRepository {
throw Exception(
tr(
'err.userfront.profile.update_failed',
fallback: '프로필 업데이트에 실패했습니다: {{error}}',
params: {'error': response.body},
),
);
@@ -98,7 +96,7 @@ class ProfileRepository {
final useCookie = AuthTokenStore.usesCookie();
if (token == null && !useCookie) {
throw Exception(
tr('err.userfront.session.missing', fallback: '활성 세션이 없습니다.'),
tr('err.userfront.session.missing'),
);
}
@@ -119,7 +117,6 @@ class ProfileRepository {
throw Exception(
tr(
'err.userfront.profile.send_code_failed',
fallback: '인증번호 전송 실패: {{error}}',
params: {'error': response.body},
),
);
@@ -134,7 +131,7 @@ class ProfileRepository {
final useCookie = AuthTokenStore.usesCookie();
if (token == null && !useCookie) {
throw Exception(
tr('err.userfront.session.missing', fallback: '활성 세션이 없습니다.'),
tr('err.userfront.session.missing'),
);
}
@@ -158,7 +155,6 @@ class ProfileRepository {
throw Exception(
tr(
'err.userfront.profile.password_change_failed',
fallback: '비밀번호 변경에 실패했습니다: {{error}}',
params: {'error': response.body},
),
);
@@ -170,7 +166,7 @@ class ProfileRepository {
final useCookie = AuthTokenStore.usesCookie();
if (token == null && !useCookie) {
throw Exception(
tr('err.userfront.session.missing', fallback: '활성 세션이 없습니다.'),
tr('err.userfront.session.missing'),
);
}
@@ -191,7 +187,6 @@ class ProfileRepository {
throw Exception(
tr(
'err.userfront.profile.verify_code_failed',
fallback: '인증 실패: {{error}}',
params: {'error': response.body},
),
);

View File

@@ -5,6 +5,7 @@ import 'package:userfront/i18n.dart';
import '../../../../core/notifiers/auth_notifier.dart';
import '../../../../core/services/auth_token_store.dart';
import '../../../../core/ui/layout_breakpoints.dart';
import '../../../../core/widgets/language_selector.dart';
import '../../data/models/user_profile_model.dart';
import '../../domain/notifiers/profile_notifier.dart';
@@ -235,7 +236,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
content: Text(
tr(
'msg.userfront.profile.phone.code_sent',
fallback: '인증번호가 전송되었습니다.',
),
),
),
@@ -249,7 +249,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
content: Text(
tr(
'msg.userfront.profile.phone.send_failed',
fallback: '전송 실패: {{error}}',
params: {'error': e.toString()},
),
),
@@ -275,7 +274,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr('msg.userfront.profile.phone.verified', fallback: '인증되었습니다.'),
tr('msg.userfront.profile.phone.verified'),
),
),
);
@@ -291,7 +290,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
content: Text(
tr(
'msg.userfront.profile.phone.verify_failed',
fallback: '인증 실패: {{error}}',
params: {'error': e.toString()},
),
),
@@ -311,7 +309,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
setState(
() => _passwordError = tr(
'msg.userfront.profile.password.current_required',
fallback: '현재 비밀번호를 입력해 주세요.',
),
);
return;
@@ -320,7 +317,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
setState(
() => _passwordError = tr(
'msg.userfront.profile.password.new_required',
fallback: '새 비밀번호를 입력해 주세요.',
),
);
return;
@@ -329,7 +325,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
setState(
() => _passwordError = tr(
'msg.userfront.profile.password.mismatch',
fallback: '새 비밀번호가 일치하지 않습니다.',
),
);
return;
@@ -354,7 +349,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
setState(() {
_passwordSuccess = tr(
'msg.userfront.profile.password.changed',
fallback: '비밀번호가 변경되었습니다.',
);
});
} catch (e) {
@@ -362,7 +356,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
setState(() {
_passwordError = tr(
'msg.userfront.profile.password.change_failed',
fallback: '비밀번호 변경 실패: {{error}}',
params: {'error': message},
);
});
@@ -440,7 +433,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr('msg.userfront.profile.name_required', fallback: '이름을 입력해주세요.'),
tr('msg.userfront.profile.name_required'),
),
),
);
@@ -452,7 +445,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
content: Text(
tr(
'msg.userfront.profile.department_required',
fallback: '소속을 입력해주세요.',
),
),
),
@@ -466,7 +458,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
content: Text(
tr(
'msg.userfront.profile.phone_required',
fallback: '휴대폰 번호를 입력해주세요.',
),
),
),
@@ -479,7 +470,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
content: Text(
tr(
'msg.userfront.profile.phone_verify_required',
fallback: '휴대폰 번호 인증이 필요합니다.',
),
),
),
@@ -525,7 +515,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
content: Text(
tr(
'msg.userfront.profile.update_success',
fallback: '정보가 수정되었습니다.',
),
),
),
@@ -538,7 +527,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
content: Text(
tr(
'msg.userfront.profile.update_failed',
fallback: '수정 실패: {{error}}',
params: {'error': e.toString()},
),
),
@@ -551,30 +539,40 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
}
Widget _buildSideMenu(BuildContext context) {
return ListView(
padding: const EdgeInsets.symmetric(vertical: 12),
return Column(
children: [
ListTile(
leading: const Icon(Icons.home_outlined),
title: Text(tr('ui.userfront.nav.dashboard', fallback: '대시보드')),
onTap: () => context.go('/'),
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 12),
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,
),
],
),
),
ListTile(
leading: const Icon(Icons.person_outline),
title: Text(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,
const Padding(
padding: EdgeInsets.only(bottom: 16),
child: LanguageSelector(compact: true),
),
],
);
@@ -626,13 +624,13 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
Widget _buildHeaderCard(UserProfile profile) {
final name = profile.name.isEmpty
? tr('msg.userfront.profile.name_missing', fallback: '이름 없음')
? tr('msg.userfront.profile.name_missing')
: profile.name;
final email = profile.email.isEmpty
? tr('msg.userfront.profile.email_missing', fallback: '이메일 없음')
? tr('msg.userfront.profile.email_missing')
: profile.email;
final department = profile.department.isEmpty
? tr('msg.userfront.profile.department_missing', fallback: '소속 정보 없음')
? tr('msg.userfront.profile.department_missing')
: profile.department;
return Container(
@@ -661,7 +659,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
Text(
tr(
'msg.userfront.profile.greeting',
fallback: '안녕하세요, {{name}}님',
params: {'name': name},
),
style: const TextStyle(
@@ -682,7 +679,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
children: [
_buildInfoChip(
Icons.badge_outlined,
tr('ui.userfront.profile.manage', fallback: '프로필 관리'),
tr('ui.userfront.profile.manage'),
),
_buildInfoChip(
Icons.apartment,
@@ -725,7 +722,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
title: Text(label),
subtitle: Text(displayValue),
trailing: Text(
tr('ui.common.read_only', fallback: '읽기 전용'),
tr('ui.common.read_only'),
style: TextStyle(color: Colors.grey[500], fontSize: 12),
),
);
@@ -749,7 +746,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
subtitle: Text(displayValue),
trailing: TextButton(
onPressed: isUpdating ? null : () => _startEditing(field, profile),
child: Text(tr('ui.common.edit', fallback: '수정')),
child: Text(tr('ui.common.edit')),
),
);
}
@@ -777,7 +774,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
const SizedBox(width: 12),
OutlinedButton(
onPressed: isUpdating ? null : () => _cancelEditing(profile),
child: Text(tr('ui.common.cancel', fallback: '취소')),
child: Text(tr('ui.common.cancel')),
),
],
),
@@ -792,11 +789,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
if (!isEditing) {
return ListTile(
contentPadding: EdgeInsets.zero,
title: Text(tr('ui.userfront.profile.phone.title', fallback: '전화번호')),
title: Text(tr('ui.userfront.profile.phone.title')),
subtitle: Text(displayValue),
trailing: TextButton(
onPressed: isUpdating ? null : () => _startEditing('phone', profile),
child: Text(tr('ui.common.edit', fallback: '수정')),
child: Text(tr('ui.common.edit')),
),
);
}
@@ -805,7 +802,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tr('ui.userfront.profile.phone.title', fallback: '전화번호'),
tr('ui.userfront.profile.phone.title'),
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
@@ -835,17 +832,16 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
onPressed: _isVerifying ? null : _sendCode,
child: Text(
_isCodeSent
? tr('ui.common.resend', fallback: '재전송')
? tr('ui.common.resend')
: tr(
'ui.userfront.profile.phone.request_code',
fallback: '인증요청',
),
),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: isUpdating ? null : () => _cancelEditing(profile),
child: Text(tr('ui.common.cancel', fallback: '취소')),
child: Text(tr('ui.common.cancel')),
),
],
),
@@ -865,7 +861,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
border: const OutlineInputBorder(),
hintText: tr(
'ui.userfront.profile.phone.code_hint',
fallback: '인증번호 6자리',
),
),
),
@@ -873,7 +868,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
const SizedBox(width: 8),
ElevatedButton(
onPressed: _isVerifying ? null : () => _verifyCode(profile),
child: Text(tr('ui.common.confirm', fallback: '확인')),
child: Text(tr('ui.common.confirm')),
),
],
),
@@ -884,7 +879,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
child: Text(
tr(
'msg.userfront.profile.phone.verify_notice',
fallback: '휴대폰 번호를 변경하려면 SMS 인증이 필요합니다.',
),
style: const TextStyle(color: Colors.orange, fontSize: 12),
),
@@ -899,14 +893,13 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tr('ui.userfront.profile.password.title', fallback: '비밀번호 변경'),
tr('ui.userfront.profile.password.title'),
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
Text(
tr(
'msg.userfront.profile.password.subtitle',
fallback: '현재 비밀번호 확인 후 새 비밀번호로 변경합니다.',
),
style: const TextStyle(color: Color(0xFF6B7280)),
),
@@ -917,7 +910,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
decoration: InputDecoration(
labelText: tr(
'ui.userfront.profile.password.current',
fallback: '현재 비밀번호',
),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
@@ -939,7 +931,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
decoration: InputDecoration(
labelText: tr(
'ui.userfront.profile.password.new',
fallback: '새 비밀번호',
),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
@@ -959,7 +950,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
decoration: InputDecoration(
labelText: tr(
'ui.userfront.profile.password.confirm',
fallback: '새 비밀번호 확인',
),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
@@ -999,7 +989,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
: Text(
tr(
'ui.userfront.profile.password.change',
fallback: '비밀번호 변경',
),
),
),
@@ -1009,7 +998,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
child: Text(
tr(
'ui.userfront.profile.password.forgot',
fallback: '비밀번호를 잊으셨나요?',
),
),
),
@@ -1035,10 +1023,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_buildHeaderCard(profile),
const SizedBox(height: 28),
_buildSectionTitle(
tr('ui.userfront.profile.section.basic', fallback: '기본 정보'),
tr('ui.userfront.profile.section.basic'),
tr(
'msg.userfront.profile.section.basic',
fallback: '계정 기본 정보를 관리합니다.',
),
),
const SizedBox(height: 12),
@@ -1049,7 +1036,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
field: 'name',
label: tr(
'ui.userfront.profile.field.name',
fallback: '이름',
),
value: profile.name,
profile: profile,
@@ -1060,7 +1046,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_buildReadOnlyTile(
tr(
'ui.userfront.profile.field.email',
fallback: '이메일',
),
profile.email,
),
@@ -1073,11 +1058,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_buildSectionTitle(
tr(
'ui.userfront.profile.section.organization',
fallback: '조직 정보',
),
tr(
'msg.userfront.profile.section.organization',
fallback: '소속 및 구분 정보입니다.',
),
),
const SizedBox(height: 12),
@@ -1088,7 +1071,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
field: 'department',
label: tr(
'ui.userfront.profile.field.department',
fallback: '소속',
),
value: profile.department,
profile: profile,
@@ -1099,7 +1081,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_buildReadOnlyTile(
tr(
'ui.userfront.profile.field.affiliation',
fallback: '구분',
),
profile.affiliationType,
),
@@ -1108,7 +1089,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_buildReadOnlyTile(
tr(
'ui.userfront.profile.field.tenant',
fallback: '소속 테넌트',
),
profile.tenant!.name,
),
@@ -1118,7 +1098,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_buildReadOnlyTile(
tr(
'ui.userfront.profile.field.company_code',
fallback: '회사코드',
),
profile.companyCode,
),
@@ -1128,10 +1107,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
const SizedBox(height: 28),
_buildSectionTitle(
tr('ui.userfront.profile.section.security', fallback: '보안'),
tr('ui.userfront.profile.section.security'),
tr(
'msg.userfront.profile.section.security',
fallback: '비밀번호를 안전하게 관리합니다.',
),
),
const SizedBox(height: 12),
@@ -1160,7 +1138,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
if (profile == null) {
return Scaffold(
appBar: AppBar(
title: Text(tr('ui.userfront.nav.profile', fallback: '내 정보')),
title: Text(tr('ui.userfront.nav.profile')),
),
body: profileState.isLoading
? const Center(child: CircularProgressIndicator())
@@ -1171,14 +1149,13 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
Text(
tr(
'msg.userfront.profile.load_failed',
fallback: '정보를 불러올 수 없습니다.',
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () =>
ref.read(profileProvider.notifier).loadProfile(),
child: Text(tr('ui.common.retry', fallback: '재시도')),
child: Text(tr('ui.common.retry')),
),
],
),
@@ -1195,7 +1172,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
backgroundColor: _subtle,
appBar: AppBar(
title: Text(
tr('ui.userfront.app_title', fallback: 'Baron 로그인'),
tr('ui.userfront.app_title'),
style: const TextStyle(fontWeight: FontWeight.bold),
),
elevation: 0,
@@ -1204,17 +1181,17 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
actions: [
IconButton(
icon: const Icon(Icons.home_outlined),
tooltip: tr('ui.userfront.nav.dashboard', fallback: '대시보드'),
tooltip: tr('ui.userfront.nav.dashboard'),
onPressed: () => context.go('/'),
),
IconButton(
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'),
),
IconButton(
icon: const Icon(Icons.logout),
tooltip: tr('ui.userfront.nav.logout', fallback: '로그아웃'),
tooltip: tr('ui.userfront.nav.logout'),
onPressed: _logout,
),
],

View File

@@ -1,36 +1,18 @@
import 'dart:ui';
import 'package:easy_localization/easy_localization.dart';
import 'i18n_data.dart';
const _defaultLocale = 'ko';
const _supportedLocales = ['ko', 'en'];
String _resolveLocale() {
final locale = PlatformDispatcher.instance.locale;
final code = locale.languageCode.toLowerCase();
if (_supportedLocales.contains(code)) {
return code;
}
return _defaultLocale;
}
String _formatTemplate(String template, Map<String, String>? params) {
if (params == null || params.isEmpty) {
return template;
}
var result = template;
params.forEach((key, value) {
result = result.replaceAll('{{$key}}', value);
});
return result;
}
final _koreanPattern = RegExp(r'[가-힣]');
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);
try {
if (fallback != null && _koreanPattern.hasMatch(fallback)) {
fallback = null;
}
final translated = key.tr(namedArgs: params);
if (translated == key && fallback != null && fallback.isNotEmpty) {
return fallback;
}
return translated;
} catch (_) {
return fallback ?? key;
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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:flutter/services.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/logger_service.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 'features/auth/presentation/consent_screen.dart';
import 'i18n.dart';
@@ -40,6 +44,7 @@ Future<void> _loadBundledFonts() async {
void main() async {
WidgetsFlutterBinding.ensureInitialized();
usePathUrlStrategy();
await EasyLocalization.ensureInitialized();
// 1. Global Error Handling
FlutterError.onError = (details) {
@@ -70,7 +75,22 @@ void main() async {
// 폰트를 먼저 로딩해서 렌더링 깨짐(FOIT/FOUT) 최소화
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
@@ -81,184 +101,209 @@ final _router = GoRouter(
debugLogDiagnostics: !kReleaseMode,
refreshListenable: AuthNotifier.instance,
routes: [
GoRoute(
path: '/',
builder: (context, state) {
_routerLogger.info("Navigating to root (DashboardScreen)");
return const DashboardScreen();
},
),
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) {
// 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();
ShellRoute(
builder: (context, state, child) {
final localeCode =
extractLocaleFromPath(state.uri) ?? resolvePreferredLocaleCode();
return LocaleGate(localeCode: localeCode, child: child);
},
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) {
final requestedLocale = extractLocaleFromPath(state.uri);
final preferredLocale = resolvePreferredLocaleCode();
if (requestedLocale == null) {
return buildLocalizedPath(preferredLocale, state.uri);
}
final hasStoredToken = AuthTokenStore.getToken() != null;
final hasCookieSession = AuthTokenStore.usesCookie();
final isLoggedIn = hasStoredToken || hasCookieSession;
final path = state.uri.path;
final path = stripLocalePath(state.uri);
// Public paths that don't require login
final isPublicPath =
@@ -290,10 +335,11 @@ final _router = GoRouter(
_routerLogger.info("Not logged in, redirecting to /signin");
// Preserve OIDC challenge if present
final loginChallenge = state.uri.queryParameters['login_challenge'];
final locale = requestedLocale;
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)
@@ -313,7 +359,10 @@ class BaronSSOApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
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(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1A1F2C), // Dark Navy/Black base

View File

@@ -105,6 +105,22 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -113,6 +129,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
@@ -134,6 +158,11 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_driver:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
@@ -142,6 +171,11 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_localizations:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_riverpod:
dependency: "direct main"
description:
@@ -168,6 +202,11 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.0.0"
fuchsia_remote_debug_protocol:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
glob:
dependency: transitive
description:
@@ -208,6 +247,19 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -336,6 +388,46 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -352,6 +444,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.2"
process:
dependency: transitive
description:
name: process
sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744
url: "https://pub.dev"
source: hosted
version: "5.0.5"
pub_semver:
dependency: transitive
description:
@@ -384,6 +484,62 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -477,6 +633,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -509,6 +673,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -629,6 +801,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -637,6 +817,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -646,5 +834,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.10.4 <4.0.0"
dart: ">=3.10.0 <4.0.0"
flutter: ">=3.38.0"

View File

@@ -19,7 +19,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
version: 1.0.0+1
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.
# To automatically upgrade your package dependencies to the latest versions
@@ -45,10 +45,14 @@ dependencies:
logger: ^2.0.0
qr_flutter: ^4.1.0
mobile_scanner: ^7.1.4
easy_localization: ^3.0.7
toml: ^0.15.0
dev_dependencies:
flutter_test:
sdk: flutter
integration_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# 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:
# assets:
# - .env
assets:
- assets/translations/
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images

View 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';

View 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();

View 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();

View 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,
);
}

View 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');
});
});
}

View File

@@ -5,15 +5,36 @@
// 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.
import 'dart:ui';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
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() {
testWidgets('BaronSSOApp builds', (WidgetTester tester) async {
// 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(); // 한 프레임 더
});
}