forked from baron/baron-sso
Merge branch 'dev' into feature/af-ui
This commit is contained in:
@@ -108,10 +108,6 @@ HYDRA_ADMIN_URL=http://hydra:4445
|
|||||||
# Oathkeeper가 /oidc 경로를 Hydra Public API로 라우팅합니다.
|
# Oathkeeper가 /oidc 경로를 Hydra Public API로 라우팅합니다.
|
||||||
HYDRA_PUBLIC_URL=${OATHKEEPER_PUBLIC_URL}/oidc
|
HYDRA_PUBLIC_URL=${OATHKEEPER_PUBLIC_URL}/oidc
|
||||||
|
|
||||||
# OIDC 클라이언트 callback (콤마 구분)
|
|
||||||
ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback
|
|
||||||
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback
|
|
||||||
|
|
||||||
# Kratos allowed_return_urls 확장 목록 (콤마 구분, 선택)
|
# Kratos allowed_return_urls 확장 목록 (콤마 구분, 선택)
|
||||||
# 기본값은 KRATOS_UI_URL, USERFRONT_URL, 각 callback URL을 자동 포함합니다.
|
# 기본값은 KRATOS_UI_URL, USERFRONT_URL, 각 callback URL을 자동 포함합니다.
|
||||||
KRATOS_ALLOWED_RETURN_URLS_EXTRA=[]
|
KRATOS_ALLOWED_RETURN_URLS_EXTRA=[]
|
||||||
@@ -134,9 +130,11 @@ CSRF_COOKIE_NAME=__HOST-baronSSO_csrf
|
|||||||
CSRF_COOKIE_SECRET=localcsrf123
|
CSRF_COOKIE_SECRET=localcsrf123
|
||||||
|
|
||||||
# AdminFront OIDC 설정
|
# AdminFront OIDC 설정
|
||||||
|
ADMINFRONT_URL=http://localhost:5173
|
||||||
ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback
|
ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback
|
||||||
|
|
||||||
# DevFront OIDC 설정
|
# DevFront OIDC 설정
|
||||||
VITE_OIDC_CLIENT_ID=devfront
|
VITE_OIDC_CLIENT_ID=devfront
|
||||||
VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc
|
VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc
|
||||||
|
DEVFRONT_URL=http://localhost:5174
|
||||||
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback
|
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback
|
||||||
@@ -87,6 +87,8 @@ jobs:
|
|||||||
ADMIN_EMAIL=${{ vars.ADMIN_EMAIL }}
|
ADMIN_EMAIL=${{ vars.ADMIN_EMAIL }}
|
||||||
ADMIN_PASSWORD=${{ secrets.STG_ADMIN_PASSWORD }}
|
ADMIN_PASSWORD=${{ secrets.STG_ADMIN_PASSWORD }}
|
||||||
USERFRONT_URL=${{ vars.USERFRONT_URL }}
|
USERFRONT_URL=${{ vars.USERFRONT_URL }}
|
||||||
|
ADMINFRONT_URL=${{ vars.ADMINFRONT_URL }}
|
||||||
|
DEVFRONT_URL=${{ vars.DEVFRONT_URL }}
|
||||||
BACKEND_URL=${{ vars.BACKEND_URL }}
|
BACKEND_URL=${{ vars.BACKEND_URL }}
|
||||||
OATHKEEPER_PUBLIC_URL=${{ vars.OATHKEEPER_PUBLIC_URL }}
|
OATHKEEPER_PUBLIC_URL=${{ vars.OATHKEEPER_PUBLIC_URL }}
|
||||||
ORY_POSTGRES_TAG=${{ vars.ORY_POSTGRES_TAG }}
|
ORY_POSTGRES_TAG=${{ vars.ORY_POSTGRES_TAG }}
|
||||||
@@ -161,14 +163,11 @@ jobs:
|
|||||||
|
|
||||||
docker compose -f staging_pull_compose.yaml pull
|
docker compose -f staging_pull_compose.yaml pull
|
||||||
|
|
||||||
# [주의] DB 초기화 스크립트는 '새로운 볼륨'에서만 실행됨.
|
|
||||||
docker compose -f staging_pull_compose.yaml down || true
|
|
||||||
|
|
||||||
# 코드 변경 반영을 위해 build 수행 (userfront nginx.conf 등)
|
# 코드 변경 반영을 위해 build 수행 (userfront nginx.conf 등)
|
||||||
docker compose -f staging_pull_compose.yaml build --pull
|
docker compose -f staging_pull_compose.yaml build --pull
|
||||||
|
|
||||||
docker compose -f staging_pull_compose.yaml up -d --remove-orphans
|
docker compose -f staging_pull_compose.yaml up -d --remove-orphans
|
||||||
docker compose -f staging_pull_compose.yaml up -d --force-recreate init-rp
|
docker compose -f staging_pull_compose.yaml up -d init-rp
|
||||||
|
|
||||||
# 배포 후 상태 확인 (실패 시 로그 출력을 위함)
|
# 배포 후 상태 확인 (실패 시 로그 출력을 위함)
|
||||||
sleep 10
|
sleep 10
|
||||||
|
|||||||
14
Makefile
14
Makefile
@@ -117,6 +117,10 @@ endif
|
|||||||
|
|
||||||
.PHONY: code-check code-check-lint code-check-test-jobs code-check-i18n code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-userfront-e2e-tests
|
.PHONY: code-check code-check-lint code-check-test-jobs code-check-i18n code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-userfront-e2e-tests
|
||||||
|
|
||||||
|
CODE_CHECK_TEST_JOBS ?= 1
|
||||||
|
PLAYWRIGHT_WORKERS ?= 1
|
||||||
|
FLUTTER_TEST_CONCURRENCY ?= 1
|
||||||
|
|
||||||
code-check: code-check-lint code-check-test-jobs
|
code-check: code-check-lint code-check-test-jobs
|
||||||
@echo "code-check complete."
|
@echo "code-check complete."
|
||||||
|
|
||||||
@@ -124,7 +128,7 @@ code-check-lint: code-check-i18n code-check-front-lint code-check-go-lint code-c
|
|||||||
|
|
||||||
code-check-test-jobs:
|
code-check-test-jobs:
|
||||||
@echo "==> run CI-equivalent test jobs (parallel)"
|
@echo "==> run CI-equivalent test jobs (parallel)"
|
||||||
@$(MAKE) --no-print-directory -j5 --output-sync=target \
|
@$(MAKE) --no-print-directory -j$(CODE_CHECK_TEST_JOBS) --output-sync=target \
|
||||||
code-check-backend-tests \
|
code-check-backend-tests \
|
||||||
code-check-userfront-tests \
|
code-check-userfront-tests \
|
||||||
code-check-userfront-e2e-tests \
|
code-check-userfront-e2e-tests \
|
||||||
@@ -203,11 +207,11 @@ code-check-userfront-tests:
|
|||||||
rm -rf "$$tmp_dir/userfront/.dart_tool" "$$tmp_dir/userfront/build"; \
|
rm -rf "$$tmp_dir/userfront/.dart_tool" "$$tmp_dir/userfront/build"; \
|
||||||
fi; \
|
fi; \
|
||||||
cd "$$tmp_dir" && /bin/sh ./scripts/sync_userfront_locales.sh; \
|
cd "$$tmp_dir" && /bin/sh ./scripts/sync_userfront_locales.sh; \
|
||||||
cd "$$tmp_dir/userfront" && flutter test
|
cd "$$tmp_dir/userfront" && flutter test --concurrency=$(FLUTTER_TEST_CONCURRENCY)
|
||||||
|
|
||||||
code-check-adminfront-tests:
|
code-check-adminfront-tests:
|
||||||
@echo "==> adminfront tests"
|
@echo "==> adminfront tests"
|
||||||
./scripts/run_adminfront_ci_tests.sh adminfront-tests
|
PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) ./scripts/run_adminfront_ci_tests.sh adminfront-tests
|
||||||
|
|
||||||
code-check-devfront-tests:
|
code-check-devfront-tests:
|
||||||
@echo "==> devfront tests"
|
@echo "==> devfront tests"
|
||||||
@@ -219,7 +223,7 @@ code-check-devfront-tests:
|
|||||||
(cd devfront && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \
|
(cd devfront && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \
|
||||||
fi; \
|
fi; \
|
||||||
if [ $$status -eq 0 ]; then \
|
if [ $$status -eq 0 ]; then \
|
||||||
(cd devfront && npm test) || status=$$?; \
|
(cd devfront && PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) npm test) || status=$$?; \
|
||||||
fi; \
|
fi; \
|
||||||
[ -d devfront/playwright-report ] && cp -R devfront/playwright-report reports/devfront/ || true; \
|
[ -d devfront/playwright-report ] && cp -R devfront/playwright-report reports/devfront/ || true; \
|
||||||
[ -d devfront/test-results ] && cp -R devfront/test-results reports/devfront/ || true; \
|
[ -d devfront/test-results ] && cp -R devfront/test-results reports/devfront/ || true; \
|
||||||
@@ -267,7 +271,7 @@ code-check-userfront-e2e-tests:
|
|||||||
if [ $$status -eq 0 ]; then \
|
if [ $$status -eq 0 ]; then \
|
||||||
port="$$(node -e "const net=require('node:net'); const s=net.createServer(); s.listen(0,'127.0.0.1',()=>{console.log(s.address().port); s.close();});")"; \
|
port="$$(node -e "const net=require('node:net'); const s=net.createServer(); s.listen(0,'127.0.0.1',()=>{console.log(s.address().port); s.close();});")"; \
|
||||||
echo "==> userfront-e2e using PORT=$$port"; \
|
echo "==> userfront-e2e using PORT=$$port"; \
|
||||||
(cd "$$tmp_dir/userfront-e2e" && PORT=$$port npm test) || status=$$?; \
|
(cd "$$tmp_dir/userfront-e2e" && PORT=$$port PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) npm test) || status=$$?; \
|
||||||
fi; \
|
fi; \
|
||||||
[ -d "$$tmp_dir/userfront-e2e/playwright-report" ] && cp -R "$$tmp_dir/userfront-e2e/playwright-report" reports/userfront-e2e/ || true; \
|
[ -d "$$tmp_dir/userfront-e2e/playwright-report" ] && cp -R "$$tmp_dir/userfront-e2e/playwright-report" reports/userfront-e2e/ || true; \
|
||||||
[ -d "$$tmp_dir/userfront-e2e/test-results" ] && cp -R "$$tmp_dir/userfront-e2e/test-results" reports/userfront-e2e/ || true; \
|
[ -d "$$tmp_dir/userfront-e2e/test-results" ] && cp -R "$$tmp_dir/userfront-e2e/test-results" reports/userfront-e2e/ || true; \
|
||||||
|
|||||||
@@ -322,6 +322,7 @@ KETO_WRITE_URL = "http://keto:4467"
|
|||||||
- **Source of Truth**: `locales/template.toml`이 전체 키의 기준이며 `locales/ko.toml`, `locales/en.toml`과 항상 동기화합니다.
|
- **Source of Truth**: `locales/template.toml`이 전체 키의 기준이며 `locales/ko.toml`, `locales/en.toml`과 항상 동기화합니다.
|
||||||
- **React(Admin/Dev)**: `adminfront/src/lib/i18n.ts`, `devfront/src/lib/i18n.ts`에서 `t(key, fallback, vars)`로 사용하고 TOML을 `?raw`로 로드합니다.
|
- **React(Admin/Dev)**: `adminfront/src/lib/i18n.ts`, `devfront/src/lib/i18n.ts`에서 `t(key, fallback, vars)`로 사용하고 TOML을 `?raw`로 로드합니다.
|
||||||
- **Flutter(User)**: `userfront/lib/i18n.dart`에서 `tr(key, fallback, params)` 사용. `locales/*.toml`을 `tools/i18n-scanner/gen-flutter-i18n.js`로 `userfront/lib/i18n_data.dart`에 사전 생성합니다.
|
- **Flutter(User)**: `userfront/lib/i18n.dart`에서 `tr(key, fallback, params)` 사용. `locales/*.toml`을 `tools/i18n-scanner/gen-flutter-i18n.js`로 `userfront/lib/i18n_data.dart`에 사전 생성합니다.
|
||||||
|
- **UserFront 동기화 규칙**: `locales/*.toml`을 수정한 뒤에는 반드시 `./scripts/sync_userfront_locales.sh`를 실행해 `userfront/assets/translations/*.toml`과 런타임 번역 리소스를 동기화합니다.
|
||||||
- **검증**: `node tools/i18n-scanner/index.js`로 코드-키-로케일 동기화 상태를 점검합니다.
|
- **검증**: `node tools/i18n-scanner/index.js`로 코드-키-로케일 동기화 상태를 점검합니다.
|
||||||
|
|
||||||
## 🧪 Code Check CI
|
## 🧪 Code Check CI
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
|
||||||
|
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read environment variables from file.
|
* Read environment variables from file.
|
||||||
* https://github.com/motdotla/dotenv
|
* https://github.com/motdotla/dotenv
|
||||||
@@ -24,7 +28,7 @@ export default defineConfig({
|
|||||||
/* Retry on CI only */
|
/* Retry on CI only */
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
/* Opt out of parallel tests on CI. */
|
/* Opt out of parallel tests on CI. */
|
||||||
workers: process.env.CI ? 1 : undefined,
|
workers: configuredWorkers ?? (process.env.CI ? 1 : undefined),
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
reporter: "html",
|
reporter: "html",
|
||||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
|
|||||||
@@ -1101,6 +1101,9 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
phone:
|
phone:
|
||||||
type: string
|
type: string
|
||||||
|
sessionAuthenticatedAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
department:
|
department:
|
||||||
type: string
|
type: string
|
||||||
affiliationType:
|
affiliationType:
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ type UserProfileResponse struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
Role string `json:"role"` // 추가
|
Role string `json:"role"` // 추가
|
||||||
|
SessionAuthenticatedAt string `json:"sessionAuthenticatedAt,omitempty"`
|
||||||
Department string `json:"department"`
|
Department string `json:"department"`
|
||||||
AffiliationType string `json:"affiliationType"`
|
AffiliationType string `json:"affiliationType"`
|
||||||
CompanyCode string `json:"companyCode,omitempty"`
|
CompanyCode string `json:"companyCode,omitempty"`
|
||||||
|
|||||||
@@ -3388,13 +3388,11 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
|||||||
name = clientID
|
name = clientID
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClientURI가 없으면 RedirectURIs에서 호스트 부분만 추출하여 URL로 사용 (Fallback)
|
clientURL := resolveLinkedRPURL(
|
||||||
clientURL := strings.TrimSpace(client.ClientURI)
|
client.ClientID,
|
||||||
if clientURL == "" && len(client.RedirectURIs) > 0 {
|
client.ClientURI,
|
||||||
if parsed, err := url.Parse(client.RedirectURIs[0]); err == nil {
|
client.RedirectURIs,
|
||||||
clientURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lastAuth := time.Time{}
|
lastAuth := time.Time{}
|
||||||
if session.AuthenticatedAt != nil {
|
if session.AuthenticatedAt != nil {
|
||||||
@@ -3484,12 +3482,11 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
|||||||
name = client.ClientID
|
name = client.ClientID
|
||||||
}
|
}
|
||||||
|
|
||||||
clientURL := strings.TrimSpace(client.ClientURI)
|
clientURL := resolveLinkedRPURL(
|
||||||
if clientURL == "" && len(client.RedirectURIs) > 0 {
|
client.ClientID,
|
||||||
if parsed, err := url.Parse(client.RedirectURIs[0]); err == nil {
|
client.ClientURI,
|
||||||
clientURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
|
client.RedirectURIs,
|
||||||
}
|
)
|
||||||
}
|
|
||||||
|
|
||||||
records[dc.ClientID] = &linkedRpRecord{
|
records[dc.ClientID] = &linkedRpRecord{
|
||||||
linkedRpSummary: linkedRpSummary{
|
linkedRpSummary: linkedRpSummary{
|
||||||
@@ -4889,37 +4886,43 @@ func extractLoginIDFromClaims(claims map[string]any) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]interface{}, error) {
|
func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]interface{}, error) {
|
||||||
|
identityID, traits, _, err := h.getKratosIdentityWithSession(sessionToken)
|
||||||
|
return identityID, traits, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) getKratosIdentityWithSession(sessionToken string) (string, map[string]interface{}, string, error) {
|
||||||
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
|
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
|
||||||
if kratosURL == "" {
|
if kratosURL == "" {
|
||||||
kratosURL = "http://kratos:4433"
|
kratosURL = "http://kratos:4433"
|
||||||
}
|
}
|
||||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, err
|
return "", nil, "", err
|
||||||
}
|
}
|
||||||
req.Header.Set("X-Session-Token", sessionToken)
|
req.Header.Set("X-Session-Token", sessionToken)
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, err
|
return "", nil, "", err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode >= 300 {
|
if resp.StatusCode >= 300 {
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
return "", nil, fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
|
return "", nil, "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
|
AuthenticatedAt string `json:"authenticated_at"`
|
||||||
Identity struct {
|
Identity struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Traits map[string]interface{} `json:"traits"`
|
Traits map[string]interface{} `json:"traits"`
|
||||||
} `json:"identity"`
|
} `json:"identity"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
return "", nil, err
|
return "", nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.Identity.ID, result.Identity.Traits, nil
|
return result.Identity.ID, result.Identity.Traits, result.AuthenticatedAt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) {
|
func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) {
|
||||||
@@ -4996,37 +4999,43 @@ func (h *AuthHandler) issueKratosSession(ctx context.Context, identityID string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) getKratosIdentityWithCookie(cookie string) (string, map[string]interface{}, error) {
|
func (h *AuthHandler) getKratosIdentityWithCookie(cookie string) (string, map[string]interface{}, error) {
|
||||||
|
identityID, traits, _, err := h.getKratosIdentityWithCookieAndSession(cookie)
|
||||||
|
return identityID, traits, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) getKratosIdentityWithCookieAndSession(cookie string) (string, map[string]interface{}, string, error) {
|
||||||
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
|
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
|
||||||
if kratosURL == "" {
|
if kratosURL == "" {
|
||||||
kratosURL = "http://kratos:4433"
|
kratosURL = "http://kratos:4433"
|
||||||
}
|
}
|
||||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, err
|
return "", nil, "", err
|
||||||
}
|
}
|
||||||
req.Header.Set("Cookie", cookie)
|
req.Header.Set("Cookie", cookie)
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, err
|
return "", nil, "", err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode >= 300 {
|
if resp.StatusCode >= 300 {
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
return "", nil, fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
|
return "", nil, "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
|
AuthenticatedAt string `json:"authenticated_at"`
|
||||||
Identity struct {
|
Identity struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Traits map[string]interface{} `json:"traits"`
|
Traits map[string]interface{} `json:"traits"`
|
||||||
} `json:"identity"`
|
} `json:"identity"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
return "", nil, err
|
return "", nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.Identity.ID, result.Identity.Traits, nil
|
return result.Identity.ID, result.Identity.Traits, result.AuthenticatedAt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) getKratosSessionIDWithCookie(cookie string) (string, error) {
|
func (h *AuthHandler) getKratosSessionIDWithCookie(cookie string) (string, error) {
|
||||||
@@ -5161,20 +5170,34 @@ func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[s
|
|||||||
return profile
|
return profile
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) applySessionAuthenticatedAtFromWhoami(profile *domain.UserProfileResponse, authenticatedAt string) *domain.UserProfileResponse {
|
||||||
|
if profile == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
profile.SessionAuthenticatedAt = strings.TrimSpace(authenticatedAt)
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfileResponse, error) {
|
func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfileResponse, error) {
|
||||||
identityID, traits, err := h.getKratosIdentity(sessionToken)
|
identityID, traits, authenticatedAt, err := h.getKratosIdentityWithSession(sessionToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return h.mapKratosIdentityToProfile(identityID, traits), nil
|
return h.applySessionAuthenticatedAtFromWhoami(
|
||||||
|
h.mapKratosIdentityToProfile(identityID, traits),
|
||||||
|
authenticatedAt,
|
||||||
|
), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserProfileResponse, error) {
|
func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserProfileResponse, error) {
|
||||||
identityID, traits, err := h.getKratosIdentityWithCookie(cookie)
|
identityID, traits, authenticatedAt, err := h.getKratosIdentityWithCookieAndSession(cookie)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return h.mapKratosIdentityToProfile(identityID, traits), nil
|
return h.applySessionAuthenticatedAtFromWhoami(
|
||||||
|
h.mapKratosIdentityToProfile(identityID, traits),
|
||||||
|
authenticatedAt,
|
||||||
|
), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateMe - Updates current user's profile with phone verification check
|
// UpdateMe - Updates current user's profile with phone verification check
|
||||||
@@ -5423,6 +5446,32 @@ func extractHydraClientLogo(metadata map[string]interface{}) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveLinkedRPURL(clientID string, clientURI string, redirectURIs []string) string {
|
||||||
|
switch strings.TrimSpace(clientID) {
|
||||||
|
case "adminfront":
|
||||||
|
if value := strings.TrimSpace(os.Getenv("ADMINFRONT_URL")); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
case "devfront":
|
||||||
|
if value := strings.TrimSpace(os.Getenv("DEVFRONT_URL")); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clientURL := strings.TrimSpace(clientURI)
|
||||||
|
if clientURL != "" {
|
||||||
|
return clientURL
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(redirectURIs) > 0 {
|
||||||
|
if parsed, err := url.Parse(redirectURIs[0]); err == nil {
|
||||||
|
return fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func mergeScopes(current []string, next []string) []string {
|
func mergeScopes(current []string, next []string) []string {
|
||||||
if len(next) == 0 {
|
if len(next) == 0 {
|
||||||
return current
|
return current
|
||||||
|
|||||||
105
backend/internal/handler/auth_handler_session_profile_test.go
Normal file
105
backend/internal/handler/auth_handler_session_profile_test.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetMe_IncludesSessionAuthenticatedAtFromKratosSession(t *testing.T) {
|
||||||
|
const (
|
||||||
|
token = "token-session"
|
||||||
|
identityID = "user-session"
|
||||||
|
sessionAuthenticated = "2026-03-23T15:30:00Z"
|
||||||
|
)
|
||||||
|
|
||||||
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
if r.URL.Host == "kratos.test" &&
|
||||||
|
r.URL.Path == "/sessions/whoami" &&
|
||||||
|
r.Method == http.MethodGet {
|
||||||
|
require.Equal(t, token, r.Header.Get("X-Session-Token"))
|
||||||
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
|
"id": "kratos-session-1",
|
||||||
|
"authenticated_at": sessionAuthenticated,
|
||||||
|
"identity": map[string]any{
|
||||||
|
"id": identityID,
|
||||||
|
"traits": map[string]any{
|
||||||
|
"email": "qa@example.com",
|
||||||
|
"name": "QA User",
|
||||||
|
"department": "Platform",
|
||||||
|
"affiliationType": "GENERAL",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||||
|
})
|
||||||
|
setDefaultHTTPClientForTest(t, transport)
|
||||||
|
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
|
||||||
|
|
||||||
|
h := &AuthHandler{}
|
||||||
|
app := fiber.New()
|
||||||
|
app.Get("/api/v1/user/me", h.GetMe)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/me", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := app.Test(req, -1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var profile map[string]any
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&profile))
|
||||||
|
require.Equal(t, sessionAuthenticated, profile["sessionAuthenticatedAt"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetMe_IncludesSessionAuthenticatedAtForCookieSession(t *testing.T) {
|
||||||
|
const (
|
||||||
|
cookieHeader = "ory_kratos_session=session-cookie"
|
||||||
|
identityID = "user-cookie"
|
||||||
|
sessionAuthenticated = "2026-03-24T01:20:00Z"
|
||||||
|
)
|
||||||
|
|
||||||
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
if r.URL.Host == "kratos.test" &&
|
||||||
|
r.URL.Path == "/sessions/whoami" &&
|
||||||
|
r.Method == http.MethodGet {
|
||||||
|
require.Equal(t, cookieHeader, r.Header.Get("Cookie"))
|
||||||
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
|
"id": "kratos-session-cookie",
|
||||||
|
"authenticated_at": sessionAuthenticated,
|
||||||
|
"identity": map[string]any{
|
||||||
|
"id": identityID,
|
||||||
|
"traits": map[string]any{
|
||||||
|
"email": "cookie@example.com",
|
||||||
|
"name": "Cookie User",
|
||||||
|
"department": "Platform",
|
||||||
|
"affiliationType": "GENERAL",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||||
|
})
|
||||||
|
setDefaultHTTPClientForTest(t, transport)
|
||||||
|
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
|
||||||
|
|
||||||
|
h := &AuthHandler{}
|
||||||
|
app := fiber.New()
|
||||||
|
app.Get("/api/v1/user/me", h.GetMe)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/me", nil)
|
||||||
|
req.Header.Set("Cookie", cookieHeader)
|
||||||
|
resp, err := app.Test(req, -1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var profile map[string]any
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&profile))
|
||||||
|
require.Equal(t, sessionAuthenticated, profile["sessionAuthenticatedAt"])
|
||||||
|
}
|
||||||
@@ -142,6 +142,10 @@ type clientUpsertRequest struct {
|
|||||||
Metadata *map[string]interface{} `json:"metadata"`
|
Metadata *map[string]interface{} `json:"metadata"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var protectedSystemClientIDs = map[string]struct{}{
|
||||||
|
"oathkeeper-introspect": {},
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeUserRole(role string) string {
|
func normalizeUserRole(role string) string {
|
||||||
return domain.NormalizeRole(role)
|
return domain.NormalizeRole(role)
|
||||||
}
|
}
|
||||||
@@ -263,6 +267,15 @@ func profileRole(profile *domain.UserProfileResponse) string {
|
|||||||
return strings.TrimSpace(profile.Role)
|
return strings.TrimSpace(profile.Role)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isProtectedSystemClientID(clientID string) bool {
|
||||||
|
_, ok := protectedSystemClientIDs[strings.TrimSpace(clientID)]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func isProtectedSystemClient(client domain.HydraClient) bool {
|
||||||
|
return isProtectedSystemClientID(client.ClientID)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
|
func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
|
||||||
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
|
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||||
if (!ok || profile == nil) && h.Auth != nil {
|
if (!ok || profile == nil) && h.Auth != nil {
|
||||||
@@ -557,6 +570,10 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
items := make([]clientSummary, 0, len(clients))
|
items := make([]clientSummary, 0, len(clients))
|
||||||
for _, client := range clients {
|
for _, client := range clients {
|
||||||
|
if isProtectedSystemClient(client) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
summary := h.mapClientSummary(client)
|
summary := h.mapClientSummary(client)
|
||||||
|
|
||||||
// 1. [Security] Filter out 'private' clients if user is not an AppManager
|
// 1. [Security] Filter out 'private' clients if user is not an AppManager
|
||||||
@@ -604,6 +621,10 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error {
|
|||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isProtectedSystemClient(*client) {
|
||||||
|
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
||||||
|
}
|
||||||
|
|
||||||
summary := h.mapClientSummary(*client)
|
summary := h.mapClientSummary(*client)
|
||||||
profile := h.getCurrentProfile(c)
|
profile := h.getCurrentProfile(c)
|
||||||
if profile == nil {
|
if profile == nil {
|
||||||
@@ -678,6 +699,10 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
|
|||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isProtectedSystemClient(*current) {
|
||||||
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
|
||||||
|
}
|
||||||
|
|
||||||
summary := h.mapClientSummary(*current)
|
summary := h.mapClientSummary(*current)
|
||||||
profile := h.getCurrentProfile(c)
|
profile := h.getCurrentProfile(c)
|
||||||
if profile == nil {
|
if profile == nil {
|
||||||
@@ -759,6 +784,9 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
|||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
clientID = uuid.NewString()
|
clientID = uuid.NewString()
|
||||||
}
|
}
|
||||||
|
if isProtectedSystemClientID(clientID) {
|
||||||
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: reserved system client id")
|
||||||
|
}
|
||||||
|
|
||||||
name := strings.TrimSpace(valueOr(req.Name, ""))
|
name := strings.TrimSpace(valueOr(req.Name, ""))
|
||||||
if name == "" {
|
if name == "" {
|
||||||
@@ -899,6 +927,10 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
|||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isProtectedSystemClient(*current) {
|
||||||
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
|
||||||
|
}
|
||||||
|
|
||||||
currentSummary := h.mapClientSummary(*current)
|
currentSummary := h.mapClientSummary(*current)
|
||||||
profile := h.getCurrentProfile(c)
|
profile := h.getCurrentProfile(c)
|
||||||
if profile == nil {
|
if profile == nil {
|
||||||
@@ -1030,6 +1062,10 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
|
|||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isProtectedSystemClient(*current) {
|
||||||
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
|
||||||
|
}
|
||||||
|
|
||||||
summary := h.mapClientSummary(*current)
|
summary := h.mapClientSummary(*current)
|
||||||
profile := h.getCurrentProfile(c)
|
profile := h.getCurrentProfile(c)
|
||||||
if profile == nil {
|
if profile == nil {
|
||||||
@@ -1265,6 +1301,10 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
|
|||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isProtectedSystemClient(*current) {
|
||||||
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
|
||||||
|
}
|
||||||
|
|
||||||
summary := h.mapClientSummary(*current)
|
summary := h.mapClientSummary(*current)
|
||||||
profile := h.getCurrentProfile(c)
|
profile := h.getCurrentProfile(c)
|
||||||
if profile == nil {
|
if profile == nil {
|
||||||
@@ -1462,6 +1502,9 @@ func (h *DevHandler) GetStats(c *fiber.Ctx) error {
|
|||||||
var totalClients int64
|
var totalClients int64
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for _, client := range clients {
|
for _, client := range clients {
|
||||||
|
if isProtectedSystemClient(client) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if isSuperAdmin {
|
if isSuperAdmin {
|
||||||
totalClients++
|
totalClients++
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -124,6 +124,44 @@ func TestListClients_Success(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListClients_ProtectedSystemClientHidden(t *testing.T) {
|
||||||
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
if r.URL.Path == "/clients" {
|
||||||
|
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
||||||
|
{"client_id": "oathkeeper-introspect", "client_name": "Internal Client"},
|
||||||
|
{"client_id": "client-1", "client_name": "App One", "metadata": map[string]interface{}{"status": "active"}},
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
mockKeto := new(devMockKetoService)
|
||||||
|
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "AppManager", "member").Return(true, nil)
|
||||||
|
|
||||||
|
h := &DevHandler{
|
||||||
|
Hydra: &service.HydraAdminService{
|
||||||
|
AdminURL: "http://hydra.test",
|
||||||
|
HTTPClient: &http.Client{Transport: transport},
|
||||||
|
},
|
||||||
|
Keto: mockKeto,
|
||||||
|
}
|
||||||
|
app := fiber.New()
|
||||||
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
|
||||||
|
return c.Next()
|
||||||
|
})
|
||||||
|
app.Get("/api/v1/dev/clients", h.ListClients)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
||||||
|
resp, _ := app.Test(req, -1)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var res clientListResponse
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&res)
|
||||||
|
assert.Len(t, res.Items, 1)
|
||||||
|
assert.Equal(t, "client-1", res.Items[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
func TestUpdateClientStatus_Success(t *testing.T) {
|
func TestUpdateClientStatus_Success(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||||
@@ -164,6 +202,38 @@ func TestUpdateClientStatus_Success(t *testing.T) {
|
|||||||
assert.Equal(t, "inactive", res.Client.Status)
|
assert.Equal(t, "inactive", res.Client.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateClientStatus_ProtectedSystemClientForbidden(t *testing.T) {
|
||||||
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" {
|
||||||
|
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
||||||
|
"client_id": "oathkeeper-introspect",
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
h := &DevHandler{
|
||||||
|
Hydra: &service.HydraAdminService{
|
||||||
|
AdminURL: "http://hydra.test",
|
||||||
|
HTTPClient: &http.Client{Transport: transport},
|
||||||
|
},
|
||||||
|
Keto: new(devMockKetoService),
|
||||||
|
}
|
||||||
|
app := fiber.New()
|
||||||
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin})
|
||||||
|
return c.Next()
|
||||||
|
})
|
||||||
|
app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]interface{}{"status": "inactive"})
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/oathkeeper-introspect/status", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, _ := app.Test(req, -1)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeleteClient_Success(t *testing.T) {
|
func TestDeleteClient_Success(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||||
@@ -204,6 +274,67 @@ func TestDeleteClient_Success(t *testing.T) {
|
|||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeleteClient_ProtectedSystemClientForbidden(t *testing.T) {
|
||||||
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" {
|
||||||
|
return httpJSONAny(r, http.StatusOK, map[string]interface{}{"client_id": "oathkeeper-introspect"}), nil
|
||||||
|
}
|
||||||
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
h := &DevHandler{
|
||||||
|
Hydra: &service.HydraAdminService{
|
||||||
|
AdminURL: "http://hydra.test",
|
||||||
|
HTTPClient: &http.Client{Transport: transport},
|
||||||
|
},
|
||||||
|
SecretRepo: &mockSecretRepo{secrets: map[string]string{"oathkeeper-introspect": "secret"}},
|
||||||
|
Redis: &devMockRedisRepo{data: map[string]string{"client_secret:oathkeeper-introspect": "secret"}},
|
||||||
|
Keto: new(devMockKetoService),
|
||||||
|
}
|
||||||
|
app := fiber.New()
|
||||||
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin})
|
||||||
|
return c.Next()
|
||||||
|
})
|
||||||
|
app.Delete("/api/v1/dev/clients/:id", h.DeleteClient)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, "/api/v1/dev/clients/oathkeeper-introspect", nil)
|
||||||
|
resp, _ := app.Test(req, -1)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetClient_ProtectedSystemClientHidden(t *testing.T) {
|
||||||
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" {
|
||||||
|
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
||||||
|
"client_id": "oathkeeper-introspect",
|
||||||
|
"client_name": "Internal Client",
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
h := &DevHandler{
|
||||||
|
Hydra: &service.HydraAdminService{
|
||||||
|
AdminURL: "http://hydra.test",
|
||||||
|
HTTPClient: &http.Client{Transport: transport},
|
||||||
|
},
|
||||||
|
Keto: new(devMockKetoService),
|
||||||
|
}
|
||||||
|
app := fiber.New()
|
||||||
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin})
|
||||||
|
return c.Next()
|
||||||
|
})
|
||||||
|
app.Get("/api/v1/dev/clients/:id", h.GetClient)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/oathkeeper-introspect", nil)
|
||||||
|
resp, _ := app.Test(req, -1)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
func TestRotateClientSecret_Success(t *testing.T) {
|
func TestRotateClientSecret_Success(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||||
@@ -254,6 +385,7 @@ func TestGetStats_Success(t *testing.T) {
|
|||||||
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
||||||
{"client_id": "c1", "metadata": map[string]interface{}{"tenant_id": "t1"}},
|
{"client_id": "c1", "metadata": map[string]interface{}{"tenant_id": "t1"}},
|
||||||
{"client_id": "c2", "metadata": map[string]interface{}{"tenant_id": "t1"}},
|
{"client_id": "c2", "metadata": map[string]interface{}{"tenant_id": "t1"}},
|
||||||
|
{"client_id": "oathkeeper-introspect", "metadata": map[string]interface{}{"tenant_id": "t1"}},
|
||||||
{"client_id": "c3", "metadata": map[string]interface{}{"tenant_id": "t2"}},
|
{"client_id": "c3", "metadata": map[string]interface{}{"tenant_id": "t2"}},
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ services:
|
|||||||
hydra create oauth2-client \
|
hydra create oauth2-client \
|
||||||
--endpoint http://hydra:4445 \
|
--endpoint http://hydra:4445 \
|
||||||
--id adminfront \
|
--id adminfront \
|
||||||
|
--name "AdminFront" \
|
||||||
--grant-type authorization_code,refresh_token \
|
--grant-type authorization_code,refresh_token \
|
||||||
--response-type code \
|
--response-type code \
|
||||||
--scope openid,offline_access,profile,email \
|
--scope openid,offline_access,profile,email \
|
||||||
@@ -220,6 +221,7 @@ services:
|
|||||||
hydra create oauth2-client \
|
hydra create oauth2-client \
|
||||||
--endpoint http://hydra:4445 \
|
--endpoint http://hydra:4445 \
|
||||||
--id devfront \
|
--id devfront \
|
||||||
|
--name "DevFront" \
|
||||||
--grant-type authorization_code,refresh_token \
|
--grant-type authorization_code,refresh_token \
|
||||||
--response-type code \
|
--response-type code \
|
||||||
--scope openid,offline_access,profile,email \
|
--scope openid,offline_access,profile,email \
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
|
||||||
|
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read environment variables from file.
|
* Read environment variables from file.
|
||||||
* https://github.com/motdotla/dotenv
|
* https://github.com/motdotla/dotenv
|
||||||
@@ -20,7 +24,7 @@ export default defineConfig({
|
|||||||
/* Retry on CI only */
|
/* Retry on CI only */
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
/* Opt out of parallel tests on CI. */
|
/* Opt out of parallel tests on CI. */
|
||||||
workers: process.env.CI ? 1 : undefined,
|
workers: configuredWorkers ?? (process.env.CI ? 1 : undefined),
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
reporter: "html",
|
reporter: "html",
|
||||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
|
|||||||
@@ -275,31 +275,36 @@ services:
|
|||||||
tar -xzf /tmp/hydra.tar.gz -C /usr/local/bin hydra
|
tar -xzf /tmp/hydra.tar.gz -C /usr/local/bin hydra
|
||||||
rm /tmp/hydra.tar.gz
|
rm /tmp/hydra.tar.gz
|
||||||
|
|
||||||
hydra delete oauth2-client --endpoint http://hydra:4445 adminfront >/dev/null 2>&1 || true
|
# Function to create or update OAuth2 client (Idempotency)
|
||||||
hydra delete oauth2-client --endpoint http://hydra:4445 devfront >/dev/null 2>&1 || true
|
upsert_client() {
|
||||||
hydra delete oauth2-client --endpoint http://hydra:4445 $${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect} >/dev/null 2>&1 || true
|
ID=$1
|
||||||
|
shift
|
||||||
|
if hydra get oauth2-client --endpoint http://hydra:4445 "$ID" >/dev/null 2>&1; then
|
||||||
|
echo "Updating existing client: $ID"
|
||||||
|
hydra update oauth2-client --endpoint http://hydra:4445 "$ID" "$@"
|
||||||
|
else
|
||||||
|
echo "Creating new client: $ID"
|
||||||
|
hydra create oauth2-client --endpoint http://hydra:4445 --id "$ID" "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
hydra create oauth2-client \
|
upsert_client "adminfront" \
|
||||||
--endpoint http://hydra:4445 \
|
--name "AdminFront" \
|
||||||
--id adminfront \
|
|
||||||
--grant-type authorization_code,refresh_token \
|
--grant-type authorization_code,refresh_token \
|
||||||
--response-type code \
|
--response-type code \
|
||||||
--scope openid,offline_access,profile,email \
|
--scope openid,offline_access,profile,email \
|
||||||
--token-endpoint-auth-method none \
|
--token-endpoint-auth-method none \
|
||||||
--redirect-uri "$${ADMINFRONT_CALLBACK_URLS:-http://localhost:5173/auth/callback}"
|
--redirect-uri "$${ADMINFRONT_CALLBACK_URLS:-http://localhost:5173/auth/callback}"
|
||||||
|
|
||||||
hydra create oauth2-client \
|
upsert_client "devfront" \
|
||||||
--endpoint http://hydra:4445 \
|
--name "DevFront" \
|
||||||
--id devfront \
|
|
||||||
--grant-type authorization_code,refresh_token \
|
--grant-type authorization_code,refresh_token \
|
||||||
--response-type code \
|
--response-type code \
|
||||||
--scope openid,offline_access,profile,email \
|
--scope openid,offline_access,profile,email \
|
||||||
--token-endpoint-auth-method none \
|
--token-endpoint-auth-method none \
|
||||||
--redirect-uri "$${DEVFRONT_CALLBACK_URLS:-http://localhost:5174/auth/callback}"
|
--redirect-uri "$${DEVFRONT_CALLBACK_URLS:-http://localhost:5174/auth/callback}"
|
||||||
|
|
||||||
hydra create oauth2-client \
|
upsert_client "$${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}" \
|
||||||
--endpoint http://hydra:4445 \
|
|
||||||
--id "$${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}" \
|
|
||||||
--secret "$${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}" \
|
--secret "$${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}" \
|
||||||
--grant-type client_credentials \
|
--grant-type client_credentials \
|
||||||
--response-type token \
|
--response-type token \
|
||||||
|
|||||||
19
docs/i18n.md
19
docs/i18n.md
@@ -111,6 +111,14 @@ TOML에서는 `[Section]`을 사용하여 계층을 표현합니다.
|
|||||||
#### 5.2.2 관리 프로세스 (Template & CI)
|
#### 5.2.2 관리 프로세스 (Template & CI)
|
||||||
1. **`template.toml`**: 개발자가 새로운 번역 키를 추가할 때 반드시 이 파일에 먼저 정의해야 합니다.
|
1. **`template.toml`**: 개발자가 새로운 번역 키를 추가할 때 반드시 이 파일에 먼저 정의해야 합니다.
|
||||||
2. **`ko.toml`, `en.toml`**: 템플릿의 키를 바탕으로 실제 번역 값을 채워 넣습니다.
|
2. **`ko.toml`, `en.toml`**: 템플릿의 키를 바탕으로 실제 번역 값을 채워 넣습니다.
|
||||||
|
3. **UserFront 런타임 리소스 동기화**:
|
||||||
|
* UserFront는 런타임에 `userfront/assets/translations/*.toml`을 직접 읽습니다.
|
||||||
|
* 따라서 `locales/ko.toml`, `locales/en.toml`, `locales/template.toml`을 수정한 뒤에는 반드시 아래 동기화 스크립트를 실행해야 합니다.
|
||||||
|
* 실행 명령:
|
||||||
|
```bash
|
||||||
|
./scripts/sync_userfront_locales.sh
|
||||||
|
```
|
||||||
|
* 이 단계가 누락되면 루트 SoT와 UserFront 실제 표시 문구가 어긋날 수 있습니다.
|
||||||
3. **CI 검증 (Verification)**:
|
3. **CI 검증 (Verification)**:
|
||||||
* **Level 1: 리소스 동기화 검사 (`template` vs `lang`)**
|
* **Level 1: 리소스 동기화 검사 (`template` vs `lang`)**
|
||||||
* `template.toml`에 있는 모든 키가 `ko.toml`, `en.toml`에 존재하는지 재귀적으로 검사합니다.
|
* `template.toml`에 있는 모든 키가 `ko.toml`, `en.toml`에 존재하는지 재귀적으로 검사합니다.
|
||||||
@@ -244,3 +252,14 @@ UserFront(`/error`)는 프로덕션에서 다음 규칙으로 에러를 표시
|
|||||||
2. [ ] **CI**: `template.toml` vs `*.toml` 키 동기화 검증 스크립트 작성 (`scripts/verify-i18n.js` or `py`).
|
2. [ ] **CI**: `template.toml` vs `*.toml` 키 동기화 검증 스크립트 작성 (`scripts/verify-i18n.js` or `py`).
|
||||||
3. [ ] **Admin/DevFront**: Vite TOML 플러그인 설정 및 `react-i18next` 연동.
|
3. [ ] **Admin/DevFront**: Vite TOML 플러그인 설정 및 `react-i18next` 연동.
|
||||||
4. [ ] **UserFront**: TOML -> JSON 변환 스크립트 추가 및 `easy_localization` 연동.
|
4. [ ] **UserFront**: TOML -> JSON 변환 스크립트 추가 및 `easy_localization` 연동.
|
||||||
|
|
||||||
|
### 6.1 UserFront 번역 수정 체크포인트
|
||||||
|
|
||||||
|
UserFront 번역을 수정할 때는 아래 순서를 기본 절차로 사용합니다.
|
||||||
|
|
||||||
|
1. `locales/*.toml` 수정
|
||||||
|
2. `./scripts/sync_userfront_locales.sh` 실행
|
||||||
|
3. UserFront 회귀 테스트 실행
|
||||||
|
- 예: `cd userfront && flutter test test/english_locale_placeholder_test.dart`
|
||||||
|
4. 전체 키 정합성 점검
|
||||||
|
- 예: `node tools/i18n-scanner/index.js`
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Issue #434 트러블슈팅 기록: 대시보드 세션 시작 시간이 `Unknown`으로 표시됨
|
||||||
|
|
||||||
|
## 기준 시점
|
||||||
|
- 2026-03-24 KST
|
||||||
|
- 대상 화면: UserFront 대시보드 상단 세션 정보 칩
|
||||||
|
|
||||||
|
## 증상
|
||||||
|
- 로그인 후 대시보드의 세션 시작 시간이 `Unknown` 또는 `알 수 없음`으로 표시됨
|
||||||
|
- 특히 동일 브라우저의 cookie session 승격 경로에서 재현됨
|
||||||
|
|
||||||
|
## 원인
|
||||||
|
1. 기존 대시보드는 저장된 로컬 토큰만 파싱해 `iat` 또는 `auth_time`을 읽었습니다.
|
||||||
|
2. cookie mode에서는 `AuthTokenStore.setCookieMode()`가 로컬 토큰을 제거하고 cookie 플래그만 유지합니다.
|
||||||
|
3. 그 결과 대시보드는 파싱할 JWT가 없어 항상 fallback 문구로 떨어졌습니다.
|
||||||
|
|
||||||
|
## 수정 방향
|
||||||
|
1. Backend `/api/v1/user/me` 응답에 Kratos `sessions/whoami`의 `authenticated_at` 값을 `sessionAuthenticatedAt`으로 포함합니다.
|
||||||
|
2. UserFront 대시보드는 세션 시각 계산 시 다음 우선순위를 사용합니다.
|
||||||
|
- JWT의 `iat` 또는 `auth_time`
|
||||||
|
- profile의 `sessionAuthenticatedAt`
|
||||||
|
3. 두 값이 모두 없을 때만 `ui.userfront.session.unknown` fallback을 사용합니다.
|
||||||
|
|
||||||
|
## 반영 파일
|
||||||
|
- `backend/internal/domain/auth_models.go`
|
||||||
|
- `backend/internal/handler/auth_handler.go`
|
||||||
|
- `backend/docs/openapi.yaml`
|
||||||
|
- `userfront/lib/features/profile/data/models/user_profile_model.dart`
|
||||||
|
- `userfront/lib/features/dashboard/domain/session_time_resolver.dart`
|
||||||
|
- `userfront/lib/features/dashboard/presentation/dashboard_screen.dart`
|
||||||
|
|
||||||
|
## 회귀 테스트
|
||||||
|
- Backend
|
||||||
|
- `backend/internal/handler/auth_handler_session_profile_test.go`
|
||||||
|
- UserFront
|
||||||
|
- `userfront/test/dashboard_session_time_resolver_test.dart`
|
||||||
|
- `userfront/test/dashboard_screen_smoke_test.dart`
|
||||||
|
|
||||||
|
## 검증 명령
|
||||||
|
- `GOCACHE=/tmp/go-build go test ./internal/handler -run 'TestGetMe_IncludesSessionAuthenticatedAt' -count=1`
|
||||||
|
- `flutter test test/dashboard_session_time_resolver_test.dart`
|
||||||
|
- `flutter test test/dashboard_screen_smoke_test.dart`
|
||||||
|
|
||||||
|
## 남은 참고사항
|
||||||
|
- Hydra introspection fallback만 사용되는 토큰 경로에서는 `sessionAuthenticatedAt`이 비어 있을 수 있습니다.
|
||||||
|
- 이 경우에도 JWT claim이 없으면 기존 fallback 문구를 유지합니다.
|
||||||
841
locales/en.toml
841
locales/en.toml
File diff suppressed because one or more lines are too long
@@ -849,6 +849,11 @@ symbol = "특수문자 1개 이상"
|
|||||||
uppercase = "대문자 1개 이상"
|
uppercase = "대문자 1개 이상"
|
||||||
|
|
||||||
[msg.userfront.signup.agreement]
|
[msg.userfront.signup.agreement]
|
||||||
|
all_hint = "필수 약관 2개를 모두 확인하고 동의하면 다음 단계로 진행할 수 있습니다."
|
||||||
|
description = "계속 진행하려면 서비스 이용 조건과 개인정보 수집·이용 항목을 확인한 뒤 동의해주세요."
|
||||||
|
privacy_summary = "개인정보 수집 항목, 이용 목적, 보관 기준을 안내합니다."
|
||||||
|
progress = "필수 약관 {{total}}개 중 {{count}}개 동의 완료"
|
||||||
|
tos_summary = "서비스 이용 조건과 책임 범위를 확인할 수 있습니다."
|
||||||
title = "서비스 이용을 위해\n약관에 동의해주세요"
|
title = "서비스 이용을 위해\n약관에 동의해주세요"
|
||||||
|
|
||||||
[msg.userfront.signup.auth]
|
[msg.userfront.signup.auth]
|
||||||
@@ -1352,6 +1357,7 @@ security = "보안"
|
|||||||
[ui.userfront.signup.agreement]
|
[ui.userfront.signup.agreement]
|
||||||
all = "모두 동의합니다"
|
all = "모두 동의합니다"
|
||||||
privacy_title = "개인정보 수집 및 이용 동의 (필수)"
|
privacy_title = "개인정보 수집 및 이용 동의 (필수)"
|
||||||
|
required = "필수"
|
||||||
tos_title = "바론 소프트웨어 이용약관 (필수)"
|
tos_title = "바론 소프트웨어 이용약관 (필수)"
|
||||||
|
|
||||||
[ui.userfront.signup.auth]
|
[ui.userfront.signup.auth]
|
||||||
|
|||||||
@@ -849,6 +849,11 @@ symbol = ""
|
|||||||
uppercase = ""
|
uppercase = ""
|
||||||
|
|
||||||
[msg.userfront.signup.agreement]
|
[msg.userfront.signup.agreement]
|
||||||
|
all_hint = ""
|
||||||
|
description = ""
|
||||||
|
privacy_summary = ""
|
||||||
|
progress = ""
|
||||||
|
tos_summary = ""
|
||||||
title = ""
|
title = ""
|
||||||
|
|
||||||
[msg.userfront.signup.auth]
|
[msg.userfront.signup.auth]
|
||||||
@@ -1352,6 +1357,7 @@ security = ""
|
|||||||
[ui.userfront.signup.agreement]
|
[ui.userfront.signup.agreement]
|
||||||
all = ""
|
all = ""
|
||||||
privacy_title = ""
|
privacy_title = ""
|
||||||
|
required = ""
|
||||||
tos_title = ""
|
tos_title = ""
|
||||||
|
|
||||||
[ui.userfront.signup.auth]
|
[ui.userfront.signup.auth]
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ USERFRONT_URL="${USERFRONT_URL:-http://localhost:5000}"
|
|||||||
OATHKEEPER_PUBLIC_URL="${OATHKEEPER_PUBLIC_URL:-$USERFRONT_URL}"
|
OATHKEEPER_PUBLIC_URL="${OATHKEEPER_PUBLIC_URL:-$USERFRONT_URL}"
|
||||||
HYDRA_PUBLIC_URL="${HYDRA_PUBLIC_URL:-${OATHKEEPER_PUBLIC_URL%/}/oidc}"
|
HYDRA_PUBLIC_URL="${HYDRA_PUBLIC_URL:-${OATHKEEPER_PUBLIC_URL%/}/oidc}"
|
||||||
KRATOS_UI_URL="${KRATOS_UI_URL:-http://localhost:5000}"
|
KRATOS_UI_URL="${KRATOS_UI_URL:-http://localhost:5000}"
|
||||||
ADMINFRONT_CALLBACK_URLS="${ADMINFRONT_CALLBACK_URLS:-http://localhost:5173/auth/callback}"
|
ADMINFRONT_URL="${ADMINFRONT_URL:-https://sadmin.hmac.kr}"
|
||||||
DEVFRONT_CALLBACK_URLS="${DEVFRONT_CALLBACK_URLS:-http://localhost:5174/callback}"
|
DEVFRONT_URL="${DEVFRONT_URL:-https://sdev.hmac.kr}"
|
||||||
|
ADMINFRONT_CALLBACK_URLS="${ADMINFRONT_CALLBACK_URLS:-http://172.16.10.176:5173/auth/callback}"
|
||||||
|
DEVFRONT_CALLBACK_URLS="${DEVFRONT_CALLBACK_URLS:-http://172.16.10.176:5174/auth/callback}"
|
||||||
KRATOS_ALLOWED_RETURN_URLS_EXTRA="${KRATOS_ALLOWED_RETURN_URLS_EXTRA:-}"
|
KRATOS_ALLOWED_RETURN_URLS_EXTRA="${KRATOS_ALLOWED_RETURN_URLS_EXTRA:-}"
|
||||||
|
|
||||||
declare -a WARNINGS=()
|
declare -a WARNINGS=()
|
||||||
@@ -382,12 +384,21 @@ run_validation() {
|
|||||||
validate_dotenv_line_safety "HYDRA_PUBLIC_URL"
|
validate_dotenv_line_safety "HYDRA_PUBLIC_URL"
|
||||||
validate_dotenv_line_safety "KRATOS_BROWSER_URL"
|
validate_dotenv_line_safety "KRATOS_BROWSER_URL"
|
||||||
validate_dotenv_line_safety "KRATOS_UI_URL"
|
validate_dotenv_line_safety "KRATOS_UI_URL"
|
||||||
|
validate_dotenv_line_safety "ADMINFRONT_URL"
|
||||||
|
validate_dotenv_line_safety "DEVFRONT_URL"
|
||||||
validate_dotenv_line_safety "ADMINFRONT_CALLBACK_URLS"
|
validate_dotenv_line_safety "ADMINFRONT_CALLBACK_URLS"
|
||||||
validate_dotenv_line_safety "DEVFRONT_CALLBACK_URLS"
|
validate_dotenv_line_safety "DEVFRONT_CALLBACK_URLS"
|
||||||
|
|
||||||
|
if [[ -n "$ADMINFRONT_URL" ]]; then
|
||||||
|
validate_urls "ADMINFRONT_URL" "$ADMINFRONT_URL"
|
||||||
|
fi
|
||||||
|
if [[ -n "$DEVFRONT_URL" ]]; then
|
||||||
|
validate_urls "DEVFRONT_URL" "$DEVFRONT_URL"
|
||||||
|
fi
|
||||||
|
|
||||||
collect_values
|
collect_values
|
||||||
validate_callback_group "ADMINFRONT_CALLBACK_URLS" "/auth/callback" "${ADMIN_CALLBACKS[@]}"
|
validate_callback_group "ADMINFRONT_CALLBACK_URLS" "/auth/callback" "${ADMIN_CALLBACKS[@]}"
|
||||||
validate_callback_group "DEVFRONT_CALLBACK_URLS" "/callback" "${DEV_CALLBACKS[@]}"
|
validate_callback_group "DEVFRONT_CALLBACK_URLS" "/auth/callback" "${DEV_CALLBACKS[@]}"
|
||||||
validate_gateway_mapping
|
validate_gateway_mapping
|
||||||
build_allowed_return_urls
|
build_allowed_return_urls
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ const port = Number.parseInt(process.env.PORT ?? '4173', 10);
|
|||||||
const defaultBaseUrl = `http://127.0.0.1:${port}`;
|
const defaultBaseUrl = `http://127.0.0.1:${port}`;
|
||||||
const baseURL = process.env.BASE_URL ?? defaultBaseUrl;
|
const baseURL = process.env.BASE_URL ?? defaultBaseUrl;
|
||||||
const reuseExistingServer = !process.env.CI;
|
const reuseExistingServer = !process.env.CI;
|
||||||
|
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
|
||||||
|
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './tests',
|
testDir: './tests',
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
workers: process.env.CI ? 1 : undefined,
|
workers: configuredWorkers ?? (process.env.CI ? 1 : undefined),
|
||||||
reporter: process.env.CI ? [['html', { open: 'never' }], ['list']] : 'html',
|
reporter: process.env.CI ? [['html', { open: 'never' }], ['list']] : 'html',
|
||||||
use: {
|
use: {
|
||||||
baseURL,
|
baseURL,
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ async function blurDepartmentEditor(page: Page): Promise<void> {
|
|||||||
await page.waitForTimeout(250);
|
await page.waitForTimeout(250);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitDepartmentEditor(page: Page): Promise<void> {
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
}
|
||||||
|
|
||||||
async function mockProfileApis(page: Page, state: ProfileState): Promise<void> {
|
async function mockProfileApis(page: Page, state: ProfileState): Promise<void> {
|
||||||
await page.route('**/api/v1/**', async (route: Route) => {
|
await page.route('**/api/v1/**', async (route: Route) => {
|
||||||
const request = route.request();
|
const request = route.request();
|
||||||
@@ -155,7 +160,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
|||||||
await page.unroute('**/api/v1/**');
|
await page.unroute('**/api/v1/**');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('소속 수정 후 포커스 아웃하면 저장 요청이 전송되고 새로고침 후 최신 값으로 재조회된다', async ({
|
test('소속 수정 후 명시 저장하면 저장 요청이 전송되고 새로고침 후 최신 값으로 재조회된다', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const state: ProfileState = {
|
const state: ProfileState = {
|
||||||
@@ -170,7 +175,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
|||||||
|
|
||||||
await openDepartmentEditor(page);
|
await openDepartmentEditor(page);
|
||||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-Updated');
|
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-Updated');
|
||||||
await blurDepartmentEditor(page);
|
await submitDepartmentEditor(page);
|
||||||
|
|
||||||
await expect.poll(() => state.putBodies.length).toBe(1);
|
await expect.poll(() => state.putBodies.length).toBe(1);
|
||||||
expect(state.putBodies[0]?.department).toBe('QA-Updated');
|
expect(state.putBodies[0]?.department).toBe('QA-Updated');
|
||||||
@@ -248,7 +253,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
|||||||
expect(state.department).toBe('QA');
|
expect(state.department).toBe('QA');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('소속을 수정한 뒤 새로고침 후 다시 수정해도 저장 요청이 누락되지 않는다', async ({ page }) => {
|
test('소속을 저장한 뒤 새로고침 후 다시 저장해도 저장 요청이 누락되지 않는다', async ({ page }) => {
|
||||||
const state: ProfileState = {
|
const state: ProfileState = {
|
||||||
department: 'QA',
|
department: 'QA',
|
||||||
getMeCount: 0,
|
getMeCount: 0,
|
||||||
@@ -261,7 +266,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
|||||||
|
|
||||||
await openDepartmentEditor(page);
|
await openDepartmentEditor(page);
|
||||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-1');
|
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-1');
|
||||||
await blurDepartmentEditor(page);
|
await submitDepartmentEditor(page);
|
||||||
await expect.poll(() => state.putBodies.length).toBe(1);
|
await expect.poll(() => state.putBodies.length).toBe(1);
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
@@ -270,7 +275,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
|||||||
|
|
||||||
await openDepartmentEditor(page);
|
await openDepartmentEditor(page);
|
||||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-2');
|
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-2');
|
||||||
await blurDepartmentEditor(page);
|
await submitDepartmentEditor(page);
|
||||||
await expect.poll(() => state.putBodies.length).toBe(2);
|
await expect.poll(() => state.putBodies.length).toBe(2);
|
||||||
|
|
||||||
expect(state.putBodies[0]?.department).toBe('QA-1');
|
expect(state.putBodies[0]?.department).toBe('QA-1');
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -277,6 +277,11 @@ privacy_full = "개인정보 수집 및 이용 동의 전문..."
|
|||||||
tos_full = "서비스 이용약관 전문..."
|
tos_full = "서비스 이용약관 전문..."
|
||||||
|
|
||||||
[msg.userfront.signup.agreement]
|
[msg.userfront.signup.agreement]
|
||||||
|
all_hint = "필수 약관 2개를 모두 확인하고 동의하면 다음 단계로 진행할 수 있습니다."
|
||||||
|
description = "계속 진행하려면 서비스 이용 조건과 개인정보 수집·이용 항목을 확인한 뒤 동의해주세요."
|
||||||
|
privacy_summary = "개인정보 수집 항목, 이용 목적, 보관 기준을 안내합니다."
|
||||||
|
progress = "필수 약관 {total}개 중 {count}개 동의 완료"
|
||||||
|
tos_summary = "서비스 이용 조건과 책임 범위를 확인할 수 있습니다."
|
||||||
title = "서비스 이용을 위해\n약관에 동의해주세요"
|
title = "서비스 이용을 위해\n약관에 동의해주세요"
|
||||||
|
|
||||||
[msg.userfront.signup.auth]
|
[msg.userfront.signup.auth]
|
||||||
@@ -583,6 +588,7 @@ title = "회원가입"
|
|||||||
[ui.userfront.signup.agreement]
|
[ui.userfront.signup.agreement]
|
||||||
all = "모두 동의합니다"
|
all = "모두 동의합니다"
|
||||||
privacy_title = "개인정보 수집 및 이용 동의 (필수)"
|
privacy_title = "개인정보 수집 및 이용 동의 (필수)"
|
||||||
|
required = "필수"
|
||||||
tos_title = "바론 소프트웨어 이용약관 (필수)"
|
tos_title = "바론 소프트웨어 이용약관 (필수)"
|
||||||
|
|
||||||
[ui.userfront.signup.auth]
|
[ui.userfront.signup.auth]
|
||||||
@@ -616,4 +622,3 @@ verify = "본인인증"
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = "로그인하기"
|
action = "로그인하기"
|
||||||
|
|
||||||
|
|||||||
@@ -277,6 +277,11 @@ privacy_full = ""
|
|||||||
tos_full = ""
|
tos_full = ""
|
||||||
|
|
||||||
[msg.userfront.signup.agreement]
|
[msg.userfront.signup.agreement]
|
||||||
|
all_hint = ""
|
||||||
|
description = ""
|
||||||
|
privacy_summary = ""
|
||||||
|
progress = ""
|
||||||
|
tos_summary = ""
|
||||||
title = ""
|
title = ""
|
||||||
|
|
||||||
[msg.userfront.signup.auth]
|
[msg.userfront.signup.auth]
|
||||||
@@ -583,6 +588,7 @@ title = ""
|
|||||||
[ui.userfront.signup.agreement]
|
[ui.userfront.signup.agreement]
|
||||||
all = ""
|
all = ""
|
||||||
privacy_title = ""
|
privacy_title = ""
|
||||||
|
required = ""
|
||||||
tos_title = ""
|
tos_title = ""
|
||||||
|
|
||||||
[ui.userfront.signup.auth]
|
[ui.userfront.signup.auth]
|
||||||
@@ -616,4 +622,3 @@ verify = ""
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = ""
|
action = ""
|
||||||
|
|
||||||
|
|||||||
234
userfront/lib/core/ui/toast_service.dart
Normal file
234
userfront/lib/core/ui/toast_service.dart
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
enum ToastType { success, error, info }
|
||||||
|
|
||||||
|
class _ToastItem {
|
||||||
|
const _ToastItem({
|
||||||
|
required this.id,
|
||||||
|
required this.message,
|
||||||
|
required this.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String message;
|
||||||
|
final ToastType type;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ToastService {
|
||||||
|
static const Duration _displayDuration = Duration(milliseconds: 3000);
|
||||||
|
static final ValueNotifier<List<_ToastItem>> _toasts =
|
||||||
|
ValueNotifier<List<_ToastItem>>(<_ToastItem>[]);
|
||||||
|
|
||||||
|
static void success(String message) {
|
||||||
|
show(message, type: ToastType.success);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void error(String message) {
|
||||||
|
show(message, type: ToastType.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void info(String message) {
|
||||||
|
show(message, type: ToastType.info);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void show(String message, {ToastType type = ToastType.success}) {
|
||||||
|
final trimmed = message.trim();
|
||||||
|
if (trimmed.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final item = _ToastItem(
|
||||||
|
id: '${DateTime.now().microsecondsSinceEpoch}-${_toasts.value.length}',
|
||||||
|
message: trimmed,
|
||||||
|
type: type,
|
||||||
|
);
|
||||||
|
|
||||||
|
_toasts.value = [..._toasts.value, item];
|
||||||
|
|
||||||
|
unawaited(
|
||||||
|
Future<void>.delayed(_displayDuration, () {
|
||||||
|
_remove(item.id);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _remove(String id) {
|
||||||
|
final next = _toasts.value.where((toast) => toast.id != id).toList();
|
||||||
|
if (next.length == _toasts.value.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_toasts.value = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ToastViewport extends StatelessWidget {
|
||||||
|
const ToastViewport({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return IgnorePointer(
|
||||||
|
ignoring: true,
|
||||||
|
child: SafeArea(
|
||||||
|
child: ValueListenableBuilder<List<_ToastItem>>(
|
||||||
|
valueListenable: ToastService._toasts,
|
||||||
|
builder: (context, toasts, _) {
|
||||||
|
if (toasts.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final media = MediaQuery.of(context);
|
||||||
|
final width = math.min(320.0, media.size.width - 32);
|
||||||
|
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.bottomRight,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 16, bottom: 16),
|
||||||
|
child: SizedBox(
|
||||||
|
width: width > 0 ? width : 320,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
for (final toast in toasts)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: _ToastCard(item: toast),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ToastCard extends StatefulWidget {
|
||||||
|
const _ToastCard({required this.item});
|
||||||
|
|
||||||
|
final _ToastItem item;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ToastCard> createState() => _ToastCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ToastCardState extends State<_ToastCard> {
|
||||||
|
bool _visible = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_visible = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = _toastColorScheme(widget.item.type);
|
||||||
|
final icon = _toastIcon(widget.item.type);
|
||||||
|
|
||||||
|
return AnimatedSlide(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
offset: _visible ? Offset.zero : const Offset(1, 0),
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
opacity: _visible ? 1 : 0,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: scheme.background,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: scheme.border),
|
||||||
|
boxShadow: const [
|
||||||
|
BoxShadow(
|
||||||
|
color: Color(0x26000000),
|
||||||
|
blurRadius: 16,
|
||||||
|
offset: Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 20, color: scheme.foreground),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
widget.item.message,
|
||||||
|
style: TextStyle(
|
||||||
|
color: scheme.foreground,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
height: 1.2,
|
||||||
|
decoration: TextDecoration.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_ToastColorScheme _toastColorScheme(ToastType type) {
|
||||||
|
switch (type) {
|
||||||
|
case ToastType.success:
|
||||||
|
return const _ToastColorScheme(
|
||||||
|
background: Color(0xFFECFDF5),
|
||||||
|
border: Color(0xFFA7F3D0),
|
||||||
|
foreground: Color(0xFF065F46),
|
||||||
|
);
|
||||||
|
case ToastType.error:
|
||||||
|
return const _ToastColorScheme(
|
||||||
|
background: Color(0xFFFFF1F2),
|
||||||
|
border: Color(0xFFFDA4AF),
|
||||||
|
foreground: Color(0xFF9F1239),
|
||||||
|
);
|
||||||
|
case ToastType.info:
|
||||||
|
return const _ToastColorScheme(
|
||||||
|
background: Color(0xFFEFF6FF),
|
||||||
|
border: Color(0xFFBFDBFE),
|
||||||
|
foreground: Color(0xFF1E40AF),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _toastIcon(ToastType type) {
|
||||||
|
switch (type) {
|
||||||
|
case ToastType.success:
|
||||||
|
return Icons.check_circle_outline;
|
||||||
|
case ToastType.error:
|
||||||
|
return Icons.error_outline;
|
||||||
|
case ToastType.info:
|
||||||
|
return Icons.info_outline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ToastColorScheme {
|
||||||
|
const _ToastColorScheme({
|
||||||
|
required this.background,
|
||||||
|
required this.border,
|
||||||
|
required this.foreground,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Color background;
|
||||||
|
final Color border;
|
||||||
|
final Color foreground;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../../core/services/auth_proxy_service.dart';
|
import '../../../../core/services/auth_proxy_service.dart';
|
||||||
import '../../../../core/i18n/locale_utils.dart';
|
import '../../../../core/i18n/locale_utils.dart';
|
||||||
|
import '../../../../core/ui/toast_service.dart';
|
||||||
|
|
||||||
class CreateUserScreen extends StatefulWidget {
|
class CreateUserScreen extends StatefulWidget {
|
||||||
const CreateUserScreen({super.key});
|
const CreateUserScreen({super.key});
|
||||||
@@ -86,12 +87,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ToastService.error('Invalid Password. Access Denied.');
|
||||||
const SnackBar(
|
|
||||||
content: Text('Invalid Password. Access Denied.'),
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
context.go(buildLocalizedHomePath(Uri.base)); // Kick out
|
context.go(buildLocalizedHomePath(Uri.base)); // Kick out
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,12 +140,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ToastService.success('User created successfully!');
|
||||||
const SnackBar(
|
|
||||||
content: Text('User created successfully!'),
|
|
||||||
backgroundColor: Colors.green,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
_formKey.currentState!.reset();
|
_formKey.currentState!.reset();
|
||||||
_loginIdController.clear();
|
_loginIdController.clear();
|
||||||
_emailController.clear();
|
_emailController.clear();
|
||||||
@@ -158,9 +149,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ToastService.error('Error: $e');
|
||||||
SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _isLoading = false);
|
if (mounted) setState(() => _isLoading = false);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import '../../../../core/services/auth_proxy_service.dart';
|
import '../../../../core/services/auth_proxy_service.dart';
|
||||||
import '../../../../core/i18n/locale_utils.dart';
|
import '../../../../core/i18n/locale_utils.dart';
|
||||||
|
import '../../../../core/ui/toast_service.dart';
|
||||||
|
|
||||||
class UserManagementScreen extends StatefulWidget {
|
class UserManagementScreen extends StatefulWidget {
|
||||||
const UserManagementScreen({super.key});
|
const UserManagementScreen({super.key});
|
||||||
@@ -108,12 +109,7 @@ class _UserManagementScreenState extends State<UserManagementScreen>
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ToastService.error('Invalid Password');
|
||||||
const SnackBar(
|
|
||||||
content: Text('Invalid Password'),
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
context.go(buildLocalizedHomePath(Uri.base));
|
context.go(buildLocalizedHomePath(Uri.base));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -343,16 +339,12 @@ class _UserManagementScreenState extends State<UserManagementScreen>
|
|||||||
// --- UI Helpers ---
|
// --- UI Helpers ---
|
||||||
void _showError(String msg) {
|
void _showError(String msg) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(
|
ToastService.error(msg);
|
||||||
context,
|
|
||||||
).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.red));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showSuccess(String msg) {
|
void _showSuccess(String msg) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(
|
ToastService.success(msg);
|
||||||
context,
|
|
||||||
).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.green));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:userfront/i18n.dart';
|
|||||||
import 'package:userfront/core/i18n/locale_utils.dart';
|
import 'package:userfront/core/i18n/locale_utils.dart';
|
||||||
import 'package:userfront/core/services/auth_proxy_service.dart';
|
import 'package:userfront/core/services/auth_proxy_service.dart';
|
||||||
import 'package:userfront/core/services/web_window.dart';
|
import 'package:userfront/core/services/web_window.dart';
|
||||||
|
import 'package:userfront/core/ui/toast_service.dart';
|
||||||
|
|
||||||
class ConsentScreen extends StatefulWidget {
|
class ConsentScreen extends StatefulWidget {
|
||||||
final String consentChallenge;
|
final String consentChallenge;
|
||||||
@@ -187,17 +188,12 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _isSubmitting = false);
|
setState(() => _isSubmitting = false);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ToastService.error(
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.consent.cancel.error',
|
'msg.userfront.consent.cancel.error',
|
||||||
fallback:
|
fallback: 'An error occurred while cancelling consent: {{error}}',
|
||||||
'An error occurred while cancelling consent: {{error}}',
|
|
||||||
params: {'error': '$e'},
|
params: {'error': '$e'},
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,10 +233,13 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildConsentCard(BuildContext context) {
|
Widget _buildConsentCard(BuildContext context) {
|
||||||
final clientName =
|
final clientRawName = _consentInfo?['client']?['client_name'] as String?;
|
||||||
_consentInfo?['client']?['client_name'] ??
|
final clientId = _consentInfo?['client']?['client_id'] as String? ?? '-';
|
||||||
tr('msg.userfront.consent.client_unknown');
|
final clientName = (clientRawName != null && clientRawName.isNotEmpty)
|
||||||
final clientId = _consentInfo?['client']?['client_id'] ?? '-';
|
? clientRawName
|
||||||
|
: (clientId != '-'
|
||||||
|
? clientId
|
||||||
|
: tr('msg.userfront.consent.client_unknown'));
|
||||||
final clientLogo = _consentInfo?['client']?['logo_uri'];
|
final clientLogo = _consentInfo?['client']?['logo_uri'];
|
||||||
final requestedScopes =
|
final requestedScopes =
|
||||||
(_consentInfo?['requested_scope'] as List<dynamic>?)?.cast<String>() ??
|
(_consentInfo?['requested_scope'] as List<dynamic>?)?.cast<String>() ??
|
||||||
@@ -419,7 +418,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
|||||||
)
|
)
|
||||||
: Text(
|
: Text(
|
||||||
tr('ui.userfront.consent.accept'),
|
tr('ui.userfront.consent.accept'),
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../core/services/auth_proxy_service.dart';
|
import '../../../core/services/auth_proxy_service.dart';
|
||||||
|
import '../../../core/ui/toast_service.dart';
|
||||||
import 'package:userfront/i18n.dart';
|
import 'package:userfront/i18n.dart';
|
||||||
|
|
||||||
class ForgotPasswordScreen extends StatefulWidget {
|
class ForgotPasswordScreen extends StatefulWidget {
|
||||||
@@ -46,12 +47,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
|||||||
drySend: _drySendEnabled,
|
drySend: _drySendEnabled,
|
||||||
);
|
);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ToastService.success(tr('msg.userfront.forgot.sent'));
|
||||||
SnackBar(
|
|
||||||
content: Text(tr('msg.userfront.forgot.sent')),
|
|
||||||
backgroundColor: Colors.green,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -68,9 +64,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _showError(String message) {
|
void _showError(String message) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ToastService.error(message);
|
||||||
SnackBar(content: Text(message), backgroundColor: Colors.red),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _parseBoolParam(String? value) {
|
bool _parseBoolParam(String? value) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:qr_flutter/qr_flutter.dart';
|
import 'package:qr_flutter/qr_flutter.dart';
|
||||||
@@ -18,6 +19,7 @@ import '../domain/cookie_session_policy.dart';
|
|||||||
import '../domain/login_link_route_policy.dart';
|
import '../domain/login_link_route_policy.dart';
|
||||||
import '../../profile/domain/notifiers/profile_notifier.dart';
|
import '../../profile/domain/notifiers/profile_notifier.dart';
|
||||||
import '../../../core/services/web_window.dart';
|
import '../../../core/services/web_window.dart';
|
||||||
|
import '../../../core/ui/toast_service.dart';
|
||||||
|
|
||||||
class LoginScreen extends ConsumerStatefulWidget {
|
class LoginScreen extends ConsumerStatefulWidget {
|
||||||
final String? verificationToken;
|
final String? verificationToken;
|
||||||
@@ -42,8 +44,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
final TextEditingController _passwordLoginIdController =
|
final TextEditingController _passwordLoginIdController =
|
||||||
TextEditingController();
|
TextEditingController();
|
||||||
final TextEditingController _passwordController = TextEditingController();
|
final TextEditingController _passwordController = TextEditingController();
|
||||||
|
final FocusNode _passwordFocusNode = FocusNode();
|
||||||
String? _redirectUrl;
|
String? _redirectUrl;
|
||||||
String? _loginChallenge;
|
String? _loginChallenge;
|
||||||
|
bool _isPasswordCapsLockOn = false;
|
||||||
|
|
||||||
// QR Login Variables
|
// QR Login Variables
|
||||||
String? _qrImageBase64;
|
String? _qrImageBase64;
|
||||||
@@ -92,6 +96,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
_parseBoolParam(Uri.base.queryParameters['drySend']) &&
|
_parseBoolParam(Uri.base.queryParameters['drySend']) &&
|
||||||
!AuthProxyService.isProdEnv;
|
!AuthProxyService.isProdEnv;
|
||||||
_redirectUrl = widget.redirectUrl;
|
_redirectUrl = widget.redirectUrl;
|
||||||
|
_passwordFocusNode.addListener(_handlePasswordFocusChange);
|
||||||
|
HardwareKeyboard.instance.addHandler(_handleHardwareKeyEvent);
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
final uri = Uri.base;
|
final uri = Uri.base;
|
||||||
@@ -153,6 +159,40 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _handlePasswordFocusChange() {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_passwordFocusNode.hasFocus) {
|
||||||
|
_syncPasswordCapsLockState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_isPasswordCapsLockOn) {
|
||||||
|
setState(() {
|
||||||
|
_isPasswordCapsLockOn = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _handleHardwareKeyEvent(KeyEvent event) {
|
||||||
|
if (_passwordFocusNode.hasFocus) {
|
||||||
|
_syncPasswordCapsLockState();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncPasswordCapsLockState() {
|
||||||
|
final isEnabled = HardwareKeyboard.instance.lockModesEnabled.contains(
|
||||||
|
KeyboardLockMode.capsLock,
|
||||||
|
);
|
||||||
|
if (!mounted || isEnabled == _isPasswordCapsLockOn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_isPasswordCapsLockOn = isEnabled;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _tryCookieSession({bool silent = true}) async {
|
Future<void> _tryCookieSession({bool silent = true}) async {
|
||||||
final loginChallenge = _loginChallenge;
|
final loginChallenge = _loginChallenge;
|
||||||
final token = AuthTokenStore.getToken();
|
final token = AuthTokenStore.getToken();
|
||||||
@@ -935,6 +975,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
_linkIdController.dispose();
|
_linkIdController.dispose();
|
||||||
_passwordLoginIdController.dispose();
|
_passwordLoginIdController.dispose();
|
||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
|
_passwordFocusNode
|
||||||
|
..removeListener(_handlePasswordFocusChange)
|
||||||
|
..dispose();
|
||||||
|
HardwareKeyboard.instance.removeHandler(_handleHardwareKeyEvent);
|
||||||
_shortCodePrefixController.dispose();
|
_shortCodePrefixController.dispose();
|
||||||
_shortCodeDigitsController.dispose();
|
_shortCodeDigitsController.dispose();
|
||||||
_linkResendTimer?.cancel();
|
_linkResendTimer?.cancel();
|
||||||
@@ -1153,9 +1197,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
|
|
||||||
void _showError(String message) {
|
void _showError(String message) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ToastService.error(message);
|
||||||
SnackBar(content: Text(message), backgroundColor: Colors.red),
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
AuthProxyService.logError(message);
|
AuthProxyService.logError(message);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1165,9 +1207,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
|
|
||||||
void _showInfo(String message) {
|
void _showInfo(String message) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ToastService.success(message);
|
||||||
SnackBar(content: Text(message), backgroundColor: Colors.green),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _logTokenDetails(String jwt) {
|
void _logTokenDetails(String jwt) {
|
||||||
@@ -1302,6 +1342,24 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _capsLockWarningText(BuildContext context) {
|
||||||
|
const key = 'msg.userfront.login.password.caps_lock_on';
|
||||||
|
final languageCode = Localizations.localeOf(context).languageCode;
|
||||||
|
if (languageCode == 'ko') {
|
||||||
|
final translated = tr(key);
|
||||||
|
if (translated != key) {
|
||||||
|
return translated;
|
||||||
|
}
|
||||||
|
return 'Caps Lock이 켜져 있습니다.';
|
||||||
|
}
|
||||||
|
|
||||||
|
final translated = tr(key, fallback: 'Caps Lock is on.');
|
||||||
|
if (translated != key) {
|
||||||
|
return translated;
|
||||||
|
}
|
||||||
|
return 'Caps Lock is on.';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_verificationOnly && _verificationApproved) {
|
if (_verificationOnly && _verificationApproved) {
|
||||||
@@ -1413,6 +1471,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
key: const ValueKey(
|
key: const ValueKey(
|
||||||
'password_login_password_input',
|
'password_login_password_input',
|
||||||
),
|
),
|
||||||
|
focusNode: _passwordFocusNode,
|
||||||
controller: _passwordController,
|
controller: _passwordController,
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
@@ -1426,6 +1485,29 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
),
|
),
|
||||||
onSubmitted: (_) => _handlePasswordLogin(),
|
onSubmitted: (_) => _handlePasswordLogin(),
|
||||||
),
|
),
|
||||||
|
if (_isPasswordCapsLockOn) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.keyboard_capslock_rounded,
|
||||||
|
size: 18,
|
||||||
|
color: Colors.orange,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_capsLockWarningText(context),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.orange,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
key: const ValueKey(
|
key: const ValueKey(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:userfront/i18n.dart';
|
import 'package:userfront/i18n.dart';
|
||||||
|
import 'package:userfront/core/ui/toast_service.dart';
|
||||||
|
|
||||||
import 'qr_scan_route.dart';
|
import 'qr_scan_route.dart';
|
||||||
|
|
||||||
@@ -23,15 +24,8 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
|||||||
void _submit() {
|
void _submit() {
|
||||||
final raw = _controller.text.trim();
|
final raw = _controller.text.trim();
|
||||||
if (raw.isEmpty) {
|
if (raw.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ToastService.info(
|
||||||
SnackBar(
|
tr('msg.userfront.qr.permission_required', fallback: '카메라 권한이 필요합니다.'),
|
||||||
content: Text(
|
|
||||||
tr(
|
|
||||||
'msg.userfront.qr.permission_required',
|
|
||||||
fallback: '카메라 권한이 필요합니다.',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../core/i18n/locale_utils.dart';
|
import '../../../core/i18n/locale_utils.dart';
|
||||||
import '../../../core/services/auth_proxy_service.dart';
|
import '../../../core/services/auth_proxy_service.dart';
|
||||||
|
import '../../../core/ui/toast_service.dart';
|
||||||
import 'package:userfront/i18n.dart';
|
import 'package:userfront/i18n.dart';
|
||||||
|
|
||||||
class ResetPasswordScreen extends StatefulWidget {
|
class ResetPasswordScreen extends StatefulWidget {
|
||||||
@@ -84,12 +85,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ToastService.success(tr('msg.userfront.reset.success'));
|
||||||
SnackBar(
|
|
||||||
content: Text(tr('msg.userfront.reset.success')),
|
|
||||||
backgroundColor: Colors.green,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
context.go(buildLocalizedSigninPath(Uri.base));
|
context.go(buildLocalizedSigninPath(Uri.base));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -109,9 +105,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _showError(String message) {
|
void _showError(String message) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ToastService.error(message);
|
||||||
SnackBar(content: Text(message), backgroundColor: Colors.red),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String _buildPolicyDescription() {
|
String _buildPolicyDescription() {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import '../../profile/data/models/user_profile_model.dart';
|
||||||
|
|
||||||
|
DateTime? resolveDashboardSessionIssuedAt({
|
||||||
|
String? token,
|
||||||
|
UserProfile? profile,
|
||||||
|
}) {
|
||||||
|
final tokenIssuedAt = _getJwtIssuedAt(token);
|
||||||
|
if (tokenIssuedAt != null) {
|
||||||
|
return tokenIssuedAt;
|
||||||
|
}
|
||||||
|
return _parseSessionAuthenticatedAt(profile?.sessionAuthenticatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? _getJwtIssuedAt(String? token) {
|
||||||
|
if (token == null || token.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final parts = token.split('.');
|
||||||
|
if (parts.length != 3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final payload = utf8.decode(
|
||||||
|
base64Url.decode(base64Url.normalize(parts[1])),
|
||||||
|
);
|
||||||
|
final data = json.decode(payload) as Map<String, dynamic>;
|
||||||
|
final iatValue = data['iat'] ?? data['auth_time'];
|
||||||
|
if (iatValue is num) {
|
||||||
|
return DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
iatValue.toInt() * 1000,
|
||||||
|
).toLocal();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? _parseSessionAuthenticatedAt(String? value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return DateTime.parse(value).toLocal();
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
|
import '../domain/session_time_resolver.dart';
|
||||||
import '../domain/providers/linked_rps_provider.dart';
|
import '../domain/providers/linked_rps_provider.dart';
|
||||||
import '../../../../core/notifiers/auth_notifier.dart';
|
import '../../../../core/notifiers/auth_notifier.dart';
|
||||||
import '../../../../core/services/auth_proxy_service.dart';
|
import '../../../../core/services/auth_proxy_service.dart';
|
||||||
@@ -15,6 +16,7 @@ import '../../../../core/services/http_client.dart';
|
|||||||
import '../../../../core/i18n/locale_utils.dart';
|
import '../../../../core/i18n/locale_utils.dart';
|
||||||
import '../../../../core/widgets/language_selector.dart';
|
import '../../../../core/widgets/language_selector.dart';
|
||||||
import '../../../../core/ui/layout_breakpoints.dart';
|
import '../../../../core/ui/layout_breakpoints.dart';
|
||||||
|
import '../../../../core/ui/toast_service.dart';
|
||||||
import '../../profile/domain/notifiers/profile_notifier.dart';
|
import '../../profile/domain/notifiers/profile_notifier.dart';
|
||||||
import '../domain/dashboard_providers.dart';
|
import '../domain/dashboard_providers.dart';
|
||||||
import '../domain/models.dart' hide LinkedRp;
|
import '../domain/models.dart' hide LinkedRp;
|
||||||
@@ -104,15 +106,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
try {
|
try {
|
||||||
await ref.read(linkedRpsProvider.notifier).revokeRp(clientId);
|
await ref.read(linkedRpsProvider.notifier).revokeRp(clientId);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ToastService.success(
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.dashboard.revoke.success',
|
'msg.userfront.dashboard.revoke.success',
|
||||||
params: {'app': appName},
|
params: {'app': appName},
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
setState(() {
|
setState(() {
|
||||||
_revokedClientIds.add(clientId);
|
_revokedClientIds.add(clientId);
|
||||||
@@ -121,15 +119,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ToastService.error(
|
||||||
SnackBar(
|
tr('msg.userfront.dashboard.revoke.error', params: {'error': '$e'}),
|
||||||
content: Text(
|
|
||||||
tr(
|
|
||||||
'msg.userfront.dashboard.revoke.error',
|
|
||||||
params: {'error': '$e'},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -414,32 +405,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DateTime? _getJwtIssuedAt() {
|
|
||||||
final token = AuthTokenStore.getToken();
|
|
||||||
if (token == null || token.isEmpty) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
final parts = token.split('.');
|
|
||||||
if (parts.length != 3) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final payload = utf8.decode(
|
|
||||||
base64Url.decode(base64Url.normalize(parts[1])),
|
|
||||||
);
|
|
||||||
final data = json.decode(payload) as Map<String, dynamic>;
|
|
||||||
final iatValue = data['iat'] ?? data['auth_time'];
|
|
||||||
if (iatValue is num) {
|
|
||||||
return DateTime.fromMillisecondsSinceEpoch(
|
|
||||||
iatValue.toInt() * 1000,
|
|
||||||
).toLocal();
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatDateTime(DateTime dateTime) {
|
String _formatDateTime(DateTime dateTime) {
|
||||||
final yyyy = dateTime.year.toString().padLeft(4, '0');
|
final yyyy = dateTime.year.toString().padLeft(4, '0');
|
||||||
final mm = dateTime.month.toString().padLeft(2, '0');
|
final mm = dateTime.month.toString().padLeft(2, '0');
|
||||||
@@ -547,12 +512,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
: () async {
|
: () async {
|
||||||
await Clipboard.setData(ClipboardData(text: approvedSessionId));
|
await Clipboard.setData(ClipboardData(text: approvedSessionId));
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ToastService.info(
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
tr('msg.userfront.dashboard.session_id_copied'),
|
tr('msg.userfront.dashboard.session_id_copied'),
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -626,12 +587,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
: () async {
|
: () async {
|
||||||
await Clipboard.setData(ClipboardData(text: approvedSessionId));
|
await Clipboard.setData(ClipboardData(text: approvedSessionId));
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ToastService.info(
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
tr('msg.userfront.dashboard.session_id_copied'),
|
tr('msg.userfront.dashboard.session_id_copied'),
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -730,11 +687,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
profile?.email ??
|
profile?.email ??
|
||||||
profile?.phone ??
|
profile?.phone ??
|
||||||
tr('ui.userfront.profile.user_fallback', fallback: 'User');
|
tr('ui.userfront.profile.user_fallback', fallback: 'User');
|
||||||
final departmentValue = profile?.department ?? '';
|
final departmentValue =
|
||||||
|
profile?.tenant?.name ?? profile?.department ?? '';
|
||||||
final department = departmentValue.isNotEmpty
|
final department = departmentValue.isNotEmpty
|
||||||
? departmentValue
|
? departmentValue
|
||||||
: tr('ui.userfront.profile.department_empty');
|
: tr('ui.userfront.profile.department_empty');
|
||||||
final sessionIssuedAt = _getJwtIssuedAt();
|
final sessionIssuedAt = resolveDashboardSessionIssuedAt(
|
||||||
|
token: AuthTokenStore.getToken(),
|
||||||
|
profile: profile,
|
||||||
|
);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: _subtle,
|
backgroundColor: _subtle,
|
||||||
@@ -1280,7 +1241,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
cursor: SystemMouseCursors.click,
|
cursor: SystemMouseCursors.click,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
|
||||||
final itemUrl = item.url;
|
final itemUrl = item.url;
|
||||||
if (itemUrl != null && itemUrl.isNotEmpty) {
|
if (itemUrl != null && itemUrl.isNotEmpty) {
|
||||||
final uri = Uri.parse(itemUrl);
|
final uri = Uri.parse(itemUrl);
|
||||||
@@ -1290,18 +1250,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
await launchUrl(uri);
|
await launchUrl(uri);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
messenger.showSnackBar(
|
ToastService.error(tr('msg.userfront.dashboard.link_open_error'));
|
||||||
SnackBar(
|
|
||||||
content: Text(tr('msg.userfront.dashboard.link_open_error')),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
messenger.showSnackBar(
|
ToastService.info(tr('msg.userfront.dashboard.link_missing'));
|
||||||
SnackBar(
|
|
||||||
content: Text(tr('msg.userfront.dashboard.link_missing')),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: opaqueCard,
|
child: opaqueCard,
|
||||||
@@ -1489,9 +1441,12 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
double _historySessionColumnWidth(double maxWidth) {
|
double _historySessionColumnWidth(double maxWidth) {
|
||||||
return math.max(
|
return math.min(
|
||||||
|
200.0,
|
||||||
|
math.max(
|
||||||
_historySessionMinWidth,
|
_historySessionMinWidth,
|
||||||
maxWidth - _historyOtherColumnsBaselineWidth,
|
maxWidth - _historyOtherColumnsBaselineWidth,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class UserProfile {
|
|||||||
final String department;
|
final String department;
|
||||||
final String affiliationType;
|
final String affiliationType;
|
||||||
final String companyCode;
|
final String companyCode;
|
||||||
|
final String? sessionAuthenticatedAt;
|
||||||
final Map<String, dynamic>? metadata;
|
final Map<String, dynamic>? metadata;
|
||||||
final Tenant? tenant;
|
final Tenant? tenant;
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ class UserProfile {
|
|||||||
required this.department,
|
required this.department,
|
||||||
required this.affiliationType,
|
required this.affiliationType,
|
||||||
required this.companyCode,
|
required this.companyCode,
|
||||||
|
this.sessionAuthenticatedAt,
|
||||||
this.metadata,
|
this.metadata,
|
||||||
this.tenant,
|
this.tenant,
|
||||||
});
|
});
|
||||||
@@ -57,6 +59,7 @@ class UserProfile {
|
|||||||
department: json['department'] ?? '',
|
department: json['department'] ?? '',
|
||||||
affiliationType: json['affiliationType'] ?? '',
|
affiliationType: json['affiliationType'] ?? '',
|
||||||
companyCode: json['companyCode'] ?? '',
|
companyCode: json['companyCode'] ?? '',
|
||||||
|
sessionAuthenticatedAt: json['sessionAuthenticatedAt'] as String?,
|
||||||
metadata: json['metadata'] != null
|
metadata: json['metadata'] != null
|
||||||
? Map<String, dynamic>.from(json['metadata'])
|
? Map<String, dynamic>.from(json['metadata'])
|
||||||
: null,
|
: null,
|
||||||
@@ -73,6 +76,7 @@ class UserProfile {
|
|||||||
'department': department,
|
'department': department,
|
||||||
'affiliationType': affiliationType,
|
'affiliationType': affiliationType,
|
||||||
'companyCode': companyCode,
|
'companyCode': companyCode,
|
||||||
|
'sessionAuthenticatedAt': sessionAuthenticatedAt,
|
||||||
'metadata': metadata,
|
'metadata': metadata,
|
||||||
'tenant': tenant?.toJson(),
|
'tenant': tenant?.toJson(),
|
||||||
};
|
};
|
||||||
@@ -87,6 +91,7 @@ class UserProfile {
|
|||||||
department: department ?? this.department,
|
department: department ?? this.department,
|
||||||
affiliationType: affiliationType,
|
affiliationType: affiliationType,
|
||||||
companyCode: companyCode,
|
companyCode: companyCode,
|
||||||
|
sessionAuthenticatedAt: sessionAuthenticatedAt,
|
||||||
tenant: tenant,
|
tenant: tenant,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../../../../core/notifiers/auth_notifier.dart';
|
|||||||
import '../../../../core/i18n/locale_utils.dart';
|
import '../../../../core/i18n/locale_utils.dart';
|
||||||
import '../../../../core/services/auth_token_store.dart';
|
import '../../../../core/services/auth_token_store.dart';
|
||||||
import '../../../../core/ui/layout_breakpoints.dart';
|
import '../../../../core/ui/layout_breakpoints.dart';
|
||||||
|
import '../../../../core/ui/toast_service.dart';
|
||||||
import '../../../../core/widgets/language_selector.dart';
|
import '../../../../core/widgets/language_selector.dart';
|
||||||
import '../../data/models/user_profile_model.dart';
|
import '../../data/models/user_profile_model.dart';
|
||||||
import '../../domain/notifiers/profile_notifier.dart';
|
import '../../domain/notifiers/profile_notifier.dart';
|
||||||
@@ -38,12 +39,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
final FocusNode _departmentFocus = FocusNode();
|
final FocusNode _departmentFocus = FocusNode();
|
||||||
final FocusNode _phoneFocus = FocusNode();
|
final FocusNode _phoneFocus = FocusNode();
|
||||||
final FocusNode _phoneCodeFocus = FocusNode();
|
final FocusNode _phoneCodeFocus = FocusNode();
|
||||||
bool _nameTouched = false;
|
|
||||||
bool _departmentTouched = false;
|
|
||||||
bool _phoneTouched = false;
|
|
||||||
bool _phoneCodeTouched = false;
|
|
||||||
bool _isSavingField = false;
|
bool _isSavingField = false;
|
||||||
String? _skipAutoSaveField;
|
String? _fieldSaveError;
|
||||||
|
|
||||||
String _initialPhone = '';
|
String _initialPhone = '';
|
||||||
bool _isPhoneChanged = false;
|
bool _isPhoneChanged = false;
|
||||||
@@ -61,10 +58,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_nameFocus.addListener(_onNameFocusChange);
|
|
||||||
_departmentFocus.addListener(_onDepartmentFocusChange);
|
|
||||||
_phoneFocus.addListener(_onPhoneFocusChange);
|
|
||||||
_phoneCodeFocus.addListener(_onPhoneCodeFocusChange);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _debugLog(
|
void _debugLog(
|
||||||
@@ -83,63 +76,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
_log.fine(parts.join(' '));
|
_log.fine(parts.join(' '));
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onNameFocusChange() {
|
|
||||||
if (!mounted) return;
|
|
||||||
if (!_nameFocus.hasFocus && _nameTouched) {
|
|
||||||
Future.microtask(() {
|
|
||||||
if (!mounted) return;
|
|
||||||
final profile = ref.read(profileProvider).value ?? _cachedProfile;
|
|
||||||
if (profile != null) _autoSaveIfEditing(profile, 'name');
|
|
||||||
});
|
|
||||||
} else if (_nameFocus.hasFocus) {
|
|
||||||
_nameTouched = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onDepartmentFocusChange() {
|
|
||||||
if (!mounted) return;
|
|
||||||
_debugLog(
|
|
||||||
'department_focus_change',
|
|
||||||
field: 'department',
|
|
||||||
hasFocus: _departmentFocus.hasFocus,
|
|
||||||
);
|
|
||||||
if (!_departmentFocus.hasFocus && _departmentTouched) {
|
|
||||||
Future.microtask(() {
|
|
||||||
if (!mounted) return;
|
|
||||||
final profile = ref.read(profileProvider).value ?? _cachedProfile;
|
|
||||||
if (profile != null) _autoSaveIfEditing(profile, 'department');
|
|
||||||
});
|
|
||||||
} else if (_departmentFocus.hasFocus) {
|
|
||||||
_departmentTouched = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onPhoneFocusChange() {
|
|
||||||
if (!mounted) return;
|
|
||||||
if (!_phoneFocus.hasFocus && _phoneTouched) {
|
|
||||||
Future.microtask(() {
|
|
||||||
if (!mounted) return;
|
|
||||||
final profile = ref.read(profileProvider).value ?? _cachedProfile;
|
|
||||||
if (profile != null) _handlePhoneFocusChange(profile);
|
|
||||||
});
|
|
||||||
} else if (_phoneFocus.hasFocus) {
|
|
||||||
_phoneTouched = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onPhoneCodeFocusChange() {
|
|
||||||
if (!mounted) return;
|
|
||||||
if (!_phoneCodeFocus.hasFocus && _phoneCodeTouched) {
|
|
||||||
Future.microtask(() {
|
|
||||||
if (!mounted) return;
|
|
||||||
final profile = ref.read(profileProvider).value ?? _cachedProfile;
|
|
||||||
if (profile != null) _handlePhoneFocusChange(profile);
|
|
||||||
});
|
|
||||||
} else if (_phoneCodeFocus.hasFocus) {
|
|
||||||
_phoneCodeTouched = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_nameController?.dispose();
|
_nameController?.dispose();
|
||||||
@@ -210,14 +146,13 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
_isCodeSent = false;
|
_isCodeSent = false;
|
||||||
_isVerifying = false;
|
_isVerifying = false;
|
||||||
_codeController?.clear();
|
_codeController?.clear();
|
||||||
_phoneTouched = false;
|
|
||||||
_phoneCodeTouched = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _startEditing(String field, UserProfile profile) {
|
void _startEditing(String field, UserProfile profile) {
|
||||||
_debugLog('start_editing', field: field);
|
_debugLog('start_editing', field: field);
|
||||||
setState(() {
|
setState(() {
|
||||||
_editingField = field;
|
_editingField = field;
|
||||||
|
_fieldSaveError = null;
|
||||||
if (field == 'name') {
|
if (field == 'name') {
|
||||||
_nameController?.text = profile.name;
|
_nameController?.text = profile.name;
|
||||||
} else if (field == 'department') {
|
} else if (field == 'department') {
|
||||||
@@ -252,8 +187,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
_resetPhoneState();
|
_resetPhoneState();
|
||||||
}
|
}
|
||||||
_editingField = null;
|
_editingField = null;
|
||||||
_nameTouched = false;
|
_fieldSaveError = null;
|
||||||
_departmentTouched = false;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,22 +203,16 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
_isVerifying = false;
|
_isVerifying = false;
|
||||||
});
|
});
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ToastService.info(tr('msg.userfront.profile.phone.code_sent'));
|
||||||
SnackBar(content: Text(tr('msg.userfront.profile.phone.code_sent'))),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _isVerifying = false);
|
setState(() => _isVerifying = false);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ToastService.error(
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.profile.phone.send_failed',
|
'msg.userfront.profile.phone.send_failed',
|
||||||
params: {'error': e.toString()},
|
params: {'error': e.toString()},
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,25 +231,16 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
_isVerifying = false;
|
_isVerifying = false;
|
||||||
});
|
});
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ToastService.success(tr('msg.userfront.profile.phone.verified'));
|
||||||
SnackBar(content: Text(tr('msg.userfront.profile.phone.verified'))),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (_editingField == 'phone') {
|
|
||||||
await _saveField(profile);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _isVerifying = false);
|
setState(() => _isVerifying = false);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ToastService.error(
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.profile.phone.verify_failed',
|
'msg.userfront.profile.phone.verify_failed',
|
||||||
params: {'error': e.toString()},
|
params: {'error': e.toString()},
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -372,8 +291,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
_newPasswordController?.clear();
|
_newPasswordController?.clear();
|
||||||
_confirmPasswordController?.clear();
|
_confirmPasswordController?.clear();
|
||||||
setState(() {
|
setState(() {
|
||||||
_passwordSuccess = tr('msg.userfront.profile.password.changed');
|
_passwordSuccess = null;
|
||||||
});
|
});
|
||||||
|
ToastService.success(tr('msg.userfront.profile.password.changed'));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
final message = e.toString().replaceFirst('Exception: ', '');
|
final message = e.toString().replaceFirst('Exception: ', '');
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -382,6 +302,12 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
params: {'error': message},
|
params: {'error': message},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
ToastService.error(
|
||||||
|
tr(
|
||||||
|
'msg.userfront.profile.password.change_failed',
|
||||||
|
params: {'error': message},
|
||||||
|
),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _isPasswordSaving = false);
|
setState(() => _isPasswordSaving = false);
|
||||||
@@ -389,64 +315,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _autoSaveIfEditing(UserProfile profile, String field) {
|
|
||||||
if (_editingField != field) return;
|
|
||||||
if (_skipAutoSaveField == field) {
|
|
||||||
_debugLog('autosave_skip', field: field, reason: 'skip_flag');
|
|
||||||
_skipAutoSaveField = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (_isVerifying) {
|
|
||||||
_debugLog('autosave_skip', field: field, reason: 'verifying');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (_isSavingField) {
|
|
||||||
_debugLog('autosave_skip', field: field, reason: 'saving_in_flight');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!_hasFieldChanged(profile, field)) {
|
|
||||||
_debugLog(
|
|
||||||
'autosave_skip',
|
|
||||||
field: field,
|
|
||||||
reason: 'unchanged',
|
|
||||||
changed: false,
|
|
||||||
);
|
|
||||||
setState(() {
|
|
||||||
if (field == 'phone') {
|
|
||||||
_resetPhoneState();
|
|
||||||
}
|
|
||||||
_editingField = null;
|
|
||||||
if (field == 'name') {
|
|
||||||
_nameTouched = false;
|
|
||||||
} else if (field == 'department') {
|
|
||||||
_departmentTouched = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_debugLog('autosave_trigger', field: field, changed: true);
|
|
||||||
_saveField(profile);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handlePhoneFocusChange(UserProfile profile) {
|
|
||||||
if (_editingField != 'phone') return;
|
|
||||||
if (_skipAutoSaveField == 'phone') {
|
|
||||||
_skipAutoSaveField = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (_isVerifying) return;
|
|
||||||
if (_isSavingField) return;
|
|
||||||
if (_phoneFocus.hasFocus || _phoneCodeFocus.hasFocus) return;
|
|
||||||
if (!_hasFieldChanged(profile, 'phone')) {
|
|
||||||
setState(() {
|
|
||||||
_resetPhoneState();
|
|
||||||
_editingField = null;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_saveField(profile);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _hasFieldChanged(UserProfile profile, String field) {
|
bool _hasFieldChanged(UserProfile profile, String field) {
|
||||||
if (field == 'name') {
|
if (field == 'name') {
|
||||||
return (_nameController?.text.trim() ?? '') != profile.name;
|
return (_nameController?.text.trim() ?? '') != profile.name;
|
||||||
@@ -466,6 +334,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
_debugLog('save_skip', reason: 'saving_in_flight');
|
_debugLog('save_skip', reason: 'saving_in_flight');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_fieldSaveError = null;
|
||||||
|
});
|
||||||
|
|
||||||
final currentField = _editingField!;
|
final currentField = _editingField!;
|
||||||
|
|
||||||
final nextName = currentField == 'name'
|
final nextName = currentField == 'name'
|
||||||
@@ -482,26 +355,24 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
|
|
||||||
if (currentField == 'name' && nextName.isEmpty) {
|
if (currentField == 'name' && nextName.isEmpty) {
|
||||||
_debugLog('save_skip', field: currentField, reason: 'empty_name');
|
_debugLog('save_skip', field: currentField, reason: 'empty_name');
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
setState(() {
|
||||||
SnackBar(content: Text(tr('msg.userfront.profile.name_required'))),
|
_fieldSaveError = tr('msg.userfront.profile.name_required');
|
||||||
);
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (currentField == 'department' && nextDepartment.isEmpty) {
|
if (currentField == 'department' && nextDepartment.isEmpty) {
|
||||||
_debugLog('save_skip', field: currentField, reason: 'empty_department');
|
_debugLog('save_skip', field: currentField, reason: 'empty_department');
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
setState(() {
|
||||||
SnackBar(
|
_fieldSaveError = tr('msg.userfront.profile.department_required');
|
||||||
content: Text(tr('msg.userfront.profile.department_required')),
|
});
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (currentField == 'phone') {
|
if (currentField == 'phone') {
|
||||||
if (nextPhone.isEmpty) {
|
if (nextPhone.isEmpty) {
|
||||||
_debugLog('save_skip', field: currentField, reason: 'empty_phone');
|
_debugLog('save_skip', field: currentField, reason: 'empty_phone');
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
setState(() {
|
||||||
SnackBar(content: Text(tr('msg.userfront.profile.phone_required'))),
|
_fieldSaveError = tr('msg.userfront.profile.phone_required');
|
||||||
);
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_isPhoneChanged && !_isPhoneVerified) {
|
if (_isPhoneChanged && !_isPhoneVerified) {
|
||||||
@@ -510,11 +381,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
field: currentField,
|
field: currentField,
|
||||||
reason: 'phone_not_verified',
|
reason: 'phone_not_verified',
|
||||||
);
|
);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
setState(() {
|
||||||
SnackBar(
|
_fieldSaveError = tr('msg.userfront.profile.phone_verify_required');
|
||||||
content: Text(tr('msg.userfront.profile.phone_verify_required')),
|
});
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -531,13 +400,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
_resetPhoneState();
|
_resetPhoneState();
|
||||||
}
|
}
|
||||||
_editingField = null;
|
_editingField = null;
|
||||||
_nameTouched = false;
|
|
||||||
_departmentTouched = false;
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
_isSavingField = true;
|
_isSavingField = true;
|
||||||
|
});
|
||||||
|
|
||||||
_debugLog('save_dispatch', field: currentField, changed: true);
|
_debugLog('save_dispatch', field: currentField, changed: true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -555,30 +425,26 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
_resetPhoneState();
|
_resetPhoneState();
|
||||||
}
|
}
|
||||||
_editingField = null;
|
_editingField = null;
|
||||||
_nameTouched = false;
|
|
||||||
_departmentTouched = false;
|
|
||||||
});
|
});
|
||||||
_debugLog('save_success', field: currentField);
|
_debugLog('save_success', field: currentField);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ToastService.success(tr('msg.userfront.profile.update_success'));
|
||||||
SnackBar(content: Text(tr('msg.userfront.profile.update_success'))),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_debugLog('save_failed', field: currentField, reason: e.toString());
|
_debugLog('save_failed', field: currentField, reason: e.toString());
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
setState(() {
|
||||||
SnackBar(
|
_fieldSaveError = tr(
|
||||||
content: Text(
|
|
||||||
tr(
|
|
||||||
'msg.userfront.profile.update_failed',
|
'msg.userfront.profile.update_failed',
|
||||||
params: {'error': e.toString()},
|
params: {'error': e.toString().replaceFirst('Exception: ', '')},
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
_isSavingField = false;
|
_isSavingField = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -793,13 +659,15 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final hasChanged = _hasFieldChanged(profile, field);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
|
Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
@@ -807,23 +675,40 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
controller: controller,
|
controller: controller,
|
||||||
focusNode: field == 'name' ? _nameFocus : _departmentFocus,
|
focusNode: field == 'name' ? _nameFocus : _departmentFocus,
|
||||||
textInputAction: TextInputAction.done,
|
textInputAction: TextInputAction.done,
|
||||||
onSubmitted: (_) => _autoSaveIfEditing(profile, field),
|
onSubmitted: (_) => _saveField(profile),
|
||||||
|
onChanged: (_) {
|
||||||
|
setState(() {
|
||||||
|
_fieldSaveError = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
hintText: label,
|
hintText: label,
|
||||||
|
errorText: _fieldSaveError,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Listener(
|
ElevatedButton(
|
||||||
onPointerDown: (_) {
|
key: Key('profile-$field-save-button'),
|
||||||
_skipAutoSaveField = field;
|
onPressed: isUpdating || !hasChanged || _isSavingField
|
||||||
},
|
? null
|
||||||
child: OutlinedButton(
|
: () => _saveField(profile),
|
||||||
key: Key('profile-$field-cancel-button'),
|
child: _isSavingField
|
||||||
onPressed: isUpdating ? null : () => _cancelEditing(profile),
|
? const SizedBox(
|
||||||
child: Text(tr('ui.common.cancel')),
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: Text(tr('ui.common.save')),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
OutlinedButton(
|
||||||
|
key: Key('profile-$field-cancel-button'),
|
||||||
|
onPressed: isUpdating || _isSavingField
|
||||||
|
? null
|
||||||
|
: () => _cancelEditing(profile),
|
||||||
|
child: Text(tr('ui.common.cancel')),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -847,6 +732,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final hasChanged = _hasFieldChanged(profile, 'phone');
|
||||||
|
final canSave = hasChanged && (!_isPhoneChanged || _isPhoneVerified);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -856,7 +744,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
@@ -864,10 +752,16 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
focusNode: _phoneFocus,
|
focusNode: _phoneFocus,
|
||||||
keyboardType: TextInputType.phone,
|
keyboardType: TextInputType.phone,
|
||||||
textInputAction: TextInputAction.done,
|
textInputAction: TextInputAction.done,
|
||||||
onSubmitted: (_) => _autoSaveIfEditing(profile, 'phone'),
|
onSubmitted: (_) => _saveField(profile),
|
||||||
|
onChanged: (_) {
|
||||||
|
setState(() {
|
||||||
|
_fieldSaveError = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
hintText: '01012345678',
|
hintText: '01012345678',
|
||||||
|
errorText: _fieldSaveError,
|
||||||
suffixIcon: _isPhoneVerified
|
suffixIcon: _isPhoneVerified
|
||||||
? const Icon(Icons.check_circle, color: Colors.green)
|
? const Icon(Icons.check_circle, color: Colors.green)
|
||||||
: null,
|
: null,
|
||||||
@@ -886,14 +780,24 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Listener(
|
ElevatedButton(
|
||||||
onPointerDown: (_) {
|
onPressed: isUpdating || !canSave || _isSavingField
|
||||||
_skipAutoSaveField = 'phone';
|
? null
|
||||||
},
|
: () => _saveField(profile),
|
||||||
child: OutlinedButton(
|
child: _isSavingField
|
||||||
onPressed: isUpdating ? null : () => _cancelEditing(profile),
|
? const SizedBox(
|
||||||
child: Text(tr('ui.common.cancel')),
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: Text(tr('ui.common.save')),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: isUpdating || _isSavingField
|
||||||
|
? null
|
||||||
|
: () => _cancelEditing(profile),
|
||||||
|
child: Text(tr('ui.common.cancel')),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import 'core/i18n/locale_gate.dart';
|
|||||||
import 'core/i18n/locale_registry.dart';
|
import 'core/i18n/locale_registry.dart';
|
||||||
import 'core/i18n/locale_utils.dart';
|
import 'core/i18n/locale_utils.dart';
|
||||||
import 'core/i18n/toml_asset_loader.dart';
|
import 'core/i18n/toml_asset_loader.dart';
|
||||||
|
import 'core/ui/toast_service.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'features/auth/presentation/consent_screen.dart';
|
import 'features/auth/presentation/consent_screen.dart';
|
||||||
import 'i18n.dart';
|
import 'i18n.dart';
|
||||||
@@ -370,6 +371,11 @@ class BaronSSOApp extends StatelessWidget {
|
|||||||
localizationsDelegates: delegates,
|
localizationsDelegates: delegates,
|
||||||
supportedLocales: supportedLocales,
|
supportedLocales: supportedLocales,
|
||||||
locale: locale,
|
locale: locale,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Stack(
|
||||||
|
children: [if (child != null) child, const ToastViewport()],
|
||||||
|
);
|
||||||
|
},
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(
|
colorScheme: ColorScheme.fromSeed(
|
||||||
seedColor: const Color(0xFF1A1F2C), // Dark Navy/Black base
|
seedColor: const Color(0xFF1A1F2C), // Dark Navy/Black base
|
||||||
|
|||||||
35
userfront/test/dashboard_session_time_resolver_test.dart
Normal file
35
userfront/test/dashboard_session_time_resolver_test.dart
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:userfront/features/dashboard/domain/session_time_resolver.dart';
|
||||||
|
import 'package:userfront/features/profile/data/models/user_profile_model.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('JWT에 iat가 있으면 세션 시각으로 사용한다', () {
|
||||||
|
const token = 'eyJhbGciOiJub25lIn0.eyJpYXQiOjE3MTEyMDc4MDB9.signature';
|
||||||
|
|
||||||
|
final issuedAt = resolveDashboardSessionIssuedAt(token: token);
|
||||||
|
|
||||||
|
expect(issuedAt, isNotNull);
|
||||||
|
expect(issuedAt!.toUtc().toIso8601String(), '2024-03-23T15:30:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cookie mode에서는 profile의 sessionAuthenticatedAt으로 복원한다', () {
|
||||||
|
final profile = UserProfile(
|
||||||
|
id: 'user-1',
|
||||||
|
email: 'qa@example.com',
|
||||||
|
name: 'QA User',
|
||||||
|
phone: '01012345678',
|
||||||
|
department: 'Platform',
|
||||||
|
affiliationType: 'GENERAL',
|
||||||
|
companyCode: '',
|
||||||
|
sessionAuthenticatedAt: '2026-03-23T15:30:00Z',
|
||||||
|
);
|
||||||
|
|
||||||
|
final issuedAt = resolveDashboardSessionIssuedAt(
|
||||||
|
token: null,
|
||||||
|
profile: profile,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(issuedAt, isNotNull);
|
||||||
|
expect(issuedAt!.toUtc().toIso8601String(), '2026-03-23T15:30:00.000Z');
|
||||||
|
});
|
||||||
|
}
|
||||||
106
userfront/test/english_locale_placeholder_test.dart
Normal file
106
userfront/test/english_locale_placeholder_test.dart
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:toml/toml.dart';
|
||||||
|
|
||||||
|
const Set<String> _placeholderValues = {
|
||||||
|
'Action',
|
||||||
|
'Action Label',
|
||||||
|
'Approve Error',
|
||||||
|
'Approve Success',
|
||||||
|
'Body',
|
||||||
|
'Code Hint',
|
||||||
|
'Code Label',
|
||||||
|
'Confirm',
|
||||||
|
'Confirm Button',
|
||||||
|
'Description',
|
||||||
|
'Error',
|
||||||
|
'Heading',
|
||||||
|
'Input Label',
|
||||||
|
'Invalid',
|
||||||
|
'Label',
|
||||||
|
'Load Failed',
|
||||||
|
'Page Title',
|
||||||
|
'Request Code',
|
||||||
|
'Result Failure',
|
||||||
|
'Sent',
|
||||||
|
'Subtitle',
|
||||||
|
'Title',
|
||||||
|
'Update Success',
|
||||||
|
};
|
||||||
|
|
||||||
|
String? _readTomlValue(Map<String, dynamic> root, String key) {
|
||||||
|
dynamic cursor = root;
|
||||||
|
for (final part in key.split('.')) {
|
||||||
|
if (cursor is! Map<String, dynamic>) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
cursor = cursor[part];
|
||||||
|
}
|
||||||
|
return cursor is String ? cursor : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('critical english copy does not expose placeholder values', () {
|
||||||
|
final file = File('assets/translations/en.toml');
|
||||||
|
final document = TomlDocument.parse(file.readAsStringSync());
|
||||||
|
final translations = document.toMap();
|
||||||
|
|
||||||
|
const criticalKeys = <String>[
|
||||||
|
'ui.userfront.forgot.heading',
|
||||||
|
'ui.userfront.forgot.input_label',
|
||||||
|
'ui.userfront.forgot.title',
|
||||||
|
'msg.userfront.forgot.description',
|
||||||
|
'msg.userfront.forgot.sent',
|
||||||
|
'ui.userfront.login.link.action_label',
|
||||||
|
'ui.userfront.login.link.page_title',
|
||||||
|
'ui.userfront.login.link.title',
|
||||||
|
'ui.userfront.login.unregistered.action',
|
||||||
|
'ui.userfront.login.unregistered.title',
|
||||||
|
'ui.userfront.login.verification.action_label',
|
||||||
|
'ui.userfront.login.verification.page_title',
|
||||||
|
'ui.userfront.login.verification.title',
|
||||||
|
'msg.userfront.login.qr.load_failed',
|
||||||
|
'msg.userfront.login.short_code.invalid',
|
||||||
|
'msg.userfront.login.unregistered.body',
|
||||||
|
'ui.userfront.login_success.title',
|
||||||
|
'msg.userfront.login_success.subtitle',
|
||||||
|
'msg.userfront.profile.load_failed',
|
||||||
|
'msg.userfront.profile.update_success',
|
||||||
|
'ui.userfront.profile.password.title',
|
||||||
|
'ui.userfront.profile.phone.code_hint',
|
||||||
|
'msg.userfront.reset.invalid_body',
|
||||||
|
'msg.userfront.reset.invalid_title',
|
||||||
|
'ui.userfront.reset.subtitle',
|
||||||
|
'ui.userfront.reset.title',
|
||||||
|
'ui.userfront.signup.title',
|
||||||
|
'msg.userfront.signup.agreement.title',
|
||||||
|
'msg.userfront.signup.auth.title',
|
||||||
|
'ui.userfront.signup.auth.email.label',
|
||||||
|
'ui.userfront.signup.auth.email.title',
|
||||||
|
'msg.userfront.signup.password.title',
|
||||||
|
'msg.userfront.signup.profile.title',
|
||||||
|
'msg.userfront.signup.success.body',
|
||||||
|
'msg.userfront.signup.success.title',
|
||||||
|
'ui.userfront.signup.success.action',
|
||||||
|
];
|
||||||
|
|
||||||
|
final failures = <String>[];
|
||||||
|
for (final key in criticalKeys) {
|
||||||
|
final value = _readTomlValue(translations, key);
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
failures.add('$key is missing');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (_placeholderValues.contains(value.trim())) {
|
||||||
|
failures.add('$key uses placeholder "$value"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(
|
||||||
|
failures,
|
||||||
|
isEmpty,
|
||||||
|
reason: failures.isEmpty ? null : failures.join('\n'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
131
userfront/test/profile_page_edit_flow_test.dart
Normal file
131
userfront/test/profile_page_edit_flow_test.dart
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:userfront/features/profile/data/models/user_profile_model.dart';
|
||||||
|
import 'package:userfront/features/profile/domain/notifiers/profile_notifier.dart';
|
||||||
|
import 'package:userfront/features/profile/presentation/pages/profile_page.dart';
|
||||||
|
|
||||||
|
// Mocking the profile notifier
|
||||||
|
class MockProfileNotifier extends ProfileNotifier {
|
||||||
|
UserProfile? _profile;
|
||||||
|
bool updateCalled = false;
|
||||||
|
String? updatedName;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<UserProfile?> build() async {
|
||||||
|
_profile = UserProfile(
|
||||||
|
id: 'test-id',
|
||||||
|
email: 'test@example.com',
|
||||||
|
name: 'Original Name',
|
||||||
|
phone: '01012345678',
|
||||||
|
department: 'Dev',
|
||||||
|
affiliationType: 'employee',
|
||||||
|
companyCode: 'C100',
|
||||||
|
);
|
||||||
|
return _profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<UserProfile?> loadProfile() async {
|
||||||
|
state = const AsyncValue.loading();
|
||||||
|
state = AsyncValue.data(_profile);
|
||||||
|
return _profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateProfile({
|
||||||
|
String? name,
|
||||||
|
String? phone,
|
||||||
|
String? department,
|
||||||
|
}) async {
|
||||||
|
updateCalled = true;
|
||||||
|
updatedName = name;
|
||||||
|
_profile = _profile!.copyWith(
|
||||||
|
name: name ?? _profile!.name,
|
||||||
|
phone: phone ?? _profile!.phone,
|
||||||
|
department: department ?? _profile!.department,
|
||||||
|
);
|
||||||
|
state = AsyncValue.data(_profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets(
|
||||||
|
'ProfilePage explicit save button UX flow (Edit -> Cancel -> Edit -> Save)',
|
||||||
|
(tester) async {
|
||||||
|
final recordedErrors = <FlutterErrorDetails>[];
|
||||||
|
final previousOnError = FlutterError.onError;
|
||||||
|
FlutterError.onError = (details) {
|
||||||
|
final text = details.exceptionAsString();
|
||||||
|
if (text.contains('A RenderFlex overflowed')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recordedErrors.add(details);
|
||||||
|
};
|
||||||
|
addTearDown(() {
|
||||||
|
FlutterError.onError = previousOnError;
|
||||||
|
});
|
||||||
|
|
||||||
|
tester.view.physicalSize = const Size(1920, 1080);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
final mockNotifier = MockProfileNotifier();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [profileProvider.overrideWith(() => mockNotifier)],
|
||||||
|
child: const MaterialApp(home: Scaffold(body: ProfilePage())),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// 1. Entering edit mode
|
||||||
|
final editButton = find.byKey(const Key('profile-name-edit-button'));
|
||||||
|
expect(editButton, findsOneWidget);
|
||||||
|
await tester.tap(editButton);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final inputField = find.byKey(const Key('profile-name-input'));
|
||||||
|
expect(inputField, findsOneWidget);
|
||||||
|
|
||||||
|
// 2. Testing cancel flow
|
||||||
|
await tester.enterText(inputField, 'Changed Name');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final cancelButton = find.byKey(const Key('profile-name-cancel-button'));
|
||||||
|
await tester.tap(cancelButton);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// After cancellation, the field should be read-only again.
|
||||||
|
expect(find.byKey(const Key('profile-name-input')), findsNothing);
|
||||||
|
// Find text could be part of ListTile
|
||||||
|
expect(find.text('Original Name'), findsWidgets);
|
||||||
|
|
||||||
|
// 3. Re-enter edit mode and explicitly save
|
||||||
|
await tester.tap(find.byKey(const Key('profile-name-edit-button')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const Key('profile-name-input')),
|
||||||
|
'Saved Name',
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final saveButton = find.byKey(const Key('profile-name-save-button'));
|
||||||
|
await tester.tap(saveButton);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.pump(const Duration(seconds: 4));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
FlutterError.onError = previousOnError;
|
||||||
|
|
||||||
|
// Verify the mock received the update
|
||||||
|
expect(mockNotifier.updateCalled, isTrue);
|
||||||
|
expect(mockNotifier.updatedName, 'Saved Name');
|
||||||
|
expect(recordedErrors, isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user