-
- {t("ui.dev.brand", "Baron 로그인")}
-
-
- {t("ui.dev.console_title", "Developer Console")}
-
+
+
+ {t("ui.dev.scope_badge", "Scoped to /dev")}
-
- {t("ui.dev.scope_badge", "Scoped to /dev")}
+
+
+
{t("msg.dev.sidebar.notice", "개발자 전용 콘솔입니다.")}
+
+ {t(
+ "msg.dev.sidebar.notice_detail",
+ "클라이언트 애플리케이션 등록 및 관리를 수행할 수 있습니다.",
+ )}
+
-
{t("msg.dev.sidebar.notice", "개발자 전용 콘솔입니다.")}
-
- {t(
- "msg.dev.sidebar.notice_detail",
- "클라이언트 애플리케이션 등록 및 관리를 수행할 수 있습니다.",
- )}
-
+
+
+
diff --git a/devfront/src/features/clients/ClientConsentsPage.tsx b/devfront/src/features/clients/ClientConsentsPage.tsx
index 1897a966..7bacc080 100644
--- a/devfront/src/features/clients/ClientConsentsPage.tsx
+++ b/devfront/src/features/clients/ClientConsentsPage.tsx
@@ -69,7 +69,7 @@ function ClientConsentsPage() {
/
- {t("ui.dev.clients.consents.breadcrumb.clients", "Clients")}
+ {t("ui.dev.clients.consents.breadcrumb.clients", "Apps")}
/
{clientData?.client?.name || clientId}
diff --git a/devfront/src/features/clients/ClientDetailsPage.tsx b/devfront/src/features/clients/ClientDetailsPage.tsx
index 8a586125..1882c04a 100644
--- a/devfront/src/features/clients/ClientDetailsPage.tsx
+++ b/devfront/src/features/clients/ClientDetailsPage.tsx
@@ -124,7 +124,7 @@ function ClientDetailsPage() {
if (isLoading) {
return (
- {t("msg.dev.clients.details.loading", "Loading client...")}
+ {t("msg.dev.clients.details.loading", "Loading app...")}
);
}
@@ -137,7 +137,7 @@ function ClientDetailsPage() {
{t(
"msg.dev.clients.details.load_error",
- "Error loading client: {{error}}",
+ "Error loading app: {{error}}",
{ error: errMsg || t("msg.common.unknown_error", "unknown error") },
)}
@@ -185,7 +185,7 @@ function ClientDetailsPage() {
- {t("ui.dev.clients.details.breadcrumb.section", "Relying Parties")}
+ {t("ui.dev.clients.details.breadcrumb.section", "Apps")}
/
diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx
index 1bcefe66..626e1b7a 100644
--- a/devfront/src/features/clients/ClientGeneralPage.tsx
+++ b/devfront/src/features/clients/ClientGeneralPage.tsx
@@ -47,7 +47,7 @@ function ClientGeneralPage() {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [logoUrl, setLogoUrl] = useState("");
- const [clientType, setClientType] = useState("confidential");
+ const [clientType, setClientType] = useState("private");
const [status, setStatus] = useState("active");
const [redirectUris, setRedirectUris] = useState("");
const [scopes, setScopes] = useState(() => [
@@ -157,6 +157,21 @@ function ClientGeneralPage() {
}
alert(t("msg.dev.clients.general.saved", "설정이 저장되었습니다."));
},
+ onError: (err) => {
+ const errorMessage =
+ (err as AxiosError<{ error?: string }>).response?.data?.error ??
+ (err as Error)?.message ??
+ t("msg.common.unknown_error", "unknown error");
+ alert(
+ t(
+ "msg.dev.clients.general.save_error",
+ "저장에 실패했습니다: {{error}}",
+ {
+ error: errorMessage,
+ },
+ ),
+ );
+ },
});
if (!isCreate && isLoading) {
@@ -376,7 +391,6 @@ function ClientGeneralPage() {
- | Scope Name |
{t(
"ui.dev.clients.general.scopes.table.name",
@@ -395,7 +409,9 @@ function ClientGeneralPage() {
"Mandatory",
)}
|
- |
+
+ {t("ui.dev.clients.general.scopes.table.delete", "Delete")}
+ |
@@ -489,7 +505,7 @@ function ClientGeneralPage() {
diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx
index d1ddffe8..7cd7ad2a 100644
--- a/devfront/src/features/clients/ClientsPage.tsx
+++ b/devfront/src/features/clients/ClientsPage.tsx
@@ -87,7 +87,10 @@ function ClientsPage() {
const clients = data?.items || [];
const totalClients = clients.length;
- // TODO: Add real stats for active sessions and auth failures
+ const activeClients = clients.filter(
+ (client) => client.status === "active",
+ ).length;
+ // TODO: Replace with real session/auth-failure metrics when backend endpoints are available.
type StatTone = "up" | "down" | "stable";
type StatItem = {
labelKey: string;
@@ -101,7 +104,7 @@ function ClientsPage() {
const stats: StatItem[] = [
{
labelKey: "ui.dev.clients.stats.total",
- labelFallback: "총 클라이언트",
+ labelFallback: "총 애플리케이션",
value: totalClients.toString(),
deltaKey: "ui.dev.clients.stats.realtime",
deltaFallback: "Realtime",
@@ -110,10 +113,10 @@ function ClientsPage() {
{
labelKey: "ui.dev.clients.stats.active_sessions",
labelFallback: "활성 세션",
- value: "-",
- deltaKey: "ui.dev.clients.stats.not_impl",
- deltaFallback: "Not impl",
- tone: "stable" as const,
+ value: activeClients.toString(),
+ deltaKey: "ui.dev.clients.stats.realtime",
+ deltaFallback: "Realtime",
+ tone: "up" as const,
},
{
labelKey: "ui.dev.clients.stats.auth_failures",
@@ -266,7 +269,7 @@ function ClientsPage() {
- {client.type === "confidential" ? (
+ {client.type === "private" ? (
) : (
@@ -309,16 +312,11 @@ function ClientsPage() {
- {client.type === "confidential"
- ? t(
- "ui.dev.clients.type.confidential",
- "기밀(Confidential)",
- )
- : t("ui.dev.clients.type.public", "Public")}
+ {client.type === "private"
+ ? t("ui.dev.clients.type.private", "Private")
+ : t("ui.dev.clients.type.pkce", "PKCE")}
diff --git a/devfront/src/lib/devApi.ts b/devfront/src/lib/devApi.ts
index 0aa35ff0..5aa29992 100644
--- a/devfront/src/lib/devApi.ts
+++ b/devfront/src/lib/devApi.ts
@@ -1,7 +1,7 @@
import apiClient from "./apiClient";
export type ClientStatus = "active" | "inactive";
-export type ClientType = "confidential" | "public";
+export type ClientType = "private" | "pkce";
export type ClientSummary = {
id: string;
diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml
index 26d7f7ae..79dbcf4f 100644
--- a/devfront/src/locales/en.toml
+++ b/devfront/src/locales/en.toml
@@ -205,14 +205,15 @@ saving = "Saving..."
unknown_error = "unknown error"
[msg.dev]
+logout_confirm = "Are you sure you want to log out?"
[msg.dev.clients]
copy_client_id = "Copy Client Id"
load_error = "Error loading clients: {{error}}"
-loading = "Loading clients..."
-showing = "Showing {{shown}} of {{total}} clients"
+loading = "Loading apps..."
+showing = "Showing {{shown}} of {{total}} apps"
status_update_error = "Failed to update client status"
-status_updated = "Status Updated"
+status_updated = "The app has been {{status}}."
[msg.dev.clients.consents]
empty = "No consents found."
@@ -260,9 +261,9 @@ empty = "Empty"
subtitle = "Subtitle"
[msg.dev.clients.general.security]
-confidential_help = "Confidential Help"
-public_help = "Public Help"
-subtitle = "Subtitle"
+private_help = "Private App (Server-side): For apps that can safely store a client secret, such as Node.js or Java servers."
+pkce_help = "PKCE App (SPA/Mobile): For apps that cannot safely store a client secret. PKCE is mandatory."
+subtitle = "Select application type. Security level determines authentication method."
[msg.dev.clients.help]
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."
@@ -927,10 +928,14 @@ console_title = "Developer Console"
env_badge = "Env: dev"
scope_badge = "Scoped to /dev"
+[ui.dev.nav]
+clients = "Connected Application"
+logout = "Logout"
+
[ui.dev.clients]
copy_client_id = "Copy client id"
-new = "New"
-search_placeholder = "Search Placeholder"
+new = "Add Connected Application"
+search_placeholder = "Search by app name or ID..."
tenant_scoped = "Tenant-scoped"
untitled = "Untitled"
@@ -1006,8 +1011,8 @@ consents = "Consent & Users"
settings = "Settings"
[ui.dev.clients.general]
-create = "Create"
-display_new = "Display New"
+create = "Create Application"
+display_new = "Add Connected Application"
save = "Settings Save"
title_create = "Create Client"
title_edit = "Client Settings"
@@ -1043,10 +1048,11 @@ title = "Scopes"
description = "Description"
mandatory = "Mandatory"
name = "Scope Name"
+delete = "Delete"
[ui.dev.clients.general.security]
-confidential = "Confidential"
-public = "Public"
+private = "Private"
+pkce = "PKCE"
title = "Security Settings"
[ui.dev.clients.help]
@@ -1055,7 +1061,7 @@ title = "Need help with OIDC configuration?"
view_guides = "View guides"
[ui.dev.clients.list]
-title = "Title"
+title = "Connected Applications"
[ui.dev.clients.owner]
avatar_alt = "ops user"
@@ -1079,8 +1085,8 @@ status = "Status"
type = "Type"
[ui.dev.clients.type]
-confidential = "Confidential"
-public = "Public"
+private = "Private"
+pkce = "PKCE"
[ui.dev.dashboard]
ready_badge = "devfront ready"
diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml
index 73958bf0..a8392019 100644
--- a/devfront/src/locales/ko.toml
+++ b/devfront/src/locales/ko.toml
@@ -205,14 +205,15 @@ saving = "저장 중..."
unknown_error = "unknown error"
[msg.dev]
+logout_confirm = "로그아웃 하시겠습니까?"
[msg.dev.clients]
-copy_client_id = "클라이언트 ID가 복사되었습니다."
+copy_client_id = "Client ID가 복사되었습니다."
load_error = "Error loading clients: {{error}}"
-loading = "Loading clients..."
-showing = "Showing {{shown}} of {{total}} clients"
+loading = "Loading apps..."
+showing = "Showing {{shown}} of {{total}} apps"
status_update_error = "Failed to update client status"
-status_updated = "클라이언트가 {{status}}되었습니다."
+status_updated = "앱이 {{status}}되었습니다."
[msg.dev.clients.consents]
empty = "No consents found."
@@ -257,19 +258,19 @@ help = "인증 후 리다이렉트될 URI를 입력하세요. 생성 후 Connect
[msg.dev.clients.general.scopes]
empty = "등록된 스코프가 없습니다."
-subtitle = "이 클라이언트가 요청할 수 있는 권한 범위를 정의합니다."
+subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다."
[msg.dev.clients.general.security]
-confidential_help = "서버 사이드 앱(예: Node.js, Java)처럼 비밀키를 안전하게 보관 가능한 경우."
-public_help = "SPA/모바일 앱처럼 비밀키 보관이 어려운 경우. PKCE를 기본 사용합니다."
-subtitle = "클라이언트 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다."
+private_help = "Private 앱 (서버 사이드 앱): Node.js, Java 등 비밀키를 안전하게 보관 가능한 경우 사용합니다."
+pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다."
+subtitle = "앱 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다."
[msg.dev.clients.help]
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."
subtitle = "Developer guides for Confidential/Public clients, redirect URIs, and auth methods."
[msg.dev.clients.registry]
-description = "OIDC 클라이언트, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다."
+description = "OIDC 앱, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다."
[msg.dev.clients.scopes]
email = "이메일 주소 접근"
@@ -291,7 +292,7 @@ hydra_health = "Hydra Admin 상태 체크 준비"
[msg.dev.sidebar]
notice = "개발자 전용 콘솔입니다."
-notice_detail = "클라이언트 애플리케이션 등록 및 관리를 수행할 수 있습니다."
+notice_detail = "연동 앱 등록 및 관리를 수행할 수 있습니다."
[msg.info]
saved_success = "저장이 완료되었습니다."
@@ -927,10 +928,14 @@ console_title = "Developer Console"
env_badge = "Env: dev"
scope_badge = "Scoped to /dev"
+[ui.dev.nav]
+clients = "연동 앱"
+logout = "로그아웃"
+
[ui.dev.clients]
copy_client_id = "Copy client id"
-new = "새 클라이언트"
-search_placeholder = "클라이언트 이름/ID로 검색..."
+new = "연동 앱 추가"
+search_placeholder = "연동 앱 이름/ID로 검색..."
tenant_scoped = "Tenant-scoped"
untitled = "Untitled"
@@ -973,13 +978,13 @@ user = "User"
[ui.dev.clients.details]
[ui.dev.clients.details.breadcrumb]
-current = "클라이언트 상세"
+current = "연동 앱 상세"
section = "Relying Parties"
[ui.dev.clients.details.credentials]
client_id = "Client ID"
client_secret = "Client Secret"
-title = "클라이언트 자격 증명"
+title = "앱 자격 증명"
[ui.dev.clients.details.endpoints]
read_only = "읽기 전용"
@@ -1006,8 +1011,8 @@ consents = "Consent & Users"
settings = "Settings"
[ui.dev.clients.general]
-create = "클라이언트 생성"
-display_new = "새 클라이언트"
+create = "앱 생성"
+display_new = "연동 앱 추가"
save = "설정 저장"
title_create = "Create Client"
title_edit = "Client Settings"
@@ -1043,10 +1048,11 @@ title = "Scopes"
description = "Description"
mandatory = "Mandatory"
name = "Scope Name"
+delete = "Delete"
[ui.dev.clients.general.security]
-confidential = "Confidential"
-public = "Public"
+private = "Private"
+pkce = "PKCE"
title = "보안 설정"
[ui.dev.clients.help]
@@ -1055,7 +1061,7 @@ title = "Need help with OIDC configuration?"
view_guides = "View guides"
[ui.dev.clients.list]
-title = "클라이언트 목록"
+title = "연동 앱 목록"
[ui.dev.clients.owner]
avatar_alt = "ops user"
@@ -1079,8 +1085,8 @@ status = "상태"
type = "유형"
[ui.dev.clients.type]
-confidential = "기밀(Confidential)"
-public = "Public"
+private = "Private"
+pkce = "PKCE"
[ui.dev.dashboard]
ready_badge = "devfront ready"
diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml
index d3f46c56..fb270793 100644
--- a/devfront/src/locales/template.toml
+++ b/devfront/src/locales/template.toml
@@ -205,6 +205,7 @@ saving = ""
unknown_error = ""
[msg.dev]
+logout_confirm = ""
[msg.dev.clients]
copy_client_id = ""
@@ -260,8 +261,8 @@ empty = ""
subtitle = ""
[msg.dev.clients.general.security]
-confidential_help = ""
-public_help = ""
+private_help = ""
+pkce_help = ""
subtitle = ""
[msg.dev.clients.help]
@@ -939,6 +940,10 @@ console_title = ""
env_badge = ""
scope_badge = ""
+[ui.dev.nav]
+clients = ""
+logout = ""
+
[ui.dev.clients]
copy_client_id = ""
new = ""
@@ -1055,10 +1060,11 @@ title = ""
description = ""
mandatory = ""
name = ""
+delete = ""
[ui.dev.clients.general.security]
-confidential = ""
-public = ""
+private = ""
+pkce = ""
title = ""
[ui.dev.clients.help]
@@ -1091,8 +1097,8 @@ status = ""
type = ""
[ui.dev.clients.type]
-confidential = ""
-public = ""
+private = ""
+pkce = ""
[ui.dev.dashboard]
ready_badge = ""
diff --git a/devfront/tests/clients.spec.ts b/devfront/tests/clients.spec.ts
index 249e78e0..0b2d2f09 100644
--- a/devfront/tests/clients.spec.ts
+++ b/devfront/tests/clients.spec.ts
@@ -48,7 +48,7 @@ test("clients page loads correctly", async ({ page }) => {
{
id: "client-playwright",
name: "Playwright Client",
- type: "confidential",
+ type: "private",
status: "active",
createdAt: new Date().toISOString(),
redirectUris: ["http://localhost:5174/callback"],
@@ -68,7 +68,7 @@ test("clients page loads correctly", async ({ page }) => {
await expect(page).toHaveTitle(/바론 개발자 서비스/);
// 페이지 내 주요 텍스트 확인
- await expect(page.getByText("클라이언트 목록")).toBeVisible();
+ await expect(page.getByText("연동 앱 목록")).toBeVisible();
// 테이블 헤더 확인
await expect(
diff --git a/docs/AGENTS.md b/docs/AGENTS.md
index 227b1214..650b3219 100644
--- a/docs/AGENTS.md
+++ b/docs/AGENTS.md
@@ -1,5 +1,13 @@
# AGENTS 가이드 (Baron SSO)
+## 버그 수정 절차 대원칙 (강제)
+- 버그 대응 시 **재현 테스트를 먼저 작성**합니다.
+- 재현 테스트가 실패하는 상태를 확인한 뒤에만 수정 작업을 시작합니다.
+- 수정 후에는 테스트를 반복 실행하여 재현 테스트가 안정적으로 통과할 때까지 계속 보완합니다.
+- 재현 테스트 없이 “감으로 수정”하거나, 실패 테스트를 남긴 채 성공으로 보고하지 않습니다.
+- 이슈 종료 전에는 최소 1회 이상 실제 사용자 경로(예: 로그인/새로고침/리다이렉트)를 확인합니다.
+- 테스트/원인/조치 내역은 문서(`docs/test-plan/*`, `docs/trouble-shooting/*`)에 반영합니다.
+
## 목적
- 인증/인가 허브로서 **Backend + Ory Stack** 중심 아키텍처를 유지
- 사용자 플로우(UserFront)와 관리 플로우(Admin/DevFront)를 명확히 분리
diff --git a/docs/test-plan.md b/docs/test-plan.md
index 6b1599a9..5c6ab8f9 100644
--- a/docs/test-plan.md
+++ b/docs/test-plan.md
@@ -28,6 +28,7 @@
- Backend 테스트 전수 목록: `docs/test-plan/backend-test-inventory.md`
- UserFront 테스트 전수 목록: `docs/test-plan/userfront-test-inventory.md`
- AdminFront/DevFront E2E 전수 목록: `docs/test-plan/web-e2e-test-inventory.md`
+- UserFront WASM Playwright E2E 확장 계획: `docs/test-plan/userfront-wasm-e2e-expansion-plan.md`
## 4) 실행 커맨드
- Backend 전체 테스트: `cd backend && go test ./...`
@@ -35,6 +36,7 @@
- UserFront 테스트: `cd userfront && flutter test`
- AdminFront E2E: `cd adminfront && npm test`
- DevFront E2E: `cd devfront && npm test`
+- UserFront WASM E2E(계획): `docs/test-plan/userfront-wasm-e2e-expansion-plan.md` 기준으로 Playwright 워크스페이스를 추가한 뒤 실행
## 5) 유지 원칙
- 신규 기능은 관련 테스트를 반드시 추가합니다.
diff --git a/docs/test-plan/backend-test-inventory.md b/docs/test-plan/backend-test-inventory.md
index 6daa3b97..92cdb9d0 100644
--- a/docs/test-plan/backend-test-inventory.md
+++ b/docs/test-plan/backend-test-inventory.md
@@ -31,9 +31,12 @@
| `backend/internal/handler/auth_handler_qr_test.go:107` | `TestScanQRLogin_Success` | 인증/OIDC 플로우 검증 |
| `backend/internal/handler/auth_handler_qr_test.go:150` | `TestResolveConsentSubjects_TokenAndCookie` | 인증/OIDC 플로우 검증 |
| `backend/internal/handler/auth_handler_qr_test.go:57` | `TestQRLoginFlow_Success` | 인증/OIDC 플로우 검증 |
-| `backend/internal/handler/auth_handler_test.go:20` | `TestCompletePasswordReset_MissingLoginID` | 오류/예외/거부 경로 검증 |
-| `backend/internal/handler/auth_handler_test.go:50` | `TestCompletePasswordReset_InvalidPasswordPolicy` | 오류/예외/거부 경로 검증 |
-| `backend/internal/handler/auth_handler_test.go:80` | `TestCompletePasswordReset_NilIDPProvider` | 인증/OIDC 플로우 검증 |
+| `backend/internal/handler/auth_handler_test.go:67` | `TestCompletePasswordReset_MissingLoginID` | 오류/예외/거부 경로 검증 |
+| `backend/internal/handler/auth_handler_test.go:97` | `TestCompletePasswordReset_InvalidPasswordPolicy` | 오류/예외/거부 경로 검증 |
+| `backend/internal/handler/auth_handler_test.go:127` | `TestCompletePasswordReset_NilIDPProvider` | 오류/예외/거부 경로 검증 |
+| `backend/internal/handler/auth_handler_test.go:157` | `TestCompletePasswordReset_TokenValueOverridesLoginIDQuery` | 비밀번호 재설정 토큰 우선 규칙 검증 |
+| `backend/internal/handler/auth_handler_test.go:209` | `TestCompletePasswordReset_InvalidTokenRejectedEvenWhenLoginIDExists` | 오류/예외/거부 경로 검증 |
+| `backend/internal/handler/auth_handler_test.go:249` | `TestProcessPasswordResetToken_EncodesLoginIDInRedirect` | 리다이렉트/쿼리 보존 규칙 검증 |
| `backend/internal/handler/dev_handler_test.go:103` | `TestCreateClient_Success` | Hydra/RP 연동 검증 |
| `backend/internal/handler/dev_handler_test.go:15` | `TestListClients_Success` | Hydra/RP 연동 검증 |
| `backend/internal/handler/dev_handler_test.go:49` | `TestGetClient_Success` | Hydra/RP 연동 검증 |
@@ -48,6 +51,7 @@
| `backend/internal/idp/factory_test.go:123` | `TestChainedProviderMetadataUnion` | 회귀 방지 기본 동작 검증 |
| `backend/internal/idp/factory_test.go:139` | `TestChainedProviderUpdateUserPasswordFallback` | 복구/격리/회복 탄력성 검증 |
| `backend/internal/idp/factory_test.go:152` | `TestChainedProviderUpdateUserPasswordAllFail` | 인증/OIDC 플로우 검증 |
+| `backend/internal/logger/audit_logger_test.go:14` | `TestAuditLogEntry_RedactsSensitiveFields` | 감사 로그 민감정보 마스킹/비노출 검증 |
| `backend/internal/middleware/audit_middleware_test.go:42` | `TestAuditMiddleware` | 회귀 방지 기본 동작 검증 |
| `backend/internal/middleware/error_code_enricher_test.go:22` | `TestErrorCodeEnricher_AddsCodeToLegacyErrorResponse` | 오류/예외/거부 경로 검증 |
| `backend/internal/middleware/error_code_enricher_test.go:50` | `TestErrorCodeEnricher_DoesNotOverrideExistingCode` | 오류/예외/거부 경로 검증 |
diff --git a/docs/test-plan/userfront-test-inventory.md b/docs/test-plan/userfront-test-inventory.md
index 217ab751..8d2ca68e 100644
--- a/docs/test-plan/userfront-test-inventory.md
+++ b/docs/test-plan/userfront-test-inventory.md
@@ -43,15 +43,22 @@
| `userfront/test/login_challenge_resolver_test.dart` | `widget 값이 없으면 URI query에서 복구` | fallback/복구 경로 검증 |
| `userfront/test/login_challenge_resolver_test.dart` | `widget 값이 있으면 최우선으로 사용` | 핵심 동작 회귀 방지 검증 |
| `userfront/test/login_challenge_resolver_test.dart` | `값이 전부 없으면 missing` | fallback/복구 경로 검증 |
+| `userfront/test/null_check_recovery_test.dart` | `Null check 오류 + 루트(/)면 선호 로케일 signin으로 복구` | Null-check 예외 복구 경로 검증 |
+| `userfront/test/null_check_recovery_test.dart` | `Null check 오류 + /ko면 /ko/signin으로 복구` | Null-check 예외 복구 경로 검증 |
+| `userfront/test/null_check_recovery_test.dart` | `이미 /ko/signin이면 복구 이동하지 않음` | Null-check 예외 복구 경로 검증 |
+| `userfront/test/null_check_recovery_test.dart` | `Null check 오류여도 /ko/profile에서는 복구 이동하지 않음` | Null-check 예외 복구 경로 검증 |
+| `userfront/test/null_check_recovery_test.dart` | `다른 오류 메시지면 복구 이동하지 않음` | Null-check 예외 복구 경로 검증 |
| `userfront/test/oidc_redirect_guard_test.dart` | `http/https 절대 URL만 허용` | 핵심 동작 회귀 방지 검증 |
| `userfront/test/oidc_redirect_guard_test.dart` | `빈 문자열과 파싱 실패를 차단` | 핵심 동작 회귀 방지 검증 |
| `userfront/test/password_login_flow_policy_test.dart` | `OIDC challenge가 없고 jwt가 있으면 로컬 로그인 완료로 진행한다` | 로그인 분기/라우팅 규칙 검증 |
| `userfront/test/password_login_flow_policy_test.dart` | `OIDC challenge가 있고 redirectTo가 없으면 accept를 시도한다` | 로그인 분기/라우팅 규칙 검증 |
| `userfront/test/password_login_flow_policy_test.dart` | `redirectTo/jwt 모두 없으면 invalid로 처리한다` | 로그인 분기/라우팅 규칙 검증 |
| `userfront/test/password_login_flow_policy_test.dart` | `redirectTo가 있으면 OIDC redirect를 우선한다` | 로그인 분기/라우팅 규칙 검증 |
+| `userfront/test/router_redirect_widget_test.dart` | `루트 경로: /{locale} 로 접근 시 /{locale}/signin 으로 리다이렉트되어야 한다 (버그: 화면 렌더링 안됨)` | 로그인 분기/라우팅 규칙 검증 |
| `userfront/test/router_redirect_widget_test.dart` | `/login: login_challenge와 redirect_uri를 전달` | 리다이렉트/쿼리 보존 규칙 검증 |
| `userfront/test/router_redirect_widget_test.dart` | `로그인 상태: profile 접근 시 signin으로 리다이렉트하지 않음` | 로그인 분기/라우팅 규칙 검증 |
| `userfront/test/router_redirect_widget_test.dart` | `로그인 후 같은 브라우저 새 창/팝업에서도 세션이 유지된다` | 로그인 세션 지속성(동일 브라우저) 검증 |
| `userfront/test/router_redirect_widget_test.dart` | `비로그인: redirect_uri/login_challenge가 signin으로 전달` | 리다이렉트/쿼리 보존 규칙 검증 |
| `userfront/test/router_redirect_widget_test.dart` | `비로그인: redirect_uri가 없으면 redirect_url을 전달` | 리다이렉트/쿼리 보존 규칙 검증 |
-| `userfront/test/widget_test.dart` | `BaronSSOApp builds` | 기본 앱 렌더링 스모크 검증 |
+| `userfront/test/dashboard_screen_smoke_test.dart` | `대시보드는 로그인 토큰이 있으면 크래시 없이 기본 프레임을 렌더링한다` | 대시보드 Null-check 회귀 방지 스모크 검증 |
+| `userfront/test/widget_test.dart` | `smoke test` | 기본 앱 렌더링 스모크 검증 |
diff --git a/docs/test-plan/userfront-wasm-e2e-expansion-plan.md b/docs/test-plan/userfront-wasm-e2e-expansion-plan.md
new file mode 100644
index 00000000..26c8d90a
--- /dev/null
+++ b/docs/test-plan/userfront-wasm-e2e-expansion-plan.md
@@ -0,0 +1,69 @@
+# UserFront WASM Playwright E2E 확장 계획
+
+- 작성일: 2026-02-23
+- 대상: `userfront` (Flutter Web WASM 산출물)
+- 목적: 로그인/리다이렉트/QR 흐름의 브라우저 실동작 회귀를 CI에서 자동 검증
+
+## 1) 전제
+- `flutter build web --wasm --release` 산출물(`userfront/build/web`)을 정적 서버로 서빙합니다.
+- Playwright는 해당 URL로 접속해 E2E를 수행합니다.
+- 카메라/QR은 실장비 의존도를 제거하기 위해 브라우저 API mock 기반 케이스를 기본으로 구성합니다.
+
+## 2) 확장 범위 (우선순위)
+1. Locale 진입/리다이렉트
+- `/` 진입 시 `/{locale}`로 이동
+- 비로그인 상태 `/{locale}` 진입 시 `/{locale}/signin` 이동
+- 로그인 상태 `/{locale}` 진입 시 `/{locale}/dashboard` 이동
+
+2. 로그인 성공/실패 및 새로고침 회귀
+- 정상 로그인 후 `/{locale}/dashboard` 진입
+- 대시보드 진입 후 새로고침 시 `signin`으로 튕기지 않음
+- 비밀번호 오류 시 코드 기반 에러 표시 동작 확인
+
+3. 비밀번호 재설정 플로우
+- reset 링크 진입 후 비밀번호 변경
+- 변경된 비밀번호로 즉시 로그인 가능
+
+4. QR 로그인 (웹 로그인 페이지)
+- QR init/poll 기본 플로우
+- 만료/재발급 동작
+
+5. QR 스캔/승인 (WASM)
+- `/scan`에서 스캔 결과가 `/{locale}/approve?ref=...`로 전달됨
+- BarcodeDetector 미지원/카메라 실패 시 수동 입력 fallback 동작
+- approve 성공 시 dashboard 이동
+
+6. 널체크 회복 경로 회귀
+- `/ko` 경로에서 null-check 예외 발생 시 recovery target(`/{locale}/signin`) 이동 보장
+
+## 3) 구현 단계
+### Phase 0. E2E 실행 기반
+- `userfront-e2e/` (Playwright) 추가
+- `BASE_URL`/`LOCALE`/`MOCK_AUTH` 환경변수 표준화
+- CI job: WASM build 산출물 서빙 + Playwright 실행
+
+### Phase 1. 인증/리다이렉트 핵심 회귀
+- 범위 1~2 구현
+- 실패 재현 케이스를 먼저 작성(Failing test first)
+
+### Phase 2. 비밀번호 재설정 회귀
+- 범위 3 구현
+- 성공/실패 케이스 분리
+
+### Phase 3. QR 흐름 회귀
+- 범위 4~5 구현
+- BarcodeDetector/getUserMedia mock fixture 도입
+
+### Phase 4. 에러/회복 회귀
+- 범위 6 구현
+- null-check 복구 라우팅 검증
+
+## 4) 완료 기준
+- 핵심 인증 플로우(로그인/새로고침/리다이렉트/QR)가 Playwright 회귀군으로 자동화됩니다.
+- 프로덕션 이슈 재발 건은 재현 테스트가 먼저 추가됩니다.
+- PR에서 E2E 결과 링크(성공/실패 로그) 확인이 가능합니다.
+
+## 5) 운영 원칙
+- 버그는 반드시 재현 테스트를 먼저 추가합니다.
+- 재현 테스트가 실패하는 상태를 확인한 뒤 수정합니다.
+- 수정 후 동일 테스트를 반복 실행해 안정 통과까지 완료합니다.
diff --git a/docs/test-plan/web-e2e-test-inventory.md b/docs/test-plan/web-e2e-test-inventory.md
index 7e771a51..4bf80423 100644
--- a/docs/test-plan/web-e2e-test-inventory.md
+++ b/docs/test-plan/web-e2e-test-inventory.md
@@ -2,6 +2,7 @@
- 범위: `adminfront/tests/*.spec.ts`, `devfront/tests/*.spec.ts`
- 기준: Playwright `test(...)` 케이스 전수
+- 참고: UserFront WASM E2E 확장 계획은 `docs/test-plan/userfront-wasm-e2e-expansion-plan.md`에서 별도 관리
| 파일 | 테스트 | 역할 |
|---|---|---|
diff --git a/docs/trouble-shooting/issue-277-null-check-dashboard-routing.md b/docs/trouble-shooting/issue-277-null-check-dashboard-routing.md
new file mode 100644
index 00000000..8ee809e8
--- /dev/null
+++ b/docs/trouble-shooting/issue-277-null-check-dashboard-routing.md
@@ -0,0 +1,76 @@
+# Issue #277/#302 트러블슈팅 기록: 로그인 후 공백 화면 + 새로고침 시 signin 회귀
+
+## 기준 시점
+- 2026-02-23 KST
+- 재현 환경: `https://sss.hmac.kr` (WASM 배포)
+
+## 증상
+- 로그인 직후 URL은 `/{locale}` 또는 `/{locale}/dashboard`로 보이지만 화면이 렌더링되지 않음
+- 이후 새로고침하면 `/{locale}/signin`으로 되돌아감
+- 콘솔/백엔드 수집 로그:
+ - `Null check operator used on a null value`
+ - `wasm-function[765]` 포함 스택 반복
+
+## 스택 매핑 결과 (source-map + no-strip-wasm)
+- 매핑 커맨드:
+ - `python3 scripts/map_wasm_stack.py --wasm userfront/build/web/main.dart.wasm --sourcemap userfront/build/web/main.dart.wasm.map --frame ...`
+- 핵심 프레임:
+ - `wasm-function[765]` -> `_TypeError._throwNullCheckErrorWithCurrentStack`
+ - 상위 프레임 -> Flutter `NavigatorState.didUpdateWidget/_updatePages` 경로
+- 결론:
+ - 단일 위젯 null 접근보다, 라우트 갱신 타이밍/중복 네비게이션 경쟁에서 `Navigator` 내부에서 터지는 양상
+
+## 지금까지 시행착오와 실패 내역
+1. `LocaleGate`, `LanguageSelector`의 `EasyLocalization.of(context)` null 방어만 적용
+- 결과: 동일 예외 재발
+- 이유: 루트 원인은 로케일 위젯 단일 null 접근이 아니라 네비게이션 경쟁 구간
+
+2. `/ko` 루트에서 signin 강제 리다이렉트만 강화
+- 결과: 최초 진입은 일부 개선됐지만 로그인 직후/새로고침 회귀 지속
+- 이유: 로그인 성공 경로가 루트(`/{locale}`)와 엮이면서 라우트 재평가가 중첩
+
+3. 로그인 화면에서 `AuthNotifier.notify()` + `context.go(...)` 동시 수행
+- 결과: 간헐적 경쟁 상태 유발 가능성 확인
+- 조치: 로컬 네비게이션 1회 가드 도입(`_goLocalizedHomeOnce`)
+
+4. cookie 세션 승격이 토큰 저장 이후 덮어쓰는 경합
+- 결과: 일부 흐름에서 저장 상태 불안정 가능성
+- 조치: `cookie_session_policy` 추가, 토큰 존재 시 불필요한 cookie 승격 차단
+
+5. `/:locale` 엔트리가 redirect 없이 매칭되는 구조
+- 결과: `/ko` 직접 진입 시 페이지 스택 재계산 과정에서 `NavigatorState.didUpdateWidget/_updatePages` 경로 null check 재발
+- 이유: `/ko`는 실질 화면이 아닌 분기 지점인데, 명시적 redirect 경로가 없으면 라우트 갱신 타이밍 경쟁에 취약
+- 조치: `/:locale`를 redirect 전용 엔트리로 확정(비로그인 `/{locale}/signin`, 로그인 `/{locale}/dashboard`)
+
+## 최종 반영 방향 (이번 패치)
+1. 로그인 성공 기본 경로를 명시적으로 `/{locale}/dashboard`로 고정
+- `buildLocalizedHomePath()` 반환값을 `/{locale}/dashboard`로 변경
+- `/:locale` 엔트리는 `/:locale/dashboard`로 redirect 전용 처리
+
+2. 라우터/화면 역할 분리
+- 보호 경로 검사는 router redirect에서 수행
+- 대시보드는 필요 시 cookie 세션 복구를 1회 시도 후 signin 이동
+
+3. 중복 네비게이션 억제
+- 로그인 성공 시 내부 이동은 1회만 수행
+
+## 검증
+- 추가 테스트:
+ - `userfront/test/login_navigation_race_test.dart`
+ - `userfront/test/cookie_session_policy_test.dart`
+ - `userfront/test/router_redirect_widget_test.dart` (`/{locale}` 직접 진입 시 signin/dashboard 분기 검증)
+- 갱신 테스트:
+ - `userfront/test/locale_utils_test.dart` (home path `/{locale}/dashboard` 기준)
+- 실행:
+ - `flutter test`
+ - `flutter test --platform chrome test/router_redirect_widget_test.dart test/login_navigation_race_test.dart test/cookie_session_policy_test.dart`
+
+## 남은 리스크
+- 실제 브라우저 저장소 정책(localStorage 차단/쿠키 정책)에 따라 세션 판정이 달라질 수 있음
+- 운영 검증 시 네트워크/스토리지 상태를 함께 수집해야 원인 분리 가능
+
+## 운영 확인 체크리스트
+1. 비로그인으로 `/{locale}` 접속 시 즉시 `/{locale}/signin` 이동
+2. 로그인 성공 시 `/{locale}/dashboard` 진입
+3. `/{locale}/dashboard`에서 새로고침 후 세션 유지 (동일 브라우저)
+4. 실패 시 `RECOVERY_NAV_NULL_CHECK`와 wasm frame 동시 수집
diff --git a/locales/en.toml b/locales/en.toml
index 43be38dd..68f376b5 100644
--- a/locales/en.toml
+++ b/locales/en.toml
@@ -243,14 +243,15 @@ saving = "Saving..."
unknown_error = "unknown error"
[msg.dev]
+logout_confirm = "Are you sure you want to log out?"
[msg.dev.clients]
copy_client_id = "Copy Client Id"
load_error = "Error loading clients: {{error}}"
-loading = "Loading clients..."
-showing = "Showing {{shown}} of {{total}} clients"
+loading = "Loading apps..."
+showing = "Showing {{shown}} of {{total}} apps"
status_update_error = "Failed to update client status"
-status_updated = "Status Updated"
+status_updated = "The app has been {{status}}."
[msg.dev.clients.consents]
empty = "No consents found."
@@ -285,6 +286,7 @@ note = "Note"
load_error = "Error loading client: {{error}}"
loading = "Loading client..."
saved = "Saved"
+save_error = "Failed to save: {{error}}"
[msg.dev.clients.general.identity]
logo_help = "Logo Help"
@@ -298,9 +300,9 @@ empty = "Empty"
subtitle = "Subtitle"
[msg.dev.clients.general.security]
-confidential_help = "Confidential Help"
-public_help = "Public Help"
-subtitle = "Subtitle"
+private_help = "Private App (Server-side): For apps that can safely store a client secret, such as Node.js or Java servers."
+pkce_help = "PKCE App (SPA/Mobile): For apps that cannot safely store a client secret. PKCE is mandatory."
+subtitle = "Select application type. Security level determines authentication method."
[msg.dev.clients.help]
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."
@@ -352,6 +354,7 @@ approved_device = "Approved Device"
approved_ip = "Approve IP: {{ip}}"
audit_empty = "Audit Empty"
audit_load_error = "Audit Load Error"
+render_error = "Dashboard render error: {{error}}"
auth_method = "Auth Method"
client_id = "Client ID: {{id}}"
client_id_missing = "Client Id Missing"
@@ -1051,10 +1054,14 @@ console_title = "Developer Console"
env_badge = "Env: dev"
scope_badge = "Scoped to /dev"
+[ui.dev.nav]
+clients = "Connected Application"
+logout = "Logout"
+
[ui.dev.clients]
copy_client_id = "Copy client id"
-new = "New"
-search_placeholder = "Search Placeholder"
+new = "Add Connected Application"
+search_placeholder = "Search by app name or ID..."
tenant_scoped = "Tenant-scoped"
untitled = "Untitled"
@@ -1130,8 +1137,8 @@ consents = "Consent & Users"
settings = "Settings"
[ui.dev.clients.general]
-create = "Create"
-display_new = "Display New"
+create = "Create Application"
+display_new = "Add Connected Application"
save = "Settings Save"
title_create = "Create Client"
title_edit = "Client Settings"
@@ -1167,10 +1174,11 @@ title = "Scopes"
description = "Description"
mandatory = "Mandatory"
name = "Scope Name"
+delete = "Delete"
[ui.dev.clients.general.security]
-confidential = "Confidential"
-public = "Public"
+private = "Private"
+pkce = "PKCE"
title = "Security Settings"
[ui.dev.clients.help]
@@ -1179,7 +1187,7 @@ title = "Need help with OIDC configuration?"
view_guides = "View guides"
[ui.dev.clients.list]
-title = "Title"
+title = "Connected Applications"
[ui.dev.clients.owner]
avatar_alt = "ops user"
@@ -1203,8 +1211,8 @@ status = "Status"
type = "Type"
[ui.dev.clients.type]
-confidential = "Confidential"
-public = "Public"
+private = "Private"
+pkce = "PKCE"
[ui.dev.dashboard]
ready_badge = "devfront ready"
diff --git a/locales/ko.toml b/locales/ko.toml
index a767c2b2..fb3b4804 100644
--- a/locales/ko.toml
+++ b/locales/ko.toml
@@ -243,14 +243,15 @@ saving = "저장 중..."
unknown_error = "unknown error"
[msg.dev]
+logout_confirm = "로그아웃 하시겠습니까?"
[msg.dev.clients]
-copy_client_id = "클라이언트 ID가 복사되었습니다."
+copy_client_id = "Client ID가 복사되었습니다."
load_error = "Error loading clients: {{error}}"
-loading = "Loading clients..."
-showing = "Showing {{shown}} of {{total}} clients"
+loading = "Loading apps..."
+showing = "Showing {{shown}} of {{total}} apps"
status_update_error = "Failed to update client status"
-status_updated = "클라이언트가 {{status}}되었습니다."
+status_updated = "앱이 {{status}}되었습니다."
[msg.dev.clients.consents]
empty = "No consents found."
@@ -285,6 +286,7 @@ note = "엔드포인트는 읽기 전용으로 유지하고, 비밀키 재발행
load_error = "Error loading client: {{error}}"
loading = "Loading client..."
saved = "설정이 저장되었습니다."
+save_error = "저장 실패: {{error}}"
[msg.dev.clients.general.identity]
logo_help = "인증 화면에 표시될 PNG/SVG URL입니다."
@@ -295,19 +297,19 @@ help = "인증 후 리다이렉트될 URI를 입력하세요. 생성 후 Connect
[msg.dev.clients.general.scopes]
empty = "등록된 스코프가 없습니다."
-subtitle = "이 클라이언트가 요청할 수 있는 권한 범위를 정의합니다."
+subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다."
[msg.dev.clients.general.security]
-confidential_help = "서버 사이드 앱(예: Node.js, Java)처럼 비밀키를 안전하게 보관 가능한 경우."
-public_help = "SPA/모바일 앱처럼 비밀키 보관이 어려운 경우. PKCE를 기본 사용합니다."
-subtitle = "클라이언트 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다."
+private_help = "Private 앱 (서버 사이드 앱): Node.js, Java 등 비밀키를 안전하게 보관 가능한 경우 사용합니다."
+pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다."
+subtitle = "앱 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다."
[msg.dev.clients.help]
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."
subtitle = "Developer guides for Confidential/Public clients, redirect URIs, and auth methods."
[msg.dev.clients.registry]
-description = "OIDC 클라이언트, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다."
+description = "OIDC 앱, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다."
[msg.dev.clients.scopes]
email = "이메일 주소 접근"
@@ -329,7 +331,7 @@ hydra_health = "Hydra Admin 상태 체크 준비"
[msg.dev.sidebar]
notice = "개발자 전용 콘솔입니다."
-notice_detail = "클라이언트 애플리케이션 등록 및 관리를 수행할 수 있습니다."
+notice_detail = "연동 앱 등록 및 관리를 수행할 수 있습니다."
[msg.info]
saved_success = "저장이 완료되었습니다."
@@ -352,6 +354,7 @@ approved_device = "승인 기기: {{device}}"
approved_ip = "승인 IP: {{ip}}"
audit_empty = "최근 접속 이력이 없습니다."
audit_load_error = "접속이력을 불러오지 못했습니다."
+render_error = "대시보드 렌더링 오류: {{error}}"
auth_method = "인증수단: {{method}}"
client_id = "Client ID: {{id}}"
client_id_missing = "Client ID 없음"
@@ -1051,10 +1054,14 @@ console_title = "Developer Console"
env_badge = "Env: dev"
scope_badge = "Scoped to /dev"
+[ui.dev.nav]
+clients = "연동 앱"
+logout = "로그아웃"
+
[ui.dev.clients]
copy_client_id = "Copy client id"
-new = "새 클라이언트"
-search_placeholder = "클라이언트 이름/ID로 검색..."
+new = "연동 앱 추가"
+search_placeholder = "연동 앱 이름/ID로 검색..."
tenant_scoped = "Tenant-scoped"
untitled = "Untitled"
@@ -1097,13 +1104,13 @@ user = "User"
[ui.dev.clients.details]
[ui.dev.clients.details.breadcrumb]
-current = "클라이언트 상세"
+current = "연동 앱 상세"
section = "Relying Parties"
[ui.dev.clients.details.credentials]
client_id = "Client ID"
client_secret = "Client Secret"
-title = "클라이언트 자격 증명"
+title = "앱 자격 증명"
[ui.dev.clients.details.endpoints]
read_only = "읽기 전용"
@@ -1130,8 +1137,8 @@ consents = "Consent & Users"
settings = "Settings"
[ui.dev.clients.general]
-create = "클라이언트 생성"
-display_new = "새 클라이언트"
+create = "앱 생성"
+display_new = "연동 앱 추가"
save = "설정 저장"
title_create = "Create Client"
title_edit = "Client Settings"
@@ -1167,10 +1174,11 @@ title = "Scopes"
description = "Description"
mandatory = "Mandatory"
name = "Scope Name"
+delete = "Delete"
[ui.dev.clients.general.security]
-confidential = "Confidential"
-public = "Public"
+private = "Private"
+pkce = "PKCE"
title = "보안 설정"
[ui.dev.clients.help]
@@ -1179,7 +1187,7 @@ title = "Need help with OIDC configuration?"
view_guides = "View guides"
[ui.dev.clients.list]
-title = "클라이언트 목록"
+title = "연동 앱 목록"
[ui.dev.clients.owner]
avatar_alt = "ops user"
@@ -1203,8 +1211,8 @@ status = "상태"
type = "유형"
[ui.dev.clients.type]
-confidential = "기밀(Confidential)"
-public = "Public"
+private = "Private"
+pkce = "PKCE"
[ui.dev.dashboard]
ready_badge = "devfront ready"
diff --git a/locales/template.toml b/locales/template.toml
index 71407904..b92a8848 100644
--- a/locales/template.toml
+++ b/locales/template.toml
@@ -205,6 +205,7 @@ saving = ""
unknown_error = ""
[msg.dev]
+logout_confirm = ""
[msg.dev.clients]
copy_client_id = ""
@@ -247,6 +248,7 @@ note = ""
load_error = ""
loading = ""
saved = ""
+save_error = ""
[msg.dev.clients.general.identity]
logo_help = ""
@@ -260,8 +262,8 @@ empty = ""
subtitle = ""
[msg.dev.clients.general.security]
-confidential_help = ""
-public_help = ""
+private_help = ""
+pkce_help = ""
subtitle = ""
[msg.dev.clients.help]
@@ -314,6 +316,7 @@ approved_device = ""
approved_ip = ""
audit_empty = ""
audit_load_error = ""
+render_error = ""
auth_method = ""
client_id = ""
client_id_missing = ""
@@ -939,6 +942,10 @@ console_title = ""
env_badge = ""
scope_badge = ""
+[ui.dev.nav]
+clients = ""
+logout = ""
+
[ui.dev.clients]
copy_client_id = ""
new = ""
@@ -1055,10 +1062,11 @@ title = ""
description = ""
mandatory = ""
name = ""
+delete = ""
[ui.dev.clients.general.security]
-confidential = ""
-public = ""
+private = ""
+pkce = ""
title = ""
[ui.dev.clients.help]
@@ -1091,8 +1099,8 @@ status = ""
type = ""
[ui.dev.clients.type]
-confidential = ""
-public = ""
+private = ""
+pkce = ""
[ui.dev.dashboard]
ready_badge = ""
diff --git a/scripts/map_wasm_stack.py b/scripts/map_wasm_stack.py
new file mode 100644
index 00000000..fb2e5dae
--- /dev/null
+++ b/scripts/map_wasm_stack.py
@@ -0,0 +1,240 @@
+#!/usr/bin/env python3
+"""
+WASM 스택의 `wasm-function[IDX]:0xOFFSET`를 이름/소스 라인으로 매핑합니다.
+
+사용 예시:
+ python3 scripts/map_wasm_stack.py \
+ --wasm userfront/build/web/main.dart.wasm \
+ --sourcemap userfront/build/web/main.dart.wasm.map \
+ --frame "19112:0x2cd913" --frame "765:0x10af0e"
+"""
+
+from __future__ import annotations
+
+import argparse
+import bisect
+import json
+import re
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Dict, List, Optional, Tuple
+
+
+BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
+BASE64_MAP = {c: i for i, c in enumerate(BASE64_CHARS)}
+
+
+def read_u32_leb128(buf: bytes, i: int) -> Tuple[int, int]:
+ value = 0
+ shift = 0
+ while True:
+ b = buf[i]
+ i += 1
+ value |= (b & 0x7F) << shift
+ if b < 0x80:
+ return value, i
+ shift += 7
+
+
+def decode_vlq_segment(segment: str) -> List[int]:
+ out: List[int] = []
+ i = 0
+ while i < len(segment):
+ shift = 0
+ value = 0
+ while True:
+ d = BASE64_MAP[segment[i]]
+ i += 1
+ value |= (d & 0x1F) << shift
+ shift += 5
+ if (d & 0x20) == 0:
+ break
+ sign = value & 1
+ value >>= 1
+ out.append(-value if sign else value)
+ return out
+
+
+@dataclass
+class SourcePoint:
+ generated_col: int
+ source_index: Optional[int]
+ source_line: Optional[int]
+ source_col: Optional[int]
+ name_index: Optional[int]
+
+
+class WasmSourceMap:
+ def __init__(self, sourcemap_path: Path):
+ data = json.loads(sourcemap_path.read_text(encoding="utf-8"))
+ self.sources: List[str] = data["sources"]
+ self.names: List[str] = data.get("names", [])
+ mappings: str = data["mappings"]
+ # wasm sourcemap은 generated line 1개를 쓰는 형태라 ',' 단위로만 파싱합니다.
+ segments = mappings.split(",")
+
+ points: List[SourcePoint] = []
+ generated_col = 0
+ source_index = 0
+ source_line = 0
+ source_col = 0
+ name_index = 0
+
+ for seg in segments:
+ if not seg:
+ continue
+ vals = decode_vlq_segment(seg)
+ generated_col += vals[0]
+ si: Optional[int] = None
+ sl: Optional[int] = None
+ sc: Optional[int] = None
+ ni: Optional[int] = None
+ if len(vals) >= 4:
+ source_index += vals[1]
+ source_line += vals[2]
+ source_col += vals[3]
+ si = source_index
+ sl = source_line
+ sc = source_col
+ if len(vals) >= 5:
+ name_index += vals[4]
+ ni = name_index
+ points.append(
+ SourcePoint(
+ generated_col=generated_col,
+ source_index=si,
+ source_line=sl,
+ source_col=sc,
+ name_index=ni,
+ )
+ )
+ self.points = points
+ self.columns = [p.generated_col for p in points]
+
+ def lookup(self, offset: int) -> Optional[SourcePoint]:
+ idx = bisect.bisect_right(self.columns, offset) - 1
+ if idx < 0:
+ return None
+ return self.points[idx]
+
+ def source_name(self, index: Optional[int]) -> Optional[str]:
+ if index is None or index < 0 or index >= len(self.sources):
+ return None
+ return self.sources[index]
+
+ def symbol_name(self, index: Optional[int]) -> Optional[str]:
+ if index is None or index < 0 or index >= len(self.names):
+ return None
+ return self.names[index]
+
+
+def parse_wasm_function_names(wasm_path: Path) -> Dict[int, str]:
+ b = wasm_path.read_bytes()
+ if b[:4] != b"\x00asm":
+ raise ValueError(f"Not a wasm binary: {wasm_path}")
+
+ function_names: Dict[int, str] = {}
+ i = 8 # magic + version
+
+ while i < len(b):
+ section_id = b[i]
+ i += 1
+ section_size, i = read_u32_leb128(b, i)
+ section_start = i
+ section_end = i + section_size
+
+ if section_id == 0: # custom section
+ name_len, j = read_u32_leb128(b, i)
+ custom_name = b[j : j + name_len].decode("utf-8", errors="replace")
+ payload_start = j + name_len
+ if custom_name == "name":
+ k = payload_start
+ while k < section_end:
+ subsection_id = b[k]
+ k += 1
+ subsection_size, k = read_u32_leb128(b, k)
+ subsection_end = k + subsection_size
+ if subsection_id == 1: # function names
+ count, k = read_u32_leb128(b, k)
+ for _ in range(count):
+ fn_idx, k = read_u32_leb128(b, k)
+ nlen, k = read_u32_leb128(b, k)
+ name = b[k : k + nlen].decode("utf-8", errors="replace")
+ k += nlen
+ function_names[fn_idx] = name
+ else:
+ k = subsection_end
+
+ i = section_end
+ return function_names
+
+
+def parse_frame(raw: str) -> Tuple[int, int]:
+ m = re.match(r"^\s*(\d+)\s*:\s*(0x[0-9a-fA-F]+)\s*$", raw)
+ if not m:
+ raise ValueError(f"Invalid --frame format: {raw!r} (expected IDX:0xOFFSET)")
+ return int(m.group(1)), int(m.group(2), 16)
+
+
+def parse_args() -> argparse.Namespace:
+ p = argparse.ArgumentParser(description="Map wasm stack frames to source locations")
+ p.add_argument("--wasm", required=True, type=Path, help="WASM binary path")
+ p.add_argument("--sourcemap", required=True, type=Path, help="WASM sourcemap path")
+ p.add_argument(
+ "--frame",
+ action="append",
+ default=[],
+ help="Frame in IDX:0xOFFSET format (repeatable)",
+ )
+ p.add_argument(
+ "--offset",
+ action="append",
+ default=[],
+ help="Offset only (hex), function index unknown",
+ )
+ return p.parse_args()
+
+
+def main() -> None:
+ args = parse_args()
+ source_map = WasmSourceMap(args.sourcemap)
+ function_names = parse_wasm_function_names(args.wasm)
+
+ targets: List[Tuple[Optional[int], int]] = []
+ for f in args.frame:
+ idx, off = parse_frame(f)
+ targets.append((idx, off))
+ for off in args.offset:
+ targets.append((None, int(off, 16)))
+
+ if not targets:
+ raise SystemExit("No targets. Provide --frame or --offset.")
+
+ for fn_idx, off in targets:
+ point = source_map.lookup(off)
+ fn_name = function_names.get(fn_idx) if fn_idx is not None else None
+ mapped_col = point.generated_col if point else None
+ src = source_map.source_name(point.source_index) if point else None
+ src_line = (point.source_line + 1) if point and point.source_line is not None else None
+ src_col = (point.source_col + 1) if point and point.source_col is not None else None
+ symbol = source_map.symbol_name(point.name_index) if point else None
+
+ print(
+ json.dumps(
+ {
+ "function_index": fn_idx,
+ "function_name": fn_name,
+ "offset_hex": hex(off),
+ "mapped_generated_col_hex": hex(mapped_col) if mapped_col is not None else None,
+ "source": src,
+ "source_line": src_line,
+ "source_column": src_col,
+ "symbol": symbol,
+ },
+ ensure_ascii=False,
+ )
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/userfront/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/userfront/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
index db77bb4b..6b203725 100644
Binary files a/userfront/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/userfront/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/userfront/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/userfront/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
index 17987b79..e2fe34e0 100644
Binary files a/userfront/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/userfront/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/userfront/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/userfront/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
index 09d43914..ad04d46a 100644
Binary files a/userfront/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/userfront/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/userfront/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/userfront/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
index d5f1c8d3..3c896456 100644
Binary files a/userfront/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/userfront/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/userfront/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/userfront/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
index 4d6372ee..a773574e 100644
Binary files a/userfront/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/userfront/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/userfront/assets/baron.ico b/userfront/assets/baron.ico
new file mode 100644
index 00000000..e79c6fce
Binary files /dev/null and b/userfront/assets/baron.ico differ
diff --git a/userfront/assets/translations/en.toml b/userfront/assets/translations/en.toml
index e33f46ee..5cd91c17 100644
--- a/userfront/assets/translations/en.toml
+++ b/userfront/assets/translations/en.toml
@@ -38,6 +38,7 @@ approved_device = "Approved Device"
approved_ip = "Approve IP: {ip}"
audit_empty = "Audit Empty"
audit_load_error = "Audit Load Error"
+render_error = "Dashboard render error: {error}"
auth_method = "Auth Method"
client_id = "Client ID: {id}"
client_id_missing = "Client Id Missing"
@@ -557,4 +558,3 @@ verify = "Verify"
[ui.userfront.signup.success]
action = "Action"
-
diff --git a/userfront/assets/translations/ko.toml b/userfront/assets/translations/ko.toml
index ee1df42f..20824c45 100644
--- a/userfront/assets/translations/ko.toml
+++ b/userfront/assets/translations/ko.toml
@@ -38,6 +38,7 @@ approved_device = "승인 기기: {device}"
approved_ip = "승인 IP: {ip}"
audit_empty = "최근 접속 이력이 없습니다."
audit_load_error = "접속이력을 불러오지 못했습니다."
+render_error = "대시보드 렌더링 오류: {error}"
auth_method = "인증수단: {method}"
client_id = "Client ID: {id}"
client_id_missing = "Client ID 없음"
@@ -557,4 +558,3 @@ verify = "본인인증"
[ui.userfront.signup.success]
action = "로그인하기"
-
diff --git a/userfront/assets/translations/template.toml b/userfront/assets/translations/template.toml
index 9ee024cb..cb29cf60 100644
--- a/userfront/assets/translations/template.toml
+++ b/userfront/assets/translations/template.toml
@@ -38,6 +38,7 @@ approved_device = ""
approved_ip = ""
audit_empty = ""
audit_load_error = ""
+render_error = ""
auth_method = ""
client_id = ""
client_id_missing = ""
diff --git a/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png
index dc9ada47..b5b205e2 100644
Binary files a/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ
diff --git a/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png
index 7353c41e..edd28aea 100644
Binary files a/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ
diff --git a/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png
index 797d452e..2853019d 100644
Binary files a/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ
diff --git a/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png
index 6ed2d933..540281ef 100644
Binary files a/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ
diff --git a/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png
index 4cd7b009..11278bcc 100644
Binary files a/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ
diff --git a/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png
index fe730945..654a328d 100644
Binary files a/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ
diff --git a/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png
index 321773cd..498ad709 100644
Binary files a/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ
diff --git a/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png
index 797d452e..2853019d 100644
Binary files a/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ
diff --git a/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png
index 502f463a..c7e45fae 100644
Binary files a/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ
diff --git a/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png
index 0ec30343..7613948e 100644
Binary files a/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ
diff --git a/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png
index 0ec30343..7613948e 100644
Binary files a/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ
diff --git a/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png
index e9f5fea2..5a3af351 100644
Binary files a/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ
diff --git a/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png
index 84ac32ae..3d798be6 100644
Binary files a/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ
diff --git a/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png
index 8953cba0..3510038c 100644
Binary files a/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ
diff --git a/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
index 0467bf12..083e53fd 100644
Binary files a/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/userfront/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ
diff --git a/userfront/lib/core/i18n/locale_gate.dart b/userfront/lib/core/i18n/locale_gate.dart
index 1031703c..adeea578 100644
--- a/userfront/lib/core/i18n/locale_gate.dart
+++ b/userfront/lib/core/i18n/locale_gate.dart
@@ -1,3 +1,5 @@
+import 'dart:async';
+
import 'package:easy_localization/easy_localization.dart' hide tr;
import 'package:flutter/material.dart';
@@ -17,28 +19,54 @@ class LocaleGate extends StatefulWidget {
}
class _LocaleGateState extends State {
+ bool _syncScheduled = false;
+
@override
void didChangeDependencies() {
super.didChangeDependencies();
- _applyLocale();
+ _scheduleLocaleSync();
}
@override
void didUpdateWidget(LocaleGate oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.localeCode != widget.localeCode) {
- _applyLocale();
+ _scheduleLocaleSync();
}
}
- Future _applyLocale() async {
- final normalized = normalizeLocaleCode(widget.localeCode);
- LocaleStorage.write(normalized);
- webWindow.setTitle(tr('ui.userfront.app_title'));
- if (context.locale.languageCode == normalized) {
+ void _scheduleLocaleSync() {
+ if (_syncScheduled) {
+ return;
+ }
+ _syncScheduled = true;
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ _syncScheduled = false;
+ if (!mounted) {
+ return;
+ }
+ unawaited(_applyLocale());
+ });
+ }
+
+ Future _applyLocale() async {
+ if (!mounted) {
+ return;
+ }
+ final normalized = normalizeLocaleCode(widget.localeCode);
+ LocaleStorage.write(normalized);
+ final localization = EasyLocalization.of(context);
+ if (localization == null) {
+ return;
+ }
+ if (localization.currentLocale?.languageCode == normalized) {
+ webWindow.setTitle(tr('ui.userfront.app_title'));
+ return;
+ }
+ await localization.setLocale(Locale(normalized));
+ if (!mounted) {
return;
}
- await context.setLocale(Locale(normalized));
webWindow.setTitle(tr('ui.userfront.app_title'));
}
diff --git a/userfront/lib/core/i18n/locale_storage_engine.dart b/userfront/lib/core/i18n/locale_storage_engine.dart
index 0c907b40..c5dd2ebf 100644
--- a/userfront/lib/core/i18n/locale_storage_engine.dart
+++ b/userfront/lib/core/i18n/locale_storage_engine.dart
@@ -183,10 +183,11 @@ class LocaleStorageEngine implements LocaleStorageBackend {
final legacy = _readByKey(LocaleStoragePolicy.legacyKey);
if (LocaleStoragePolicy.shouldMigrateLegacy(
- current: current,
- legacy: legacy,
- )) {
- _writeByKey(LocaleStoragePolicy.currentKey, legacy!);
+ current: current,
+ legacy: legacy,
+ ) &&
+ legacy != null) {
+ _writeByKey(LocaleStoragePolicy.currentKey, legacy);
_removeEverywhere(LocaleStoragePolicy.legacyKey);
return legacy;
}
diff --git a/userfront/lib/core/i18n/locale_utils.dart b/userfront/lib/core/i18n/locale_utils.dart
index 5d403bcf..dd9989c8 100644
--- a/userfront/lib/core/i18n/locale_utils.dart
+++ b/userfront/lib/core/i18n/locale_utils.dart
@@ -32,10 +32,10 @@ String resolvePreferredLocaleCode() {
}
}
final deviceLocale = PlatformDispatcher.instance.locale;
- final languageTag =
- deviceLocale.countryCode == null || deviceLocale.countryCode!.isEmpty
+ final countryCode = deviceLocale.countryCode;
+ final languageTag = countryCode == null || countryCode.isEmpty
? deviceLocale.languageCode
- : '${deviceLocale.languageCode}-${deviceLocale.countryCode}';
+ : '${deviceLocale.languageCode}-$countryCode';
return normalizeLocaleCode(languageTag);
}
@@ -101,3 +101,17 @@ String buildSigninRedirectPath(String localeCode, Uri uri) {
}
return result;
}
+
+String buildLocalizedHomePath(Uri uri, {String? preferredLocaleCode}) {
+ final resolvedLocale =
+ extractLocaleFromPath(uri) ??
+ normalizeLocaleCode(preferredLocaleCode ?? resolvePreferredLocaleCode());
+ return '/$resolvedLocale/dashboard';
+}
+
+String buildLocalizedSigninPath(Uri uri, {String? preferredLocaleCode}) {
+ final resolvedLocale =
+ extractLocaleFromPath(uri) ??
+ normalizeLocaleCode(preferredLocaleCode ?? resolvePreferredLocaleCode());
+ return '/$resolvedLocale/signin';
+}
diff --git a/userfront/lib/core/services/null_check_recovery.dart b/userfront/lib/core/services/null_check_recovery.dart
new file mode 100644
index 00000000..fa31030b
--- /dev/null
+++ b/userfront/lib/core/services/null_check_recovery.dart
@@ -0,0 +1,26 @@
+import '../i18n/locale_utils.dart';
+
+String? computeNullCheckRecoveryTarget({
+ required Object exception,
+ required Uri uri,
+ required String preferredLocaleCode,
+}) {
+ final message = exception.toString();
+ if (!message.contains('Null check operator used on a null value')) {
+ return null;
+ }
+
+ final localeCode =
+ extractLocaleFromPath(uri) ?? normalizeLocaleCode(preferredLocaleCode);
+ final path = uri.path;
+ final localeRootPath = '/$localeCode';
+ if (path != '/' && path != localeRootPath) {
+ return null;
+ }
+
+ final target = '/$localeCode/signin';
+ if (path == target) {
+ return null;
+ }
+ return target;
+}
diff --git a/userfront/lib/core/services/web_auth_integration_web.dart b/userfront/lib/core/services/web_auth_integration_web.dart
index 061c5d2c..c22f454e 100644
--- a/userfront/lib/core/services/web_auth_integration_web.dart
+++ b/userfront/lib/core/services/web_auth_integration_web.dart
@@ -6,6 +6,7 @@ import 'package:web/web.dart' as web;
import 'package:flutter/foundation.dart';
import 'dart:js_interop';
import 'auth_token_store.dart';
+import '../i18n/locale_utils.dart';
void implSendLoginSuccess(String token) {
var effectiveToken = token;
@@ -87,8 +88,9 @@ void implSendLoginSuccess(String token) {
}
// No opener and no redirect: fall back to local navigation
- debugPrint('No opener found. Redirecting to /.');
- web.window.location.href = '/';
+ final fallbackTarget = buildLocalizedHomePath(Uri.base);
+ debugPrint('No opener found. Redirecting to $fallbackTarget.');
+ web.window.location.href = fallbackTarget;
}
bool implIsPopup() {
diff --git a/userfront/lib/core/widgets/language_selector.dart b/userfront/lib/core/widgets/language_selector.dart
index 27520d84..d1ff2fd6 100644
--- a/userfront/lib/core/widgets/language_selector.dart
+++ b/userfront/lib/core/widgets/language_selector.dart
@@ -13,7 +13,13 @@ class LanguageSelector extends StatelessWidget {
@override
Widget build(BuildContext context) {
- final current = context.locale.languageCode;
+ final localization = EasyLocalization.of(context);
+ final resolvedCurrent = normalizeLocaleCode(
+ localization?.currentLocale?.languageCode,
+ );
+ final current = (resolvedCurrent == 'ko' || resolvedCurrent == 'en')
+ ? resolvedCurrent
+ : 'en';
final items = [
DropdownMenuItem(value: 'ko', child: Text(tr('ui.common.language_ko'))),
DropdownMenuItem(
@@ -34,9 +40,16 @@ class LanguageSelector extends StatelessWidget {
return;
}
LocaleStorage.write(value);
- await context.setLocale(Locale(value));
+ if (localization != null) {
+ await localization.setLocale(Locale(value));
+ }
if (!context.mounted) return;
- final uri = GoRouterState.of(context).uri;
+ Uri uri;
+ try {
+ uri = GoRouterState.of(context).uri;
+ } catch (_) {
+ uri = Uri.base;
+ }
final target = buildLocalizedPath(value, uri);
context.go(target);
},
diff --git a/userfront/lib/features/admin/presentation/create_user_screen.dart b/userfront/lib/features/admin/presentation/create_user_screen.dart
index 8022df09..368cc681 100644
--- a/userfront/lib/features/admin/presentation/create_user_screen.dart
+++ b/userfront/lib/features/admin/presentation/create_user_screen.dart
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/services/auth_proxy_service.dart';
+import '../../../../core/i18n/locale_utils.dart';
class CreateUserScreen extends StatefulWidget {
const CreateUserScreen({super.key});
@@ -67,7 +68,7 @@ class _CreateUserScreenState extends State {
// If cancelled or empty
if (inputPassword == null || inputPassword.isEmpty) {
- if (mounted) context.go('/'); // Kick out
+ if (mounted) context.go(buildLocalizedHomePath(Uri.base)); // Kick out
return;
}
@@ -91,7 +92,7 @@ class _CreateUserScreenState extends State {
backgroundColor: Colors.red,
),
);
- context.go('/'); // Kick out
+ context.go(buildLocalizedHomePath(Uri.base)); // Kick out
}
}
}
@@ -178,7 +179,7 @@ class _CreateUserScreenState extends State {
title: const Text('Create User'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
- onPressed: () => context.go('/'),
+ onPressed: () => context.go(buildLocalizedHomePath(Uri.base)),
),
),
body: Center(
diff --git a/userfront/lib/features/admin/presentation/user_management_screen.dart b/userfront/lib/features/admin/presentation/user_management_screen.dart
index 881acd05..50274f13 100644
--- a/userfront/lib/features/admin/presentation/user_management_screen.dart
+++ b/userfront/lib/features/admin/presentation/user_management_screen.dart
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'dart:async';
import '../../../../core/services/auth_proxy_service.dart';
+import '../../../../core/i18n/locale_utils.dart';
class UserManagementScreen extends StatefulWidget {
const UserManagementScreen({super.key});
@@ -89,7 +90,7 @@ class _UserManagementScreenState extends State
);
if (inputPassword == null || inputPassword.isEmpty) {
- if (mounted) context.go('/');
+ if (mounted) context.go(buildLocalizedHomePath(Uri.base));
return;
}
@@ -113,7 +114,7 @@ class _UserManagementScreenState extends State
backgroundColor: Colors.red,
),
);
- context.go('/');
+ context.go(buildLocalizedHomePath(Uri.base));
}
}
}
@@ -365,7 +366,7 @@ class _UserManagementScreenState extends State
title: const Text('User Management'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
- onPressed: () => context.go('/'),
+ onPressed: () => context.go(buildLocalizedHomePath(Uri.base)),
),
bottom: TabBar(
controller: _tabController,
diff --git a/userfront/lib/features/auth/domain/cookie_session_policy.dart b/userfront/lib/features/auth/domain/cookie_session_policy.dart
new file mode 100644
index 00000000..9d6271d7
--- /dev/null
+++ b/userfront/lib/features/auth/domain/cookie_session_policy.dart
@@ -0,0 +1,15 @@
+bool shouldPromoteCookieSession({
+ required String? currentToken,
+ required String? loginChallenge,
+}) {
+ final hasToken = currentToken != null && currentToken.trim().isNotEmpty;
+ final hasChallenge =
+ loginChallenge != null && loginChallenge.trim().isNotEmpty;
+
+ // 토큰 기반 세션이 이미 확보된 일반 로그인 흐름에서는
+ // 뒤늦은 쿠키 세션 승격이 토큰을 덮어쓰지 않도록 차단합니다.
+ if (hasToken && !hasChallenge) {
+ return false;
+ }
+ return true;
+}
diff --git a/userfront/lib/features/auth/domain/login_link_route_policy.dart b/userfront/lib/features/auth/domain/login_link_route_policy.dart
new file mode 100644
index 00000000..0a3b1a5f
--- /dev/null
+++ b/userfront/lib/features/auth/domain/login_link_route_policy.dart
@@ -0,0 +1,34 @@
+import '../../../core/i18n/locale_utils.dart';
+
+bool isPublicAuthPath(String path, Uri uri) {
+ return path == '/signin' ||
+ path == '/signup' ||
+ path == '/login' ||
+ path == '/registration' ||
+ path == '/verify' ||
+ path == '/verification' ||
+ path.startsWith('/verify/') ||
+ path.startsWith('/l/') ||
+ path == '/approve' ||
+ path.startsWith('/ql/') ||
+ path == '/forgot-password' ||
+ path == '/recovery' ||
+ path == '/reset-password' ||
+ path == '/error' ||
+ path == '/settings' ||
+ path == '/consent' ||
+ path.startsWith('/consent/') ||
+ uri.path.contains('/consent');
+}
+
+String? extractLoginShortCode(Uri uri) {
+ final normalizedPath = stripLocalePath(uri);
+ final segments = normalizedPath
+ .split('/')
+ .where((segment) => segment.isNotEmpty)
+ .toList();
+ if (segments.length < 2 || segments.first != 'l') {
+ return null;
+ }
+ return segments[1];
+}
diff --git a/userfront/lib/features/auth/presentation/approve_qr_screen.dart b/userfront/lib/features/auth/presentation/approve_qr_screen.dart
index d55c6b2b..78c97aa2 100644
--- a/userfront/lib/features/auth/presentation/approve_qr_screen.dart
+++ b/userfront/lib/features/auth/presentation/approve_qr_screen.dart
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
+import '../../../../core/i18n/locale_utils.dart';
import '../../../../core/services/auth_proxy_service.dart';
import '../../../../core/services/auth_token_store.dart';
@@ -17,11 +18,15 @@ class _ApproveQrScreenState extends State {
bool _success = false;
bool _isCheckingSession = false;
bool _redirectingToLogin = false;
+ bool _autoApproveTriggered = false;
@override
void initState() {
super.initState();
- _bootstrapCookieSession().then((_) => _redirectIfNotLoggedIn());
+ _bootstrapCookieSession().then((_) {
+ _redirectIfNotLoggedIn();
+ _maybeAutoApprove();
+ });
}
Future _bootstrapCookieSession() async {
@@ -47,18 +52,44 @@ class _ApproveQrScreenState extends State {
void _redirectIfNotLoggedIn() {
if (_redirectingToLogin || !mounted) return;
- final hasStoredToken = AuthTokenStore.getToken() != null;
+ final hasStoredToken = AuthTokenStore.getToken()?.isNotEmpty ?? false;
final usesCookie = AuthTokenStore.usesCookie();
final isLoggedIn = hasStoredToken || usesCookie;
if (!isLoggedIn) {
_redirectingToLogin = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
- context.go('/signin?notice=qr_login_required');
+ final target = buildLocalizedSigninPath(Uri.base);
+ context.go('$target?notice=qr_login_required');
});
}
}
+ void _maybeAutoApprove() {
+ if (!mounted || _autoApproveTriggered) return;
+ if (widget.pendingRef == null || widget.pendingRef!.trim().isEmpty) {
+ if (_message == null) {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!mounted) return;
+ setState(() {
+ _message = 'Error: pendingRef is missing.';
+ });
+ });
+ }
+ return;
+ }
+
+ final hasStoredToken = AuthTokenStore.getToken()?.isNotEmpty ?? false;
+ final usesCookie = AuthTokenStore.usesCookie();
+ final isLoggedIn = hasStoredToken || usesCookie || _isCheckingSession;
+ if (!isLoggedIn || _isLoading || _success) {
+ return;
+ }
+
+ _autoApproveTriggered = true;
+ _handleApprove();
+ }
+
Future _handleApprove() async {
if (widget.pendingRef == null) return;
@@ -70,7 +101,8 @@ class _ApproveQrScreenState extends State {
}
if (storedToken == null && !hasCookie) {
if (mounted) {
- context.go('/signin?notice=qr_login_required');
+ final target = buildLocalizedSigninPath(Uri.base);
+ context.go('$target?notice=qr_login_required');
}
return;
}
@@ -94,7 +126,7 @@ class _ApproveQrScreenState extends State {
// Automatically go to dashboard after a short delay
Future.delayed(const Duration(seconds: 1), () {
- if (mounted) context.go('/');
+ if (mounted) context.go(buildLocalizedHomePath(Uri.base));
});
} catch (e) {
setState(() => _message = "Error: $e");
@@ -105,13 +137,16 @@ class _ApproveQrScreenState extends State {
@override
Widget build(BuildContext context) {
- final hasStoredToken = AuthTokenStore.getToken() != null;
+ final hasStoredToken = AuthTokenStore.getToken()?.isNotEmpty ?? false;
final usesCookie = AuthTokenStore.usesCookie();
final isLoggedIn = hasStoredToken || usesCookie || _isCheckingSession;
if (!isLoggedIn && !_redirectingToLogin) {
_redirectIfNotLoggedIn();
}
+ if (isLoggedIn && !_success && !_isLoading) {
+ _maybeAutoApprove();
+ }
return Scaffold(
appBar: AppBar(title: const Text("QR Login Approval")),
@@ -148,29 +183,44 @@ class _ApproveQrScreenState extends State {
),
),
- if (!_success)
- FilledButton.icon(
- onPressed: _isLoading || !isLoggedIn ? null : _handleApprove,
- icon: const Icon(Icons.check_circle),
- label: const Text("Approve Login"),
- style: FilledButton.styleFrom(
- minimumSize: const Size.fromHeight(60),
- backgroundColor: Colors.blue,
- ),
+ if (_isLoading)
+ const Padding(
+ padding: EdgeInsets.only(bottom: 16),
+ child: CircularProgressIndicator(),
+ ),
+
+ if (!_success && !_isLoading)
+ Text(
+ "Approving login request automatically...",
+ textAlign: TextAlign.center,
+ style: TextStyle(color: Colors.grey.shade700),
),
if (!isLoggedIn && !_success)
Padding(
padding: const EdgeInsets.only(top: 16),
child: TextButton(
- onPressed: () => context.go('/signin'),
+ onPressed: () =>
+ context.go(buildLocalizedSigninPath(Uri.base)),
child: const Text("Login on this device first"),
),
),
+ if (!_success && !_isLoading && _message != null)
+ FilledButton.icon(
+ onPressed: !isLoggedIn
+ ? null
+ : () {
+ _autoApproveTriggered = false;
+ _handleApprove();
+ },
+ icon: const Icon(Icons.refresh),
+ label: const Text("Retry Approval"),
+ ),
+
if (_success)
FilledButton(
- onPressed: () => context.go('/'),
+ onPressed: () => context.go(buildLocalizedHomePath(Uri.base)),
child: const Text("Go to My Dashboard"),
),
],
diff --git a/userfront/lib/features/auth/presentation/consent_screen.dart b/userfront/lib/features/auth/presentation/consent_screen.dart
index 2080063d..4d4f0734 100644
--- a/userfront/lib/features/auth/presentation/consent_screen.dart
+++ b/userfront/lib/features/auth/presentation/consent_screen.dart
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
+import 'package:userfront/core/i18n/locale_utils.dart';
import 'package:userfront/core/services/auth_proxy_service.dart';
import 'package:userfront/core/services/web_window.dart';
@@ -153,7 +154,7 @@ class _ConsentScreenState extends State {
if (redirectTo != null) {
webWindow.redirectTo(redirectTo);
} else {
- if (mounted) context.go('/');
+ if (mounted) context.go(buildLocalizedHomePath(Uri.base));
}
} catch (e) {
setState(() => _isSubmitting = false);
diff --git a/userfront/lib/features/auth/presentation/error_screen.dart b/userfront/lib/features/auth/presentation/error_screen.dart
index d86d1092..b2ebc876 100644
--- a/userfront/lib/features/auth/presentation/error_screen.dart
+++ b/userfront/lib/features/auth/presentation/error_screen.dart
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../core/constants/error_whitelist.dart';
+import '../../../core/i18n/locale_utils.dart';
import '../../../core/services/auth_proxy_service.dart';
import 'package:userfront/i18n.dart';
@@ -130,7 +131,8 @@ class ErrorScreen extends StatelessWidget {
child: Text(tr('ui.userfront.error.go_login')),
),
OutlinedButton(
- onPressed: () => context.go('/'),
+ onPressed: () =>
+ context.go(buildLocalizedHomePath(Uri.base)),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF111827),
padding: const EdgeInsets.symmetric(
diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart
index bddb8515..59cd813b 100644
--- a/userfront/lib/features/auth/presentation/login_screen.dart
+++ b/userfront/lib/features/auth/presentation/login_screen.dart
@@ -9,9 +9,12 @@ import '../../../core/widgets/language_selector.dart';
import '../../../core/services/web_auth_integration.dart';
import '../../../core/services/auth_proxy_service.dart';
import '../../../core/services/auth_token_store.dart';
+import '../../../core/i18n/locale_utils.dart';
import '../../../core/services/oidc_redirect_guard.dart';
import '../../../core/notifiers/auth_notifier.dart';
import '../domain/login_challenge_resolver.dart';
+import '../domain/cookie_session_policy.dart';
+import '../domain/login_link_route_policy.dart';
import '../../profile/domain/notifiers/profile_notifier.dart';
import '../../../core/services/web_window.dart';
@@ -65,6 +68,7 @@ class _LoginScreenState extends ConsumerState
bool _verificationOnly = false;
bool _verificationApproved = false;
bool _dismissedOverlays = false;
+ bool _localNavigationCompleted = false;
String _verificationMessage = '';
String _verificationTitle = tr('ui.userfront.login.verification.title');
String _verificationPageTitle = tr(
@@ -108,8 +112,8 @@ class _LoginScreenState extends ConsumerState
final loginIdParam = uri.queryParameters['loginId'];
final codeParam = uri.queryParameters['code'];
final pendingRefParam = uri.queryParameters['pendingRef'];
- final hasShortCodePath =
- uri.pathSegments.length >= 2 && uri.pathSegments.first == 'l';
+ final shortCodeFromPath = extractLoginShortCode(uri);
+ final hasShortCodePath = shortCodeFromPath != null;
final hasTokenParam = uri.queryParameters.containsKey('t');
final hasVerificationToken =
widget.verificationToken != null || hasTokenParam;
@@ -119,13 +123,16 @@ class _LoginScreenState extends ConsumerState
final notice = uri.queryParameters['notice'];
if (hasShortCodePath) {
- final shortCode = uri.pathSegments[1];
- _verifyShortCode(shortCode);
+ _verifyShortCode(shortCodeFromPath);
}
if (hasLoginCode) {
_verifyLoginCode(loginIdParam, codeParam, pendingRef: pendingRefParam);
} else if (hasVerificationToken) {
- _verifyToken(widget.verificationToken ?? uri.queryParameters['t']!);
+ final verificationToken =
+ widget.verificationToken ?? uri.queryParameters['t'];
+ if (verificationToken != null && verificationToken.isNotEmpty) {
+ _verifyToken(verificationToken);
+ }
}
if (!_noticeHandled && notice == 'qr_login_required') {
@@ -142,8 +149,12 @@ class _LoginScreenState extends ConsumerState
}
Future _tryCookieSession({bool silent = true}) async {
- if (AuthTokenStore.getToken() != null &&
- (_loginChallenge == null || _loginChallenge!.isEmpty)) {
+ final loginChallenge = _loginChallenge;
+ final token = AuthTokenStore.getToken();
+ if (!shouldPromoteCookieSession(
+ currentToken: token,
+ loginChallenge: loginChallenge,
+ )) {
return;
}
final pendingProvider = AuthTokenStore.getPendingProvider();
@@ -151,6 +162,12 @@ class _LoginScreenState extends ConsumerState
try {
await AuthProxyService.checkCookieSession();
+ if (!shouldPromoteCookieSession(
+ currentToken: AuthTokenStore.getToken(),
+ loginChallenge: loginChallenge,
+ )) {
+ return;
+ }
AuthTokenStore.setCookieMode(provider: provider);
AuthTokenStore.clearPendingProvider();
if (mounted) {
@@ -171,7 +188,6 @@ class _LoginScreenState extends ConsumerState
Future _onCookieLoginSuccess(String provider) async {
debugPrint("[Auth] Cookie-based login success. Provider: $provider");
- AuthNotifier.instance.notify();
if (_hasLoginChallenge) {
final accepted = await _acceptOidcLoginAndRedirect();
if (accepted) {
@@ -185,8 +201,9 @@ class _LoginScreenState extends ConsumerState
final token = AuthTokenStore.getToken();
if (token != null && token.isNotEmpty) {
+ final redirectUrl = _redirectUrl;
if (WebAuthIntegration.isPopup() ||
- (_redirectUrl != null && _redirectUrl!.isNotEmpty)) {
+ (redirectUrl != null && redirectUrl.isNotEmpty)) {
debugPrint(
"[Auth] Cookie session with external integration. Notifying...",
);
@@ -196,14 +213,23 @@ class _LoginScreenState extends ConsumerState
}
if (mounted) {
- context.go('/');
+ _goLocalizedHomeOnce();
}
}
+ void _goLocalizedHomeOnce() {
+ if (!mounted || _localNavigationCompleted) {
+ return;
+ }
+ _localNavigationCompleted = true;
+ context.go(buildLocalizedHomePath(Uri.base));
+ }
+
Future _attemptOidcAutoAccept() async {
if (_oidcAutoAcceptTried) return;
_oidcAutoAcceptTried = true;
- if (_loginChallenge == null || _loginChallenge!.isEmpty) {
+ final loginChallenge = _loginChallenge;
+ if (loginChallenge == null || loginChallenge.isEmpty) {
return;
}
@@ -227,12 +253,13 @@ class _LoginScreenState extends ConsumerState
}
Future _acceptOidcLoginAndRedirect({String? token}) async {
- if (_loginChallenge == null || _loginChallenge!.isEmpty) {
+ final loginChallenge = _loginChallenge;
+ if (loginChallenge == null || loginChallenge.isEmpty) {
return false;
}
try {
final res = await AuthProxyService.acceptOidcLogin(
- _loginChallenge!,
+ loginChallenge,
token: token,
);
final redirectTo = res['redirectTo'] as String?;
@@ -274,8 +301,10 @@ class _LoginScreenState extends ConsumerState
}
}
- bool get _hasLoginChallenge =>
- _loginChallenge != null && _loginChallenge!.isNotEmpty;
+ bool get _hasLoginChallenge {
+ final loginChallenge = _loginChallenge;
+ return loginChallenge != null && loginChallenge.isNotEmpty;
+ }
LoginChallengeResolution _resolveLoginChallenge(Uri uri) {
return resolveLoginChallenge(
@@ -486,7 +515,11 @@ class _LoginScreenState extends ConsumerState
}
try {
- final res = await AuthProxyService.pollQrStatus(_qrPendingRef!);
+ final pendingRef = _qrPendingRef;
+ if (pendingRef == null || pendingRef.isEmpty) {
+ return;
+ }
+ final res = await AuthProxyService.pollQrStatus(pendingRef);
if (res['error'] == 'slow_down') {
final interval = res['interval'];
if (interval is int && interval > 0) {
@@ -656,9 +689,11 @@ class _LoginScreenState extends ConsumerState
FilledButton(
onPressed: () {
final hasLocalSession =
- AuthTokenStore.getToken() != null ||
+ (AuthTokenStore.getToken()?.isNotEmpty ?? false) ||
AuthTokenStore.usesCookie();
- final target = hasLocalSession ? '/' : '/signin';
+ final target = hasLocalSession
+ ? buildLocalizedHomePath(Uri.base)
+ : buildLocalizedSigninPath(Uri.base);
if (mounted) {
setState(() {
_verificationOnly = false;
@@ -691,7 +726,9 @@ class _LoginScreenState extends ConsumerState
final jwt = res['token'] ?? res['sessionJwt'] ?? res['sessionToken'];
final status = res['status']?.toString();
final hasLocalSession = await _hasValidLocalSession();
- final actionPath = hasLocalSession ? '/' : '/signin';
+ final actionPath = hasLocalSession
+ ? buildLocalizedHomePath(Uri.base)
+ : buildLocalizedSigninPath(Uri.base);
if (status == 'approved' || (jwt == null && _verificationOnly)) {
if (mounted) {
@@ -754,7 +791,9 @@ class _LoginScreenState extends ConsumerState
"[Auth] Code verification successful for loginId: $sanitizedLoginId",
);
final hasLocalSession = await _hasValidLocalSession();
- final actionPath = hasLocalSession ? '/' : '/signin';
+ final actionPath = hasLocalSession
+ ? buildLocalizedHomePath(Uri.base)
+ : buildLocalizedSigninPath(Uri.base);
if (jwt == null && status == 'approved') {
if (mounted) {
@@ -814,7 +853,9 @@ class _LoginScreenState extends ConsumerState
final status = res['status']?.toString();
debugPrint("[Auth] Short code verification successful");
final hasLocalSession = await _hasValidLocalSession();
- final actionPath = hasLocalSession ? '/' : '/signin';
+ final actionPath = hasLocalSession
+ ? buildLocalizedHomePath(Uri.base)
+ : buildLocalizedSigninPath(Uri.base);
if (jwt == null && status == 'approved') {
if (mounted) {
@@ -1147,14 +1188,15 @@ class _LoginScreenState extends ConsumerState
}
// [Priority 2] OIDC Challenge Handling
- if (_loginChallenge != null && _loginChallenge!.isNotEmpty) {
+ final loginChallenge = _loginChallenge;
+ if (loginChallenge != null && loginChallenge.isNotEmpty) {
try {
// Save token first, it's needed for acceptance
final providerName = provider ?? AuthTokenStore.getProvider();
AuthTokenStore.setToken(token, provider: providerName);
final res = await AuthProxyService.acceptOidcLogin(
- _loginChallenge!,
+ loginChallenge,
token: token,
);
final nextRedirectTo = res['redirectTo'] as String?;
@@ -1196,9 +1238,8 @@ class _LoginScreenState extends ConsumerState
return;
}
- AuthNotifier.instance.notify();
if (mounted) {
- context.go('/');
+ _goLocalizedHomeOnce();
}
} catch (globalErr) {
// ignore
@@ -1237,7 +1278,7 @@ class _LoginScreenState extends ConsumerState
title: Text(_verificationPageTitle),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
- onPressed: () => context.go('/'),
+ onPressed: () => context.go(buildLocalizedHomePath(Uri.base)),
),
),
body: _buildVerificationResultView(),
diff --git a/userfront/lib/features/auth/presentation/login_success_screen.dart b/userfront/lib/features/auth/presentation/login_success_screen.dart
index 97b7b467..cb045820 100644
--- a/userfront/lib/features/auth/presentation/login_success_screen.dart
+++ b/userfront/lib/features/auth/presentation/login_success_screen.dart
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
+import 'package:userfront/core/i18n/locale_utils.dart';
import 'package:userfront/i18n.dart';
class LoginSuccessScreen extends StatelessWidget {
@@ -54,7 +55,7 @@ class LoginSuccessScreen extends StatelessWidget {
const SizedBox(height: 24),
TextButton(
onPressed: () {
- context.go('/');
+ context.go(buildLocalizedHomePath(Uri.base));
},
child: Text(
tr('ui.userfront.login_success.later'),
diff --git a/userfront/lib/features/auth/presentation/qr_camera_bootstrap_policy.dart b/userfront/lib/features/auth/presentation/qr_camera_bootstrap_policy.dart
new file mode 100644
index 00000000..657bf1ca
--- /dev/null
+++ b/userfront/lib/features/auth/presentation/qr_camera_bootstrap_policy.dart
@@ -0,0 +1,54 @@
+enum QrCameraBootstrapStatus {
+ ready,
+ detectorUnsupported,
+ permissionError,
+ cameraError,
+}
+
+class QrCameraBootstrapResult {
+ const QrCameraBootstrapResult(this.status, {this.errorDetail = ''});
+
+ final QrCameraBootstrapStatus status;
+ final String errorDetail;
+
+ bool get isReady => status == QrCameraBootstrapStatus.ready;
+}
+
+typedef QrOpenCameraAndPlay = Future Function();
+typedef QrStopCamera = Future Function();
+
+bool isQrPermissionError(Object error) {
+ final raw = error.toString();
+ return raw.contains('NotAllowedError') ||
+ raw.contains('PermissionDeniedError') ||
+ raw.contains('SecurityError');
+}
+
+Future bootstrapQrCamera({
+ required bool hasBarcodeDetector,
+ required QrOpenCameraAndPlay openCameraAndPlay,
+ required QrStopCamera stopCamera,
+}) async {
+ try {
+ await openCameraAndPlay();
+ if (!hasBarcodeDetector) {
+ await stopCamera();
+ return const QrCameraBootstrapResult(
+ QrCameraBootstrapStatus.detectorUnsupported,
+ errorDetail: 'BarcodeDetector is not supported in this browser.',
+ );
+ }
+ return const QrCameraBootstrapResult(QrCameraBootstrapStatus.ready);
+ } catch (e) {
+ if (isQrPermissionError(e)) {
+ return QrCameraBootstrapResult(
+ QrCameraBootstrapStatus.permissionError,
+ errorDetail: e.toString(),
+ );
+ }
+ return QrCameraBootstrapResult(
+ QrCameraBootstrapStatus.cameraError,
+ errorDetail: e.toString(),
+ );
+ }
+}
diff --git a/userfront/lib/features/auth/presentation/qr_scan_route.dart b/userfront/lib/features/auth/presentation/qr_scan_route.dart
new file mode 100644
index 00000000..b69c7905
--- /dev/null
+++ b/userfront/lib/features/auth/presentation/qr_scan_route.dart
@@ -0,0 +1,17 @@
+import '../../../../core/i18n/locale_utils.dart';
+
+String buildQrApprovePath(
+ String scannedValue, {
+ String? localeCode,
+ Uri? currentUri,
+}) {
+ final value = scannedValue.trim();
+ final explicitLocale = localeCode?.trim();
+ final uri = currentUri ?? Uri.base;
+ final resolvedLocale = explicitLocale != null && explicitLocale.isNotEmpty
+ ? explicitLocale.toLowerCase().replaceAll('_', '-')
+ : normalizeLocaleCode(
+ extractLocaleFromPath(uri) ?? resolvePreferredLocaleCode(),
+ );
+ return '/$resolvedLocale/approve?ref=${Uri.encodeQueryComponent(value)}';
+}
diff --git a/userfront/lib/features/auth/presentation/qr_scan_screen.dart b/userfront/lib/features/auth/presentation/qr_scan_screen.dart
index a13cf54f..fcb18617 100644
--- a/userfront/lib/features/auth/presentation/qr_scan_screen.dart
+++ b/userfront/lib/features/auth/presentation/qr_scan_screen.dart
@@ -1,30 +1,2 @@
-import 'package:flutter/material.dart';
-import 'package:go_router/go_router.dart';
-import 'package:userfront/i18n.dart';
-
-class QRScanScreen extends StatefulWidget {
- const QRScanScreen({super.key});
-
- @override
- State createState() => _QRScanScreenState();
-}
-
-class _QRScanScreenState extends State {
- @override
- Widget build(BuildContext context) {
- return Scaffold(
- appBar: AppBar(
- title: Text(tr('ui.userfront.qr.title', fallback: 'Scan QR Code')),
- leading: IconButton(
- icon: const Icon(Icons.arrow_back),
- onPressed: () => context.pop(),
- ),
- ),
- body: const Center(
- child: Text(
- 'QR Scanner is temporarily disabled for WASM build stability.',
- ),
- ),
- );
- }
-}
+export 'qr_scan_screen_stub.dart'
+ if (dart.library.js_interop) 'qr_scan_screen_web.dart';
diff --git a/userfront/lib/features/auth/presentation/qr_scan_screen_stub.dart b/userfront/lib/features/auth/presentation/qr_scan_screen_stub.dart
new file mode 100644
index 00000000..7f6b77d2
--- /dev/null
+++ b/userfront/lib/features/auth/presentation/qr_scan_screen_stub.dart
@@ -0,0 +1,86 @@
+import 'package:flutter/material.dart';
+import 'package:go_router/go_router.dart';
+import 'package:userfront/i18n.dart';
+
+import 'qr_scan_route.dart';
+
+class QRScanScreen extends StatefulWidget {
+ const QRScanScreen({super.key});
+
+ @override
+ State createState() => _QRScanScreenState();
+}
+
+class _QRScanScreenState extends State {
+ final TextEditingController _controller = TextEditingController();
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ void _submit() {
+ final raw = _controller.text.trim();
+ if (raw.isEmpty) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(
+ tr(
+ 'msg.userfront.qr.permission_required',
+ fallback: '카메라 권한이 필요합니다.',
+ ),
+ ),
+ ),
+ );
+ return;
+ }
+ context.go(buildQrApprovePath(raw));
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: Text(tr('ui.userfront.qr.title', fallback: 'Scan QR Code')),
+ leading: IconButton(
+ icon: const Icon(Icons.arrow_back),
+ onPressed: () => context.pop(),
+ ),
+ ),
+ body: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Text(
+ tr(
+ 'msg.userfront.qr.permission_error',
+ fallback: '카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요.',
+ ),
+ ),
+ const SizedBox(height: 12),
+ TextField(
+ key: const ValueKey('qr_scan_manual_input'),
+ controller: _controller,
+ decoration: const InputDecoration(
+ labelText: 'QR Payload',
+ hintText: 'https://.../ql/{ref} 또는 ref',
+ ),
+ onSubmitted: (_) => _submit(),
+ ),
+ const SizedBox(height: 12),
+ FilledButton.icon(
+ key: const ValueKey('qr_scan_submit_button'),
+ onPressed: _submit,
+ icon: const Icon(Icons.check_circle),
+ label: Text(
+ tr('ui.userfront.qr.result_success', fallback: '승인 화면으로 이동'),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/userfront/lib/features/auth/presentation/qr_scan_screen_web.dart b/userfront/lib/features/auth/presentation/qr_scan_screen_web.dart
new file mode 100644
index 00000000..c3e5291e
--- /dev/null
+++ b/userfront/lib/features/auth/presentation/qr_scan_screen_web.dart
@@ -0,0 +1,238 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:go_router/go_router.dart';
+import 'package:mobile_scanner/mobile_scanner.dart';
+import 'package:userfront/i18n.dart';
+
+import 'qr_scan_route.dart';
+
+class QRScanScreen extends StatefulWidget {
+ const QRScanScreen({super.key});
+
+ @override
+ State createState() => _QRScanScreenState();
+}
+
+class _QRScanScreenState extends State {
+ final MobileScannerController _scannerController = MobileScannerController(
+ autoStart: true,
+ detectionSpeed: DetectionSpeed.noDuplicates,
+ facing: CameraFacing.back,
+ formats: const [BarcodeFormat.qrCode],
+ );
+ final TextEditingController _manualController = TextEditingController();
+
+ bool _isProcessing = false;
+ String? _error;
+ String? _status;
+
+ @override
+ void initState() {
+ super.initState();
+ _status = tr(
+ 'msg.userfront.login.qr.scan_hint',
+ fallback: 'QR 코드를 카메라 중앙에 맞춰주세요.',
+ );
+ }
+
+ @override
+ void dispose() {
+ _manualController.dispose();
+ _scannerController.dispose();
+ super.dispose();
+ }
+
+ Future _navigateToApprove(String rawPayload) async {
+ final payload = rawPayload.trim();
+ if (payload.isEmpty || _isProcessing || !mounted) {
+ return;
+ }
+
+ setState(() {
+ _isProcessing = true;
+ _error = null;
+ _status = tr(
+ 'ui.userfront.qr.result_success',
+ fallback: '승인 화면으로 이동 중...',
+ );
+ });
+
+ try {
+ await _scannerController.stop();
+ } catch (_) {}
+
+ if (!mounted) {
+ return;
+ }
+ context.go(buildQrApprovePath(payload));
+ }
+
+ void _onDetect(BarcodeCapture capture) {
+ for (final barcode in capture.barcodes) {
+ final raw = barcode.rawValue?.trim();
+ if (raw != null && raw.isNotEmpty) {
+ unawaited(_navigateToApprove(raw));
+ return;
+ }
+ }
+ }
+
+ String _toScannerErrorMessage(MobileScannerException error) {
+ switch (error.errorCode) {
+ case MobileScannerErrorCode.permissionDenied:
+ return tr(
+ 'msg.userfront.qr.permission_error',
+ fallback: '카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요.',
+ );
+ case MobileScannerErrorCode.unsupported:
+ return tr(
+ 'msg.userfront.qr.camera_error',
+ fallback: '카메라 오류: {{error}}',
+ params: {'error': 'QR scanner is not supported in this browser.'},
+ );
+ default:
+ final detail = error.errorDetails?.message;
+ return tr(
+ 'msg.userfront.qr.camera_error',
+ fallback: '카메라 오류: {{error}}',
+ params: {'error': detail ?? error.errorCode.message},
+ );
+ }
+ }
+
+ void _submitManual() {
+ unawaited(_navigateToApprove(_manualController.text));
+ }
+
+ Future _retry() async {
+ setState(() {
+ _isProcessing = false;
+ _error = null;
+ _status = tr(
+ 'msg.userfront.login.qr.scan_hint',
+ fallback: 'QR 코드를 카메라 중앙에 맞춰주세요.',
+ );
+ });
+
+ try {
+ await _scannerController.start();
+ } catch (e) {
+ if (!mounted) {
+ return;
+ }
+ setState(() {
+ _error = tr(
+ 'msg.userfront.qr.camera_error',
+ fallback: '카메라 오류: {{error}}',
+ params: {'error': '$e'},
+ );
+ });
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: Text(tr('ui.userfront.qr.title', fallback: 'Scan QR Code')),
+ leading: IconButton(
+ icon: const Icon(Icons.arrow_back),
+ onPressed: () => context.pop(),
+ ),
+ ),
+ body: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ AspectRatio(
+ aspectRatio: 3 / 4,
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ color: Colors.black,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(12),
+ child: Stack(
+ fit: StackFit.expand,
+ children: [
+ MobileScanner(
+ controller: _scannerController,
+ onDetect: _onDetect,
+ errorBuilder: (context, error) {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!mounted) {
+ return;
+ }
+ setState(() {
+ _error = _toScannerErrorMessage(error);
+ });
+ });
+
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Text(
+ _toScannerErrorMessage(error),
+ textAlign: TextAlign.center,
+ style: const TextStyle(color: Colors.white),
+ ),
+ ),
+ );
+ },
+ ),
+ if (_isProcessing)
+ Container(
+ color: Colors.black45,
+ child: const Center(
+ child: CircularProgressIndicator(),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(height: 12),
+ if (_status != null) Text(_status!, textAlign: TextAlign.center),
+ if (_error != null) ...[
+ const SizedBox(height: 8),
+ Text(
+ _error!,
+ textAlign: TextAlign.center,
+ style: const TextStyle(color: Colors.red),
+ ),
+ ],
+ const SizedBox(height: 12),
+ FilledButton.icon(
+ onPressed: _isProcessing ? null : _retry,
+ icon: const Icon(Icons.refresh),
+ label: Text(tr('ui.userfront.qr.rescan', fallback: '다시 스캔')),
+ ),
+ const SizedBox(height: 12),
+ TextField(
+ key: const ValueKey('qr_scan_manual_input'),
+ controller: _manualController,
+ decoration: const InputDecoration(
+ labelText: 'QR Payload',
+ hintText: 'https://.../ql/{ref} 또는 ref',
+ ),
+ onSubmitted: (_) => _submitManual(),
+ ),
+ const SizedBox(height: 8),
+ FilledButton.icon(
+ key: const ValueKey('qr_scan_submit_button'),
+ onPressed: _isProcessing ? null : _submitManual,
+ icon: const Icon(Icons.check_circle),
+ label: Text(
+ tr('ui.userfront.qr.result_success', fallback: '승인 화면으로 이동'),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/userfront/lib/features/auth/presentation/reset_password_screen.dart b/userfront/lib/features/auth/presentation/reset_password_screen.dart
index 6ecc4513..d2ccd716 100644
--- a/userfront/lib/features/auth/presentation/reset_password_screen.dart
+++ b/userfront/lib/features/auth/presentation/reset_password_screen.dart
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
+import '../../../core/i18n/locale_utils.dart';
import '../../../core/services/auth_proxy_service.dart';
import 'package:userfront/i18n.dart';
@@ -89,7 +90,7 @@ class _ResetPasswordScreenState extends State {
backgroundColor: Colors.green,
),
);
- context.go('/signin');
+ context.go(buildLocalizedSigninPath(Uri.base));
}
} catch (e) {
if (mounted) {
diff --git a/userfront/lib/features/auth/presentation/signup_screen.dart b/userfront/lib/features/auth/presentation/signup_screen.dart
index 3c704c86..708e4d5e 100644
--- a/userfront/lib/features/auth/presentation/signup_screen.dart
+++ b/userfront/lib/features/auth/presentation/signup_screen.dart
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:userfront/i18n.dart';
+import '../../../core/i18n/locale_utils.dart';
import '../../../core/services/auth_proxy_service.dart';
class SignupScreen extends StatefulWidget {
@@ -345,7 +346,7 @@ class _SignupScreenState extends State {
content: Text(tr('msg.userfront.signup.success.body')),
actions: [
TextButton(
- onPressed: () => context.go('/signin'),
+ onPressed: () => context.go(buildLocalizedSigninPath(Uri.base)),
child: Text(tr('ui.userfront.signup.success.action')),
),
],
diff --git a/userfront/lib/features/dashboard/domain/dashboard_providers.dart b/userfront/lib/features/dashboard/domain/dashboard_providers.dart
index 93577cd1..1a4307b2 100644
--- a/userfront/lib/features/dashboard/domain/dashboard_providers.dart
+++ b/userfront/lib/features/dashboard/domain/dashboard_providers.dart
@@ -133,7 +133,8 @@ class AuthTimelineNotifier extends Notifier {
if (state.isLoading || state.isLoadingMore) {
return;
}
- if (state.nextCursor == null || state.nextCursor!.isEmpty) {
+ final nextCursor = state.nextCursor;
+ if (nextCursor == null || nextCursor.isEmpty) {
return;
}
state = state.copyWith(isLoadingMore: true, error: null);
diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart
index 4d86c2f8..e89e1cfe 100644
--- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart
+++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart
@@ -1,3 +1,4 @@
+import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -7,6 +8,7 @@ import 'package:url_launcher/url_launcher.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import '../domain/providers/linked_rps_provider.dart';
import '../../../../core/notifiers/auth_notifier.dart';
+import '../../../../core/services/auth_proxy_service.dart';
import '../../../../core/services/auth_token_store.dart';
import '../../../../core/services/http_client.dart';
import '../../../../core/i18n/locale_utils.dart';
@@ -38,6 +40,7 @@ class _DashboardScreenState extends ConsumerState {
bool _auditLoadingMore = false;
bool _isRevoking = false;
bool _redirectingToSignin = false;
+ bool _authBootstrapInProgress = false;
bool _showAllActivities = false;
final Set _revokedClientIds = {};
@@ -47,11 +50,10 @@ class _DashboardScreenState extends ConsumerState {
super.initState();
_pageScrollController.addListener(_onPageScroll);
WidgetsBinding.instance.addPostFrameCallback((_) {
- if (!_isLoggedIn()) {
- _redirectToSignin();
+ if (!mounted) {
return;
}
- _loadAuditLogs(reset: true);
+ unawaited(_bootstrapAuthAndLoad());
});
}
@@ -254,7 +256,7 @@ class _DashboardScreenState extends ConsumerState {
if (closeOnTap) {
Navigator.of(context).pop();
}
- context.go('/');
+ context.go(buildLocalizedHomePath(Uri.base));
},
),
ListTile(
@@ -302,8 +304,11 @@ class _DashboardScreenState extends ConsumerState {
Future _refreshAll() async {
if (!_isLoggedIn()) {
- _redirectToSignin();
- return;
+ final recovered = await _recoverSessionFromCookie();
+ if (!recovered) {
+ _redirectToSignin();
+ return;
+ }
}
await ref.read(profileProvider.notifier).loadProfile();
setState(() {
@@ -372,7 +377,8 @@ class _DashboardScreenState extends ConsumerState {
if (_auditLoading || _auditLoadingMore) {
return;
}
- if (!reset && (_auditNextCursor == null || _auditNextCursor!.isEmpty)) {
+ final nextCursor = _auditNextCursor;
+ if (!reset && (nextCursor == null || nextCursor.isEmpty)) {
return;
}
@@ -706,109 +712,133 @@ class _DashboardScreenState extends ConsumerState {
@override
Widget build(BuildContext context) {
- if (!_isLoggedIn()) {
- _redirectToSignin();
- return const SizedBox.shrink();
- }
- final isWide = MediaQuery.of(context).size.width >= sideMenuBreakpoint;
- final profileState = ref.watch(profileProvider);
- final profile = profileState.value;
- final timelineState = ref.watch(authTimelineProvider);
- final userName =
- profile?.name ??
- profile?.email ??
- profile?.phone ??
- tr('ui.userfront.profile.user_fallback', fallback: 'User');
- final department = profile?.department.isNotEmpty == true
- ? profile!.department
- : tr('ui.userfront.profile.department_empty');
- final sessionIssuedAt = _getJwtIssuedAt();
+ try {
+ if (!_isLoggedIn()) {
+ _redirectToSignin();
+ return const SizedBox.shrink();
+ }
+ final isWide = MediaQuery.of(context).size.width >= sideMenuBreakpoint;
+ final profileState = ref.watch(profileProvider);
+ final profile = profileState.value;
+ final timelineState = ref.watch(authTimelineProvider);
+ final userName =
+ profile?.name ??
+ profile?.email ??
+ profile?.phone ??
+ tr('ui.userfront.profile.user_fallback', fallback: 'User');
+ final departmentValue = profile?.department ?? '';
+ final department = departmentValue.isNotEmpty
+ ? departmentValue
+ : tr('ui.userfront.profile.department_empty');
+ final sessionIssuedAt = _getJwtIssuedAt();
- return Scaffold(
- backgroundColor: _subtle,
- appBar: AppBar(
- title: Text(
- tr('ui.userfront.app_title'),
- style: TextStyle(fontWeight: FontWeight.bold),
- ),
- elevation: 0,
- backgroundColor: _surface,
- foregroundColor: Colors.black,
- actions: [
- IconButton(
- icon: const Icon(Icons.person_outline),
- tooltip: tr('ui.userfront.nav.profile'),
- onPressed: () => context.push('/profile'),
+ return Scaffold(
+ backgroundColor: _subtle,
+ appBar: AppBar(
+ title: Text(
+ tr('ui.userfront.app_title'),
+ style: const TextStyle(fontWeight: FontWeight.bold),
),
- IconButton(
- icon: const Icon(Icons.qr_code_scanner),
- tooltip: tr('ui.userfront.nav.qr_scan'),
- onPressed: _onScanQR,
- ),
- IconButton(
- icon: const Icon(Icons.logout),
- tooltip: tr('ui.userfront.nav.logout'),
- onPressed: _logout,
- ),
- ],
- ),
- drawer: isWide
- ? null
- : Drawer(child: _buildSideMenu(context, closeOnTap: true)),
- body: Row(
- children: [
- if (isWide)
- SizedBox(
- width: 240,
- child: _buildSideMenu(context, closeOnTap: false),
+ elevation: 0,
+ backgroundColor: _surface,
+ foregroundColor: Colors.black,
+ actions: [
+ IconButton(
+ icon: const Icon(Icons.person_outline),
+ tooltip: tr('ui.userfront.nav.profile'),
+ onPressed: () => context.push('/profile'),
),
- Expanded(
- child: RefreshIndicator(
- onRefresh: _refreshAll,
- child: LayoutBuilder(
- builder: (context, constraints) {
- final timelineWide = constraints.maxWidth >= 900;
- final isMobile = constraints.maxWidth < 600;
- return SingleChildScrollView(
- controller: _pageScrollController,
- physics: const AlwaysScrollableScrollPhysics(),
- child: Padding(
- padding: const EdgeInsets.all(24),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- if (!isMobile) ...[
- _buildHeaderCard(
- userName,
- department,
- sessionIssuedAt,
+ IconButton(
+ icon: const Icon(Icons.qr_code_scanner),
+ tooltip: tr('ui.userfront.nav.qr_scan'),
+ onPressed: _onScanQR,
+ ),
+ IconButton(
+ icon: const Icon(Icons.logout),
+ tooltip: tr('ui.userfront.nav.logout'),
+ onPressed: _logout,
+ ),
+ ],
+ ),
+ drawer: isWide
+ ? null
+ : Drawer(child: _buildSideMenu(context, closeOnTap: true)),
+ body: Row(
+ children: [
+ if (isWide)
+ SizedBox(
+ width: 240,
+ child: _buildSideMenu(context, closeOnTap: false),
+ ),
+ Expanded(
+ child: RefreshIndicator(
+ onRefresh: _refreshAll,
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ final timelineWide = constraints.maxWidth >= 900;
+ final isMobile = constraints.maxWidth < 600;
+ return SingleChildScrollView(
+ controller: _pageScrollController,
+ physics: const AlwaysScrollableScrollPhysics(),
+ child: Padding(
+ padding: const EdgeInsets.all(24),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (!isMobile) ...[
+ _buildHeaderCard(
+ userName,
+ department,
+ sessionIssuedAt,
+ ),
+ const SizedBox(height: 28),
+ ],
+ _buildSectionTitle(
+ tr('ui.userfront.sections.apps'),
+ tr('msg.userfront.sections.apps_subtitle'),
),
+ const SizedBox(height: 12),
+ _buildActivitySection(isMobile),
const SizedBox(height: 28),
+ _buildSectionTitle(
+ tr('ui.userfront.sections.audit'),
+ tr('msg.userfront.sections.audit_subtitle'),
+ ),
+ const SizedBox(height: 12),
+ _buildAccessHistory(timelineState, timelineWide),
],
- _buildSectionTitle(
- tr('ui.userfront.sections.apps'),
- tr('msg.userfront.sections.apps_subtitle'),
- ),
- const SizedBox(height: 12),
- _buildActivitySection(isMobile),
- const SizedBox(height: 28),
- _buildSectionTitle(
- tr('ui.userfront.sections.audit'),
- tr('msg.userfront.sections.audit_subtitle'),
- ),
- const SizedBox(height: 12),
- _buildAccessHistory(timelineState, timelineWide),
- ],
+ ),
),
- ),
- );
- },
+ );
+ },
+ ),
),
),
+ ],
+ ),
+ );
+ } catch (error, stackTrace) {
+ AuthProxyService.logError(
+ 'DASHBOARD_RENDER_ERROR: $error\nuri=${Uri.base}',
+ error: error,
+ stackTrace: stackTrace,
+ );
+ return Scaffold(
+ backgroundColor: _subtle,
+ body: Center(
+ child: Padding(
+ padding: const EdgeInsets.all(24),
+ child: Text(
+ tr(
+ 'msg.userfront.dashboard.render_error',
+ fallback: '대시보드 렌더링 중 오류가 발생했습니다. 다시 시도해 주세요.',
+ ),
+ textAlign: TextAlign.center,
+ ),
),
- ],
- ),
- );
+ ),
+ );
+ }
}
Widget _buildHeaderCard(
@@ -973,8 +1003,9 @@ class _DashboardScreenState extends ConsumerState {
normalizedStatus == 'active' || normalizedStatus == '';
final isRevoked = !isActiveInApi;
- final lastAuthLabel = rp.lastAuthenticatedAt != null
- ? _formatDateTime(rp.lastAuthenticatedAt!)
+ final lastAuthAt = rp.lastAuthenticatedAt;
+ final lastAuthLabel = lastAuthAt != null
+ ? _formatDateTime(lastAuthAt)
: tr('ui.userfront.dashboard.activity.linked');
final statusCode = isRevoked ? 'revoked' : 'active';
@@ -1004,8 +1035,10 @@ class _DashboardScreenState extends ConsumerState {
if (!aActive && bActive) return 1;
// 둘 다 활성이거나 둘 다 비활성인 경우 최근 인증순 내림차순
- if (a.lastAuthDateTime != null && b.lastAuthDateTime != null) {
- return b.lastAuthDateTime!.compareTo(a.lastAuthDateTime!);
+ final aLastAuth = a.lastAuthDateTime;
+ final bLastAuth = b.lastAuthDateTime;
+ if (aLastAuth != null && bLastAuth != null) {
+ return bLastAuth.compareTo(aLastAuth);
}
if (a.lastAuthDateTime != null) return -1;
if (b.lastAuthDateTime != null) return 1;
@@ -1045,7 +1078,7 @@ class _DashboardScreenState extends ConsumerState {
}
// 카드의 너비를 화면 너비에 맞춰 계산 (여백 고려)
- final double spacing = 12.0;
+ const spacing = 12.0;
final double cardWidth =
(maxWidth - (spacing * (crossAxisCount - 1))) / crossAxisCount;
@@ -1145,16 +1178,16 @@ class _DashboardScreenState extends ConsumerState {
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
- color: statusColor.withValues(alpha: 31),
+ color: statusColor,
borderRadius: BorderRadius.circular(999),
),
child: Text(
item.status == 'active'
- ? tr('ui.common.status.active')
+ ? tr('ui.userfront.dashboard.activity.linked')
: tr('ui.userfront.dashboard.status.revoked'),
- style: TextStyle(
+ style: const TextStyle(
fontSize: 11,
- color: statusColor,
+ color: Colors.white,
fontWeight: FontWeight.w600,
),
),
@@ -1244,8 +1277,9 @@ class _DashboardScreenState extends ConsumerState {
child: GestureDetector(
onTap: () async {
final messenger = ScaffoldMessenger.of(context);
- if (item.url != null && item.url!.isNotEmpty) {
- final uri = Uri.parse(item.url!);
+ final itemUrl = item.url;
+ if (itemUrl != null && itemUrl.isNotEmpty) {
+ final uri = Uri.parse(itemUrl);
final canOpen = await canLaunchUrl(uri);
if (!mounted) return;
if (canOpen) {
@@ -1568,7 +1602,8 @@ class _DashboardScreenState extends ConsumerState {
),
);
}
- if (state.nextCursor == null || state.nextCursor!.isEmpty) {
+ final nextCursor = state.nextCursor;
+ if (nextCursor == null || nextCursor.isEmpty) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
@@ -1581,7 +1616,8 @@ class _DashboardScreenState extends ConsumerState {
}
bool _isLoggedIn() {
- return AuthTokenStore.getToken() != null || AuthTokenStore.usesCookie();
+ final token = AuthTokenStore.getToken();
+ return (token != null && token.isNotEmpty) || AuthTokenStore.usesCookie();
}
void _redirectToSignin() {
@@ -1593,13 +1629,60 @@ class _DashboardScreenState extends ConsumerState {
if (!mounted) {
return;
}
- final uri = GoRouterState.of(context).uri;
+ Uri uri;
+ try {
+ uri = GoRouterState.of(context).uri;
+ } catch (_) {
+ uri = Uri.base;
+ }
final localeCode =
extractLocaleFromPath(uri) ?? resolvePreferredLocaleCode();
context.go('/$localeCode/signin');
_redirectingToSignin = false;
});
}
+
+ Future _bootstrapAuthAndLoad() async {
+ if (!mounted || _authBootstrapInProgress) {
+ return;
+ }
+ _authBootstrapInProgress = true;
+ try {
+ var authenticated = _isLoggedIn();
+ if (!authenticated) {
+ authenticated = await _recoverSessionFromCookie();
+ }
+ if (!mounted) {
+ return;
+ }
+ if (!authenticated) {
+ _redirectToSignin();
+ return;
+ }
+ await _loadAuditLogs(reset: true);
+ } finally {
+ _authBootstrapInProgress = false;
+ }
+ }
+
+ Future _recoverSessionFromCookie() async {
+ try {
+ await AuthProxyService.checkCookieSession();
+ final provider =
+ AuthTokenStore.getProvider() ??
+ AuthTokenStore.getPendingProvider() ??
+ 'ory';
+ AuthTokenStore.setCookieMode(provider: provider);
+ AuthTokenStore.clearPendingProvider();
+ AuthNotifier.instance.notify();
+ try {
+ await ref.read(profileProvider.notifier).loadProfile();
+ } catch (_) {}
+ return true;
+ } catch (_) {
+ return false;
+ }
+ }
}
class _ActivityItem {
diff --git a/userfront/lib/features/profile/presentation/pages/profile_page.dart b/userfront/lib/features/profile/presentation/pages/profile_page.dart
index 5c06da38..861a7b88 100644
--- a/userfront/lib/features/profile/presentation/pages/profile_page.dart
+++ b/userfront/lib/features/profile/presentation/pages/profile_page.dart
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:userfront/i18n.dart';
import '../../../../core/notifiers/auth_notifier.dart';
+import '../../../../core/i18n/locale_utils.dart';
import '../../../../core/services/auth_token_store.dart';
import '../../../../core/ui/layout_breakpoints.dart';
import '../../../../core/widgets/language_selector.dart';
@@ -509,7 +510,7 @@ class _ProfilePageState extends ConsumerState {
ListTile(
leading: const Icon(Icons.home_outlined),
title: Text(tr('ui.userfront.nav.dashboard')),
- onTap: () => context.go('/'),
+ onTap: () => context.go(buildLocalizedHomePath(Uri.base)),
),
ListTile(
leading: const Icon(Icons.person_outline),
@@ -1092,7 +1093,7 @@ class _ProfilePageState extends ConsumerState {
IconButton(
icon: const Icon(Icons.home_outlined),
tooltip: tr('ui.userfront.nav.dashboard'),
- onPressed: () => context.go('/'),
+ onPressed: () => context.go(buildLocalizedHomePath(Uri.base)),
),
IconButton(
icon: const Icon(Icons.qr_code_scanner),
diff --git a/userfront/lib/main.dart b/userfront/lib/main.dart
index 8e86bc67..89dd0a20 100644
--- a/userfront/lib/main.dart
+++ b/userfront/lib/main.dart
@@ -14,12 +14,15 @@ import 'features/auth/presentation/qr_scan_screen.dart';
import 'features/auth/presentation/forgot_password_screen.dart';
import 'features/auth/presentation/reset_password_screen.dart';
import 'features/auth/presentation/error_screen.dart';
+import 'features/auth/domain/login_link_route_policy.dart';
import 'features/dashboard/presentation/dashboard_screen.dart';
import 'features/admin/presentation/user_management_screen.dart';
import 'features/profile/presentation/pages/profile_page.dart';
import 'core/services/auth_proxy_service.dart';
import 'core/services/auth_token_store.dart';
import 'core/services/logger_service.dart';
+import 'core/services/null_check_recovery.dart';
+import 'core/services/web_window.dart';
import 'core/notifiers/auth_notifier.dart';
import 'core/i18n/locale_gate.dart';
import 'core/i18n/locale_registry.dart';
@@ -31,6 +34,29 @@ import 'i18n.dart';
final _log = Logger('Main');
+void _attemptRecoveryFromNullCheck({
+ required Object exception,
+ StackTrace? stackTrace,
+}) {
+ final uri = Uri.base;
+ final target = computeNullCheckRecoveryTarget(
+ exception: exception,
+ uri: uri,
+ preferredLocaleCode: resolvePreferredLocaleCode(),
+ );
+ if (target == null) {
+ return;
+ }
+ final path = uri.path;
+
+ AuthProxyService.logError(
+ 'RECOVERY_NAV_NULL_CHECK path=$path target=$target uri=$uri',
+ error: exception,
+ stackTrace: stackTrace,
+ );
+ webWindow.redirectTo(target);
+}
+
Future _loadBundledFonts() async {
const family = 'NotoSansKR';
final loader = FontLoader(family);
@@ -57,11 +83,16 @@ void main() async {
AuthProxyService.logError(
"FLUTTER_ERROR: ${details.exception}\n${details.stack}",
);
+ _attemptRecoveryFromNullCheck(
+ exception: details.exception,
+ stackTrace: details.stack,
+ );
};
PlatformDispatcher.instance.onError = (error, stack) {
_log.severe("PLATFORM_ERROR", error, stack);
AuthProxyService.logError("PLATFORM_ERROR: $error\n$stack");
+ _attemptRecoveryFromNullCheck(exception: error, stackTrace: stack);
return true;
};
@@ -107,6 +138,15 @@ final _router = GoRouter(
debugLogDiagnostics: !kReleaseMode,
refreshListenable: AuthNotifier.instance,
routes: [
+ GoRoute(
+ path: '/',
+ redirect: (context, state) {
+ return buildLocalizedHomePath(
+ state.uri,
+ preferredLocaleCode: resolvePreferredLocaleCode(),
+ );
+ },
+ ),
ShellRoute(
builder: (context, state, child) {
final localeCode =
@@ -116,10 +156,25 @@ final _router = GoRouter(
routes: [
GoRoute(
path: '/:locale',
- // Note: Removed direct builder here to prevent interference with sub-routes
+ redirect: (context, state) {
+ // /{locale} 진입은 화면 렌더링 없이 단일 목적지로만 보냅니다.
+ if (state.uri.pathSegments.length != 1) {
+ return null;
+ }
+ final rawLocale = state.pathParameters['locale'];
+ final localeCode = normalizeLocaleCode(rawLocale);
+ final token = AuthTokenStore.getToken();
+ final isLoggedIn =
+ (token != null && token.isNotEmpty) ||
+ AuthTokenStore.usesCookie();
+ if (!isLoggedIn) {
+ return buildSigninRedirectPath(localeCode, state.uri);
+ }
+ return '/$localeCode/dashboard';
+ },
routes: [
GoRoute(
- path: '', // Matches /:locale
+ path: 'dashboard',
builder: (context, state) {
return const DashboardScreen();
},
@@ -274,34 +329,23 @@ final _router = GoRouter(
(token != null && token.isNotEmpty) || AuthTokenStore.usesCookie();
final path = stripLocalePath(uri);
- // Precise public path detection
- final isPublicPath =
- path == '/signin' ||
- path == '/signup' ||
- path == '/login' ||
- path == '/registration' ||
- path == '/verify' ||
- path == '/verification' ||
- path.startsWith('/verify/') ||
- path == '/approve' ||
- path.startsWith('/ql/') ||
- path == '/forgot-password' ||
- path == '/recovery' ||
- path == '/reset-password' ||
- path == '/error' ||
- path == '/settings' ||
- path == '/consent' ||
- path.startsWith('/consent/') ||
- uri.path.contains('/consent');
+ final isPublicPath = isPublicAuthPath(path, uri);
if (isPublicPath) {
return null;
}
if (!isLoggedIn) {
+ if (path == '/') {
+ return '/$requestedLocale/signin';
+ }
return buildSigninRedirectPath(requestedLocale, uri);
}
+ if (path == '/') {
+ return '/$requestedLocale/dashboard';
+ }
+
return null;
},
);
@@ -311,11 +355,21 @@ class BaronSSOApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
+ final localization = EasyLocalization.of(context);
+ final supportedLocales =
+ localization?.supportedLocales ??
+ LocaleRegistry.supportedLocaleCodes
+ .map((code) => Locale(code))
+ .toList(growable: false);
+ final delegates = localization?.delegates ?? const [];
+ final locale =
+ localization?.currentLocale ?? Locale(resolvePreferredLocaleCode());
+
return MaterialApp.router(
title: tr('ui.userfront.app_title'),
- localizationsDelegates: context.localizationDelegates,
- supportedLocales: context.supportedLocales,
- locale: context.locale,
+ localizationsDelegates: delegates,
+ supportedLocales: supportedLocales,
+ locale: locale,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1A1F2C), // Dark Navy/Black base
diff --git a/userfront/pubspec.lock b/userfront/pubspec.lock
index 7a953009..fecd33f1 100644
--- a/userfront/pubspec.lock
+++ b/userfront/pubspec.lock
@@ -45,10 +45,10 @@ packages:
dependency: transitive
description:
name: characters
- sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
+ sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
- version: "1.4.1"
+ version: "1.4.0"
cli_config:
dependency: transitive
description:
@@ -268,6 +268,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
+ js:
+ dependency: transitive
+ description:
+ name: js
+ sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.7.2"
leak_tracker:
dependency: transitive
description:
@@ -320,18 +328,18 @@ packages:
dependency: transitive
description:
name: matcher
- sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
+ sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
- version: "0.12.18"
+ version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
- sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
+ sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
- version: "0.13.0"
+ version: "0.11.1"
meta:
dependency: transitive
description:
@@ -348,6 +356,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
+ mobile_scanner:
+ dependency: "direct main"
+ description:
+ name: mobile_scanner
+ sha256: c92c26bf2231695b6d3477c8dcf435f51e28f87b1745966b1fe4c47a286171ce
+ url: "https://pub.dev"
+ source: hosted
+ version: "7.2.0"
node_preamble:
dependency: transitive
description:
@@ -637,26 +653,26 @@ packages:
dependency: transitive
description:
name: test
- sha256: "54c516bbb7cee2754d327ad4fca637f78abfc3cbcc5ace83b3eda117e42cd71a"
+ sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
url: "https://pub.dev"
source: hosted
- version: "1.29.0"
+ version: "1.26.3"
test_api:
dependency: transitive
description:
name: test_api
- sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
+ sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
- version: "0.7.9"
+ version: "0.7.7"
test_core:
dependency: transitive
description:
name: test_core
- sha256: "394f07d21f0f2255ec9e3989f21e54d3c7dc0e6e9dbce160e5a9c1a6be0e2943"
+ sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
url: "https://pub.dev"
source: hosted
- version: "0.6.15"
+ version: "0.6.12"
toml:
dependency: "direct main"
description:
diff --git a/userfront/pubspec.yaml b/userfront/pubspec.yaml
index 35e46fe6..270c2fb4 100644
--- a/userfront/pubspec.yaml
+++ b/userfront/pubspec.yaml
@@ -44,6 +44,7 @@ dependencies:
logging: ^1.2.0
logger: ^2.0.0
qr_flutter: ^4.1.0
+ mobile_scanner: ^7.1.4
easy_localization: ^3.0.7
toml: ^0.15.0
web: ^1.1.0
diff --git a/userfront/test/cookie_session_policy_test.dart b/userfront/test/cookie_session_policy_test.dart
new file mode 100644
index 00000000..c927447c
--- /dev/null
+++ b/userfront/test/cookie_session_policy_test.dart
@@ -0,0 +1,40 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:userfront/features/auth/domain/cookie_session_policy.dart';
+
+void main() {
+ group('cookie_session_policy', () {
+ test('토큰이 없고 login_challenge도 없으면 cookie 승격 허용', () {
+ expect(
+ shouldPromoteCookieSession(currentToken: null, loginChallenge: null),
+ isTrue,
+ );
+ });
+
+ test('토큰이 이미 있으면 일반 로그인에서 cookie 승격 차단', () {
+ expect(
+ shouldPromoteCookieSession(
+ currentToken: 'existing-token',
+ loginChallenge: null,
+ ),
+ isFalse,
+ );
+ });
+
+ test('OIDC login_challenge가 있으면 token 존재 시에도 cookie 승격 허용', () {
+ expect(
+ shouldPromoteCookieSession(
+ currentToken: 'existing-token',
+ loginChallenge: 'lc_123',
+ ),
+ isTrue,
+ );
+ });
+
+ test('공백 토큰은 유효 토큰으로 간주하지 않음', () {
+ expect(
+ shouldPromoteCookieSession(currentToken: ' ', loginChallenge: null),
+ isTrue,
+ );
+ });
+ });
+}
diff --git a/userfront/test/dashboard_screen_smoke_test.dart b/userfront/test/dashboard_screen_smoke_test.dart
new file mode 100644
index 00000000..519bd003
--- /dev/null
+++ b/userfront/test/dashboard_screen_smoke_test.dart
@@ -0,0 +1,50 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:userfront/core/services/auth_token_store.dart';
+import 'package:userfront/features/dashboard/presentation/dashboard_screen.dart';
+
+void main() {
+ setUp(() {
+ AuthTokenStore.clear();
+ });
+
+ tearDown(() {
+ AuthTokenStore.clear();
+ });
+
+ testWidgets('대시보드는 로그인 토큰이 있으면 크래시 없이 기본 프레임을 렌더링한다', (tester) async {
+ final recordedErrors = [];
+ 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.devicePixelRatio = 1.0;
+ tester.view.physicalSize = const Size(1920, 1080);
+ addTearDown(tester.view.resetPhysicalSize);
+ addTearDown(tester.view.resetDevicePixelRatio);
+
+ AuthTokenStore.setToken('smoke-token', provider: 'ory');
+
+ await tester.pumpWidget(
+ const ProviderScope(child: MaterialApp(home: DashboardScreen())),
+ );
+ await tester.pump();
+
+ expect(find.byType(Scaffold), findsOneWidget);
+ final hasNullCheckCrash = recordedErrors.any(
+ (error) => error.exceptionAsString().contains(
+ 'Null check operator used on a null value',
+ ),
+ );
+ expect(hasNullCheckCrash, isFalse);
+ });
+}
diff --git a/userfront/test/locale_utils_test.dart b/userfront/test/locale_utils_test.dart
index 3137c41e..adb2be4f 100644
--- a/userfront/test/locale_utils_test.dart
+++ b/userfront/test/locale_utils_test.dart
@@ -127,5 +127,32 @@ void main() {
'/ko/signin?redirect_url=https%3A%2F%2Fa.example.com%2Fcb&redirect_uri=https%3A%2F%2Fb.example.com%2Fcb',
);
});
+
+ test('buildLocalizedHomePath keeps locale from uri', () {
+ expect(buildLocalizedHomePath(Uri.parse('/ko/signin')), '/ko/dashboard');
+ expect(buildLocalizedHomePath(Uri.parse('/en/profile')), '/en/dashboard');
+ });
+
+ test('buildLocalizedHomePath falls back to preferred locale', () {
+ expect(
+ buildLocalizedHomePath(Uri.parse('/signin'), preferredLocaleCode: 'ko'),
+ '/ko/dashboard',
+ );
+ });
+
+ test('buildLocalizedSigninPath keeps locale from uri', () {
+ expect(buildLocalizedSigninPath(Uri.parse('/ko')), '/ko/signin');
+ expect(buildLocalizedSigninPath(Uri.parse('/en/profile')), '/en/signin');
+ });
+
+ test('buildLocalizedSigninPath falls back to preferred locale', () {
+ expect(
+ buildLocalizedSigninPath(
+ Uri.parse('/profile'),
+ preferredLocaleCode: 'ko',
+ ),
+ '/ko/signin',
+ );
+ });
});
}
diff --git a/userfront/test/login_link_route_policy_test.dart b/userfront/test/login_link_route_policy_test.dart
new file mode 100644
index 00000000..d81c8aff
--- /dev/null
+++ b/userfront/test/login_link_route_policy_test.dart
@@ -0,0 +1,33 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:userfront/core/i18n/locale_registry.dart';
+import 'package:userfront/features/auth/domain/login_link_route_policy.dart';
+
+void main() {
+ group('login_link_route_policy', () {
+ setUp(() {
+ LocaleRegistry.setSupportedLocaleCodesForTest(['ko', 'en']);
+ });
+
+ tearDown(() {
+ LocaleRegistry.resetForTest();
+ });
+
+ test('extracts short code from plain short-code route', () {
+ final shortCode = extractLoginShortCode(Uri.parse('/l/AB123456'));
+ expect(shortCode, 'AB123456');
+ });
+
+ test('extracts short code from localized short-code route', () {
+ final shortCode = extractLoginShortCode(Uri.parse('/ko/l/AB123456'));
+ expect(shortCode, 'AB123456');
+ });
+
+ test('treats localized short-code route as public path', () {
+ final isPublic = isPublicAuthPath(
+ '/l/AB123456',
+ Uri.parse('/ko/l/AB123456'),
+ );
+ expect(isPublic, isTrue);
+ });
+ });
+}
diff --git a/userfront/test/login_navigation_race_test.dart b/userfront/test/login_navigation_race_test.dart
new file mode 100644
index 00000000..7d55bc5f
--- /dev/null
+++ b/userfront/test/login_navigation_race_test.dart
@@ -0,0 +1,94 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:go_router/go_router.dart';
+import 'package:userfront/core/i18n/locale_registry.dart';
+import 'package:userfront/core/i18n/locale_utils.dart';
+import 'package:userfront/core/services/auth_token_store.dart';
+
+class _AuthRefreshNotifier extends ChangeNotifier {
+ void refresh() => notifyListeners();
+}
+
+Widget _buildRaceTestApp(_AuthRefreshNotifier notifier) {
+ final router = GoRouter(
+ initialLocation: '/ko/signin',
+ refreshListenable: notifier,
+ routes: [
+ GoRoute(
+ path: '/:locale',
+ builder: (context, state) => const Scaffold(body: Text('locale-root')),
+ routes: [
+ GoRoute(
+ path: 'dashboard',
+ builder: (context, state) => const Scaffold(body: Text('home')),
+ ),
+ GoRoute(
+ path: 'signin',
+ builder: (context, state) {
+ return Scaffold(
+ body: Center(
+ child: FilledButton(
+ onPressed: () {
+ AuthTokenStore.setToken('race-token', provider: 'ory');
+ notifier.refresh();
+ context.go('/ko/dashboard');
+ },
+ child: const Text('login'),
+ ),
+ ),
+ );
+ },
+ ),
+ ],
+ ),
+ ],
+ redirect: (context, state) {
+ final requestedLocale = extractLocaleFromPath(state.uri);
+ if (requestedLocale == null) {
+ return buildLocalizedPath(resolvePreferredLocaleCode(), state.uri);
+ }
+
+ final token = AuthTokenStore.getToken();
+ final isLoggedIn =
+ (token != null && token.isNotEmpty) || AuthTokenStore.usesCookie();
+ final path = stripLocalePath(state.uri);
+ if (path == '/signin') {
+ return null;
+ }
+ if (!isLoggedIn) {
+ return buildSigninRedirectPath(requestedLocale, state.uri);
+ }
+ if (path == '/') {
+ return '/$requestedLocale/dashboard';
+ }
+ return null;
+ },
+ );
+
+ return MaterialApp.router(routerConfig: router);
+}
+
+void main() {
+ setUp(() {
+ LocaleRegistry.setSupportedLocaleCodesForTest(['en', 'ko']);
+ AuthTokenStore.clear();
+ });
+
+ tearDown(() {
+ AuthTokenStore.clear();
+ LocaleRegistry.resetForTest();
+ });
+
+ testWidgets('로그인 성공 이벤트(notify + go) 동시 호출 시 홈으로 안정적으로 이동', (tester) async {
+ final notifier = _AuthRefreshNotifier();
+ await tester.pumpWidget(_buildRaceTestApp(notifier));
+ await tester.pumpAndSettle();
+ expect(find.text('login'), findsOneWidget);
+
+ await tester.tap(find.text('login'));
+ await tester.pumpAndSettle();
+
+ expect(find.text('home'), findsOneWidget);
+ expect(tester.takeException(), isNull);
+ });
+}
diff --git a/userfront/test/null_check_recovery_test.dart b/userfront/test/null_check_recovery_test.dart
new file mode 100644
index 00000000..9b7dffed
--- /dev/null
+++ b/userfront/test/null_check_recovery_test.dart
@@ -0,0 +1,63 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:userfront/core/i18n/locale_registry.dart';
+import 'package:userfront/core/services/null_check_recovery.dart';
+
+void main() {
+ setUp(() {
+ LocaleRegistry.setSupportedLocaleCodesForTest(['en', 'ko']);
+ });
+
+ tearDown(() {
+ LocaleRegistry.resetForTest();
+ });
+
+ test('Null check 오류 + 루트(/)면 선호 로케일 signin으로 복구', () {
+ final target = computeNullCheckRecoveryTarget(
+ exception: Exception('Null check operator used on a null value'),
+ uri: Uri.parse('https://sss.hmac.kr/'),
+ preferredLocaleCode: 'ko',
+ );
+
+ expect(target, '/ko/signin');
+ });
+
+ test('Null check 오류 + /ko면 /ko/signin으로 복구', () {
+ final target = computeNullCheckRecoveryTarget(
+ exception: Exception('Null check operator used on a null value'),
+ uri: Uri.parse('https://sss.hmac.kr/ko'),
+ preferredLocaleCode: 'en',
+ );
+
+ expect(target, '/ko/signin');
+ });
+
+ test('이미 /ko/signin이면 복구 이동하지 않음', () {
+ final target = computeNullCheckRecoveryTarget(
+ exception: Exception('Null check operator used on a null value'),
+ uri: Uri.parse('https://sss.hmac.kr/ko/signin'),
+ preferredLocaleCode: 'ko',
+ );
+
+ expect(target, isNull);
+ });
+
+ test('Null check 오류여도 /ko/profile에서는 복구 이동하지 않음', () {
+ final target = computeNullCheckRecoveryTarget(
+ exception: Exception('Null check operator used on a null value'),
+ uri: Uri.parse('https://sss.hmac.kr/ko/profile'),
+ preferredLocaleCode: 'ko',
+ );
+
+ expect(target, isNull);
+ });
+
+ test('다른 오류 메시지면 복구 이동하지 않음', () {
+ final target = computeNullCheckRecoveryTarget(
+ exception: Exception('Some other error'),
+ uri: Uri.parse('https://sss.hmac.kr/ko'),
+ preferredLocaleCode: 'ko',
+ );
+
+ expect(target, isNull);
+ });
+}
diff --git a/userfront/test/qr_camera_bootstrap_policy_test.dart b/userfront/test/qr_camera_bootstrap_policy_test.dart
new file mode 100644
index 00000000..0f265ac6
--- /dev/null
+++ b/userfront/test/qr_camera_bootstrap_policy_test.dart
@@ -0,0 +1,67 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:userfront/features/auth/presentation/qr_camera_bootstrap_policy.dart';
+
+void main() {
+ group('bootstrapQrCamera', () {
+ test('권한 허용 후 카메라 실행 성공 시 ready 상태를 반환한다', () async {
+ var stopCalled = false;
+
+ final result = await bootstrapQrCamera(
+ hasBarcodeDetector: true,
+ openCameraAndPlay: () async {},
+ stopCamera: () async {
+ stopCalled = true;
+ },
+ );
+
+ expect(result.status, QrCameraBootstrapStatus.ready);
+ expect(stopCalled, isFalse);
+ });
+
+ test('권한 허용 후 play 단계 오류는 cameraError로 분류한다', () async {
+ var stopCalled = false;
+
+ final result = await bootstrapQrCamera(
+ hasBarcodeDetector: true,
+ openCameraAndPlay: () async {
+ throw Exception('NotReadableError: Could not start video source');
+ },
+ stopCamera: () async {
+ stopCalled = true;
+ },
+ );
+
+ expect(result.status, QrCameraBootstrapStatus.cameraError);
+ expect(result.errorDetail, contains('NotReadableError'));
+ expect(stopCalled, isFalse);
+ });
+
+ test('권한 거부 오류는 permissionError로 분류한다', () async {
+ final result = await bootstrapQrCamera(
+ hasBarcodeDetector: true,
+ openCameraAndPlay: () async {
+ throw Exception('NotAllowedError: Permission denied');
+ },
+ stopCamera: () async {},
+ );
+
+ expect(result.status, QrCameraBootstrapStatus.permissionError);
+ expect(result.errorDetail, contains('NotAllowedError'));
+ });
+
+ test('detector 미지원이면 카메라를 정리하고 detectorUnsupported를 반환한다', () async {
+ var stopCalled = false;
+
+ final result = await bootstrapQrCamera(
+ hasBarcodeDetector: false,
+ openCameraAndPlay: () async {},
+ stopCamera: () async {
+ stopCalled = true;
+ },
+ );
+
+ expect(result.status, QrCameraBootstrapStatus.detectorUnsupported);
+ expect(stopCalled, isTrue);
+ });
+ });
+}
diff --git a/userfront/test/qr_scan_route_test.dart b/userfront/test/qr_scan_route_test.dart
new file mode 100644
index 00000000..db0a4e4c
--- /dev/null
+++ b/userfront/test/qr_scan_route_test.dart
@@ -0,0 +1,27 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:userfront/features/auth/presentation/qr_scan_route.dart';
+
+void main() {
+ group('buildQrApprovePath', () {
+ test('스캔 값을 trim/encode 해서 approve 경로를 만든다', () {
+ final result = buildQrApprovePath(
+ ' https://sss.hmac.kr/ql/abc-123?x=1&y=2 ',
+ localeCode: 'ko',
+ );
+
+ expect(
+ result,
+ '/ko/approve?ref=https%3A%2F%2Fsss.hmac.kr%2Fql%2Fabc-123%3Fx%3D1%26y%3D2',
+ );
+ });
+
+ test('현재 URI에서 locale을 추출한다', () {
+ final result = buildQrApprovePath(
+ 'abc123',
+ currentUri: Uri.parse('https://sss.hmac.kr/en/dashboard'),
+ );
+
+ expect(result, '/en/approve?ref=abc123');
+ });
+ });
+}
diff --git a/userfront/test/qr_scan_screen_test.dart b/userfront/test/qr_scan_screen_test.dart
new file mode 100644
index 00000000..fa3e31f2
--- /dev/null
+++ b/userfront/test/qr_scan_screen_test.dart
@@ -0,0 +1,16 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:userfront/features/auth/presentation/qr_scan_screen.dart';
+
+void main() {
+ testWidgets('QR 스캔 화면은 비활성 문구 대신 입력/이동 UI를 노출한다', (tester) async {
+ await tester.pumpWidget(const MaterialApp(home: QRScanScreen()));
+
+ expect(
+ find.text('QR Scanner is temporarily disabled for WASM build stability.'),
+ findsNothing,
+ );
+ expect(find.byKey(const ValueKey('qr_scan_manual_input')), findsOneWidget);
+ expect(find.byKey(const ValueKey('qr_scan_submit_button')), findsOneWidget);
+ });
+}
diff --git a/userfront/test/router_redirect_widget_test.dart b/userfront/test/router_redirect_widget_test.dart
index a11580f8..87c0f8e3 100644
--- a/userfront/test/router_redirect_widget_test.dart
+++ b/userfront/test/router_redirect_widget_test.dart
@@ -11,8 +11,28 @@ Widget _buildTestApp(String initialLocation) {
routes: [
GoRoute(
path: '/:locale',
- builder: (context, state) => const Scaffold(body: Text('root')),
+ redirect: (context, state) {
+ if (state.uri.pathSegments.length != 1) {
+ return null;
+ }
+ final localeCode = normalizeLocaleCode(
+ state.pathParameters['locale'],
+ );
+ final token = AuthTokenStore.getToken();
+ final isLoggedIn =
+ (token != null && token.isNotEmpty) ||
+ AuthTokenStore.usesCookie();
+ if (!isLoggedIn) {
+ return buildSigninRedirectPath(localeCode, state.uri);
+ }
+ return '/$localeCode/dashboard';
+ },
routes: [
+ GoRoute(
+ path: 'dashboard',
+ builder: (context, state) =>
+ const Scaffold(body: Text('dashboard-page')),
+ ),
GoRoute(
path: 'signin',
builder: (context, state) {
@@ -57,8 +77,9 @@ Widget _buildTestApp(String initialLocation) {
return buildLocalizedPath(resolvePreferredLocaleCode(), state.uri);
}
+ final token = AuthTokenStore.getToken();
final isLoggedIn =
- AuthTokenStore.getToken() != null || AuthTokenStore.usesCookie();
+ (token != null && token.isNotEmpty) || AuthTokenStore.usesCookie();
final path = stripLocalePath(state.uri);
final isPublicPath = path == '/signin' || path == '/login';
if (isPublicPath) {
@@ -85,6 +106,25 @@ void main() {
LocaleRegistry.resetForTest();
});
+ testWidgets(
+ '루트 경로: /{locale} 로 접근 시 /{locale}/signin 으로 리다이렉트되어야 한다 (버그: 화면 렌더링 안됨)',
+ (tester) async {
+ await tester.pumpWidget(_buildTestApp('/ko'));
+ await tester.pumpAndSettle();
+
+ expect(find.textContaining('signin|'), findsOneWidget);
+ },
+ );
+
+ testWidgets('로그인 상태에서 /{locale} 접근 시 dashboard로 이동', (tester) async {
+ AuthTokenStore.setToken('root-token', provider: 'ory');
+ await tester.pumpWidget(_buildTestApp('/ko'));
+ await tester.pumpAndSettle();
+
+ expect(find.text('dashboard-page'), findsOneWidget);
+ expect(find.textContaining('signin|'), findsNothing);
+ });
+
testWidgets('/login: login_challenge와 redirect_uri를 전달', (tester) async {
final encodedRedirectUri = Uri.encodeComponent(
'https://rp.example.com/callback?x=1',
@@ -153,6 +193,15 @@ void main() {
expect(find.textContaining('signin|'), findsNothing);
});
+ testWidgets('빈 토큰은 로그인으로 간주하지 않고 signin으로 리다이렉트', (tester) async {
+ AuthTokenStore.setToken('', provider: 'ory');
+ await tester.pumpWidget(_buildTestApp('/ko/profile'));
+ await tester.pumpAndSettle();
+
+ expect(find.textContaining('signin|'), findsOneWidget);
+ expect(find.text('profile-page'), findsNothing);
+ });
+
testWidgets('로그인 후 같은 브라우저 새 창/팝업에서도 세션이 유지된다', (tester) async {
await tester.pumpWidget(_buildTestApp('/en/signin'));
await tester.pumpAndSettle();
diff --git a/userfront/web/favicon.ico b/userfront/web/favicon.ico
new file mode 100644
index 00000000..e79c6fce
Binary files /dev/null and b/userfront/web/favicon.ico differ
diff --git a/userfront/web/favicon.png b/userfront/web/favicon.png
index 8aaa46ac..4f911e2e 100644
Binary files a/userfront/web/favicon.png and b/userfront/web/favicon.png differ
diff --git a/userfront/web/icons/Icon-192.png b/userfront/web/icons/Icon-192.png
index b749bfef..b0a62485 100644
Binary files a/userfront/web/icons/Icon-192.png and b/userfront/web/icons/Icon-192.png differ
diff --git a/userfront/web/icons/Icon-512.png b/userfront/web/icons/Icon-512.png
index 88cfd48d..07aba7c2 100644
Binary files a/userfront/web/icons/Icon-512.png and b/userfront/web/icons/Icon-512.png differ
diff --git a/userfront/web/icons/Icon-maskable-192.png b/userfront/web/icons/Icon-maskable-192.png
index eb9b4d76..b0a62485 100644
Binary files a/userfront/web/icons/Icon-maskable-192.png and b/userfront/web/icons/Icon-maskable-192.png differ
diff --git a/userfront/web/icons/Icon-maskable-512.png b/userfront/web/icons/Icon-maskable-512.png
index d69c5669..07aba7c2 100644
Binary files a/userfront/web/icons/Icon-maskable-512.png and b/userfront/web/icons/Icon-maskable-512.png differ
diff --git a/userfront/web/index.html b/userfront/web/index.html
index 8a4cbe6a..288bc054 100644
--- a/userfront/web/index.html
+++ b/userfront/web/index.html
@@ -27,7 +27,7 @@
-
+
Baron 로그인