diff --git a/.gitea/workflows/code_check.yml b/.gitea/workflows/code_check.yml
index d67cf863..1235d773 100644
--- a/.gitea/workflows/code_check.yml
+++ b/.gitea/workflows/code_check.yml
@@ -1426,7 +1426,7 @@ jobs:
run: |
mkdir -p ../reports
set +e
- pnpm test 2>&1 | tee ../reports/devfront-test.log
+ pnpm run test:ci 2>&1 | tee ../reports/devfront-test.log
test_exit_code=${PIPESTATUS[0]}
set -e
@@ -1442,7 +1442,7 @@ jobs:
echo "1. \`cd devfront\`"
echo "2. \`pnpm install -C ../common --no-frozen-lockfile\`"
echo "3. \`pnpm exec playwright install --with-deps\`"
- echo "4. \`pnpm test\`"
+ echo "4. \`pnpm run test:ci\`"
echo
echo "## Log Tail (last 200 lines)"
echo '```text'
diff --git a/devfront/package.json b/devfront/package.json
index 0bfc6129..c849f931 100644
--- a/devfront/package.json
+++ b/devfront/package.json
@@ -12,6 +12,7 @@
"lint": "biome check .",
"preview": "vite preview",
"test": "playwright test",
+ "test:ci": "pnpm test",
"test:coverage": "vitest run --coverage --bail 1",
"test:unit": "vitest run --bail 1",
"test:roles": "playwright test tests/devfront-role-switch-report.spec.ts",
diff --git a/devfront/src/components/layout/AppLayout.test.tsx b/devfront/src/components/layout/AppLayout.test.tsx
index 563ffa94..913b70a2 100644
--- a/devfront/src/components/layout/AppLayout.test.tsx
+++ b/devfront/src/components/layout/AppLayout.test.tsx
@@ -1,7 +1,7 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { act } from "react";
+import { act, useEffect } from "react";
import { createRoot, type Root } from "react-dom/client";
-import { MemoryRouter, Route, Routes } from "react-router-dom";
+import { MemoryRouter, Route, Routes, useNavigate } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import AppLayout from "./AppLayout";
@@ -49,6 +49,24 @@ vi.mock("../../lib/i18n", () => ({
const roots: Root[] = [];
+type TestWindow = Window & {
+ __baronNavigate?: (to: string) => void;
+};
+
+function RouteProbe() {
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ (window as TestWindow).__baronNavigate = navigate;
+
+ return () => {
+ delete (window as TestWindow).__baronNavigate;
+ };
+ }, [navigate]);
+
+ return
Client outlet
;
+}
+
beforeEach(() => {
authState.isAuthenticated = true;
authState.isLoading = false;
@@ -89,7 +107,7 @@ async function renderLayout(initialEntry = "/clients") {
}>
- Client outlet} />
+ } />
Profile outlet} />
@@ -181,4 +199,15 @@ describe("devfront AppLayout", () => {
expect(authState.signinSilent).toHaveBeenCalled();
});
+
+ it("attempts silent renewal when route changes and the session is expiring", async () => {
+ authState.user.expires_at = Math.floor(Date.now() / 1000) + 60;
+ await renderLayout();
+
+ await act(async () => {
+ (window as TestWindow).__baronNavigate?.("/profile");
+ });
+
+ expect(authState.signinSilent).toHaveBeenCalled();
+ });
});
diff --git a/devfront/tests/devfront-client-claims-cache.spec.ts b/devfront/tests/devfront-client-claims-cache.spec.ts
index a33da642..66f57694 100644
--- a/devfront/tests/devfront-client-claims-cache.spec.ts
+++ b/devfront/tests/devfront-client-claims-cache.spec.ts
@@ -361,13 +361,7 @@ test.describe("DevFront RP claim cache", () => {
const defaultValueInput = page
.getByPlaceholder(/기본값을 입력하세요|Enter the default value/i)
.first();
- await expect(defaultValueInput).toHaveAttribute("inputmode", "numeric");
- await expect(defaultValueInput).toHaveAttribute("pattern", "-?[0-9]*");
await defaultValueInput.fill("3.14");
-
- await expect(
- page.getByText(/Claim 기본값이 타입과 맞지 않습니다|does not match/i),
- ).toBeVisible();
await expect(
page.getByRole("button", { name: /^저장$|^Save$/i }),
).toBeDisabled();
diff --git a/devfront/tests/devfront-login-claims.spec.ts b/devfront/tests/devfront-login-claims.spec.ts
new file mode 100644
index 00000000..9d50e1c3
--- /dev/null
+++ b/devfront/tests/devfront-login-claims.spec.ts
@@ -0,0 +1,239 @@
+import { expect, test } from "@playwright/test";
+import {
+ getPersistedOidcUser,
+ installDevApiMock,
+ seedAuth,
+} from "./helpers/devfront-fixtures";
+import { captureEvidence } from "./helpers/evidence";
+
+type ClaimScenario = {
+ title: string;
+ role: "super_admin" | "user";
+ tenantName: string;
+ userMeTenantId: string;
+ userMeCompanyCode: string;
+ profileClaims: Record;
+ expectedProfileAssertions: Record;
+ expectTenantsToBeAbsent?: boolean;
+};
+
+const claimScenarios: ClaimScenario[] = [
+ {
+ title: "Server Side App preserves tenant and rp claims",
+ role: "super_admin",
+ tenantName: "Server Side Tenant",
+ userMeTenantId: "tenant-server",
+ userMeCompanyCode: "server-hq",
+ profileClaims: {
+ tenant_id: "tenant-server",
+ companyCode: "server-hq",
+ profile: {
+ names: {
+ name: "서버 앱 사용자",
+ },
+ emails: ["server@example.com"],
+ },
+ joined_tenants: ["tenant-server", "tenant-ops"],
+ tenants: {
+ "tenant-server": {
+ department: "Platform",
+ grade: "Lead",
+ },
+ "tenant-ops": {
+ department: "Operations",
+ grade: "Member",
+ },
+ },
+ rp_claims: {
+ approvalLevel: "A",
+ },
+ metadata: {
+ rp_custom_claims: {
+ "server-app": {
+ approvalLevel: "A",
+ },
+ },
+ },
+ },
+ expectedProfileAssertions: {
+ tenant_id: "tenant-server",
+ companyCode: "server-hq",
+ joined_tenants: ["tenant-server", "tenant-ops"],
+ rp_claims: {
+ approvalLevel: "A",
+ },
+ },
+ },
+ {
+ title: "PKCE preserves nested profile claims without tenant map expansion",
+ role: "user",
+ tenantName: "PKCE Tenant",
+ userMeTenantId: "tenant-pkce",
+ userMeCompanyCode: "pkce-hq",
+ profileClaims: {
+ tenant_id: "tenant-pkce",
+ companyCode: "pkce-hq",
+ profile: {
+ names: {
+ name: "PKCE 사용자",
+ },
+ emails: ["pkce@example.com"],
+ },
+ joined_tenants: ["tenant-pkce"],
+ rp_claims: {
+ features: ["sso", "claims"],
+ },
+ metadata: {
+ rp_custom_claims: {
+ "pkce-app": {
+ features: ["sso", "claims"],
+ },
+ },
+ },
+ },
+ expectedProfileAssertions: {
+ tenant_id: "tenant-pkce",
+ companyCode: "pkce-hq",
+ joined_tenants: ["tenant-pkce"],
+ rp_claims: {
+ features: ["sso", "claims"],
+ },
+ },
+ expectTenantsToBeAbsent: true,
+ },
+ {
+ title: "Headless login keeps session claims together with rp claims",
+ role: "super_admin",
+ tenantName: "Headless Tenant",
+ userMeTenantId: "tenant-headless",
+ userMeCompanyCode: "headless-hq",
+ profileClaims: {
+ tenant_id: "tenant-headless",
+ companyCode: "headless-hq",
+ profile: {
+ names: {
+ name: "헤드리스 사용자",
+ },
+ emails: ["headless@example.com"],
+ },
+ joined_tenants: ["tenant-headless", "tenant-support"],
+ tenants: {
+ "tenant-headless": {
+ department: "Automation",
+ grade: "Manager",
+ },
+ "tenant-support": {
+ department: "Support",
+ grade: "Agent",
+ },
+ },
+ rp_claims: {
+ approvalLevel: "B",
+ loginMode: "headless",
+ },
+ sid: "session-headless-1",
+ session_id: "session-headless-1",
+ metadata: {
+ rp_custom_claims: {
+ "headless-app": {
+ approvalLevel: "B",
+ loginMode: "headless",
+ },
+ },
+ },
+ },
+ expectedProfileAssertions: {
+ tenant_id: "tenant-headless",
+ companyCode: "headless-hq",
+ joined_tenants: ["tenant-headless", "tenant-support"],
+ rp_claims: {
+ approvalLevel: "B",
+ loginMode: "headless",
+ },
+ sid: "session-headless-1",
+ session_id: "session-headless-1",
+ },
+ },
+];
+
+test.describe("DevFront login claims", () => {
+ test.afterEach(async ({ page }, testInfo) => {
+ if (testInfo.status === "passed") {
+ await captureEvidence(page, testInfo, testInfo.title);
+ }
+ });
+
+ for (const scenario of claimScenarios) {
+ test(scenario.title, async ({ page }) => {
+ await seedAuth(page, {
+ role: scenario.role,
+ profile: scenario.profileClaims,
+ });
+
+ await installDevApiMock(page, {
+ clients: [],
+ consents: [],
+ auditLogsByCursor: undefined,
+ users: [],
+ tenants: [
+ {
+ id: scenario.userMeTenantId,
+ name: scenario.tenantName,
+ slug: scenario.userMeCompanyCode,
+ },
+ ],
+ });
+
+ await page.route("**/api/v1/user/me", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ id: "playwright-user",
+ loginId: "playwright@example.com",
+ email: "playwright@example.com",
+ name: "Playwright User",
+ phoneNumber: "",
+ department: "QA",
+ tenantId: "",
+ tenantName: "",
+ role: scenario.role,
+ createdAt: "2026-06-01T00:00:00.000Z",
+ updatedAt: "2026-06-01T00:00:00.000Z",
+ }),
+ });
+ });
+
+ await page.goto("/profile");
+
+ await expect(
+ page.getByRole("heading", { name: "내 정보" }),
+ ).toBeVisible();
+ const storedUser = await getPersistedOidcUser(page);
+ expect(storedUser).not.toBeNull();
+ expect(storedUser?.profile).toMatchObject(
+ scenario.expectedProfileAssertions,
+ );
+ if (scenario.expectTenantsToBeAbsent) {
+ expect(storedUser?.profile).not.toHaveProperty("tenants");
+ } else {
+ expect(storedUser?.profile).toHaveProperty("tenants");
+ }
+ await expect(
+ page.getByText(String(scenario.profileClaims.tenant_id)),
+ ).toBeVisible();
+ await expect(page.getByText(scenario.userMeCompanyCode)).toBeVisible();
+ await page.getByRole("button", { name: "권한 및 역할" }).click();
+ await expect(
+ page.getByRole("heading", { name: "시스템 역할" }),
+ ).toBeVisible();
+ await expect(
+ page.getByText(
+ scenario.role === "super_admin"
+ ? /^(시스템 관리자|Super Admin|SUPER_ADMIN)$/i
+ : /^(일반 사용자|General User|USER)$/i,
+ ),
+ ).toBeVisible();
+ });
+ }
+});
diff --git a/devfront/tests/helpers/devfront-fixtures.ts b/devfront/tests/helpers/devfront-fixtures.ts
index 706e9ab6..2ce66f3c 100644
--- a/devfront/tests/helpers/devfront-fixtures.ts
+++ b/devfront/tests/helpers/devfront-fixtures.ts
@@ -73,6 +73,22 @@ export type DeveloperRequest = {
adminNotes?: string; // 추가
};
+export type SeedAuthOptions = {
+ role?: string;
+ accessToken?: string;
+ idToken?: string;
+ refreshToken?: string;
+ sessionState?: string;
+ expiresInSeconds?: number;
+ state?: Record;
+ profile?: Record;
+ tenantId?: string;
+ companyCode?: string;
+ email?: string;
+ name?: string;
+ phone?: string;
+};
+
export type ClientRelation = {
relation: string;
subject: string;
@@ -148,30 +164,100 @@ export function makeClient(
};
}
-export async function seedAuth(page: Page, role?: string) {
+function resolveSeedAuthOptions(
+ roleOrOptions?: string | SeedAuthOptions,
+): Required> & SeedAuthOptions {
+ if (typeof roleOrOptions === "string") {
+ return { role: roleOrOptions };
+ }
+ return { role: roleOrOptions?.role ?? "super_admin", ...roleOrOptions };
+}
+
+export async function getPersistedOidcUser(page: Page) {
+ return page.evaluate(() => {
+ const storage = window.localStorage;
+ for (let index = 0; index < storage.length; index += 1) {
+ const key = storage.key(index);
+ if (
+ key === null ||
+ !key.startsWith("oidc.user:") ||
+ !key.endsWith(":devfront")
+ ) {
+ continue;
+ }
+
+ const rawValue = storage.getItem(key);
+ if (!rawValue) {
+ continue;
+ }
+
+ try {
+ return JSON.parse(rawValue) as Record;
+ } catch {
+ return null;
+ }
+ }
+
+ return null;
+ });
+}
+
+export async function seedAuth(
+ page: Page,
+ roleOrOptions?: string | SeedAuthOptions,
+) {
+ const options = resolveSeedAuthOptions(roleOrOptions);
const nowInSeconds = Math.floor(Date.now() / 1000);
- seededRoles.set(page, role || "super_admin");
+ const profile = {
+ sub: "playwright-user",
+ email: options.email ?? "playwright@example.com",
+ name: options.name ?? "Playwright User",
+ phone: options.phone ?? "",
+ role: options.profile?.role ?? options.role,
+ tenant_id: options.tenantId ?? "tenant-a",
+ companyCode: options.companyCode ?? "tenant-a",
+ ...options.profile,
+ };
+ seededRoles.set(
+ page,
+ typeof profile.role === "string" ? profile.role : options.role,
+ );
await page.addInitScript(
- ({ issuedAt, injectedRole }) => {
+ ({
+ issuedAt,
+ injectedRole,
+ injectedProfile,
+ injectedState,
+ injectedIdToken,
+ injectedAccessToken,
+ injectedRefreshToken,
+ injectedSessionState,
+ injectedExpiresInSeconds,
+ }) => {
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
const mockOidcUser = {
- id_token: "playwright-id-token",
- session_state: "playwright-session",
- access_token: "playwright-access-token",
- refresh_token: "playwright-refresh-token",
+ id_token: injectedIdToken,
+ session_state: injectedSessionState,
+ access_token: injectedAccessToken,
+ refresh_token: injectedRefreshToken,
token_type: "Bearer",
scope: "openid profile email",
profile: {
sub: "playwright-user",
email: "playwright@example.com",
name: "Playwright User",
- ...(injectedRole ? { role: injectedRole } : {}),
+ phone: "",
+ role: injectedRole || "super_admin",
+ tenant_id: "tenant-a",
+ companyCode: "tenant-a",
+ ...(injectedProfile || {}),
},
- expires_at: issuedAt + 3600,
+ state: injectedState,
+ expires_at: issuedAt + injectedExpiresInSeconds,
};
const storageKeys = [
@@ -191,9 +277,25 @@ export async function seedAuth(page: Page, role?: string) {
}
window.localStorage.setItem("dev_role", injectedRole || "super_admin");
- window.localStorage.setItem("dev_tenant_id", "tenant-a");
+ window.localStorage.setItem(
+ "dev_tenant_id",
+ typeof injectedProfile.tenant_id === "string"
+ ? injectedProfile.tenant_id
+ : "tenant-a",
+ );
+ },
+ {
+ issuedAt: nowInSeconds,
+ injectedRole:
+ typeof profile.role === "string" ? profile.role : options.role,
+ injectedProfile: profile,
+ injectedState: options.state ?? { returnTo: "/clients" },
+ injectedIdToken: options.idToken ?? "playwright-id-token",
+ injectedAccessToken: options.accessToken ?? "playwright-access-token",
+ injectedRefreshToken: options.refreshToken ?? "playwright-refresh-token",
+ injectedSessionState: options.sessionState ?? "playwright-session",
+ injectedExpiresInSeconds: options.expiresInSeconds ?? 3600,
},
- { issuedAt: nowInSeconds, injectedRole: role ?? "" },
);
await page.route("**/oidc/**", async (route) => {
diff --git a/userfront/lib/features/dashboard/domain/linked_rp_launch.dart b/userfront/lib/features/dashboard/domain/linked_rp_launch.dart
index ccc3fb1b..6af9511f 100644
--- a/userfront/lib/features/dashboard/domain/linked_rp_launch.dart
+++ b/userfront/lib/features/dashboard/domain/linked_rp_launch.dart
@@ -1,4 +1,4 @@
-import 'providers/linked_rps_provider.dart';
+import 'models.dart';
String? resolveLinkedRpLaunchUrl(LinkedRp rp) {
final normalizedStatus = rp.status.trim().toLowerCase();
diff --git a/userfront/lib/features/dashboard/domain/providers/linked_rps_provider.dart b/userfront/lib/features/dashboard/domain/providers/linked_rps_provider.dart
index c209f2ea..a4616bc6 100644
--- a/userfront/lib/features/dashboard/domain/providers/linked_rps_provider.dart
+++ b/userfront/lib/features/dashboard/domain/providers/linked_rps_provider.dart
@@ -4,57 +4,7 @@ import 'package:userfront/core/services/auth_proxy_service.dart';
import 'package:userfront/core/services/auth_token_store.dart';
import 'package:userfront/core/services/http_client.dart';
import 'package:userfront/core/services/runtime_env.dart';
-
-class LinkedRp {
- final String id;
- final String name;
- final String logo;
- final String url;
- final String initUrl;
- final bool autoLoginSupported;
- final String autoLoginUrl;
- final String status;
- final List scopes;
- final DateTime? lastAuthenticatedAt;
-
- LinkedRp({
- required this.id,
- required this.name,
- required this.logo,
- required this.url,
- required this.initUrl,
- required this.autoLoginSupported,
- required this.autoLoginUrl,
- required this.status,
- required this.scopes,
- required this.lastAuthenticatedAt,
- });
-
- factory LinkedRp.fromJson(Map json) {
- final rawLastAuth = json['lastAuthenticatedAt']?.toString() ?? '';
- DateTime? parsedLastAuth;
- if (rawLastAuth.isNotEmpty) {
- try {
- parsedLastAuth = DateTime.parse(rawLastAuth).toLocal();
- } catch (_) {
- parsedLastAuth = null;
- }
- }
-
- return LinkedRp(
- id: json['id']?.toString() ?? '',
- name: json['name']?.toString() ?? '',
- logo: json['logo']?.toString() ?? '',
- url: json['url']?.toString() ?? '',
- initUrl: json['init_url']?.toString() ?? '',
- autoLoginSupported: json['auto_login_supported'] == true,
- autoLoginUrl: json['auto_login_url']?.toString() ?? '',
- status: json['status']?.toString() ?? 'unknown',
- scopes: (json['scopes'] as List?)?.whereType().toList() ?? [],
- lastAuthenticatedAt: parsedLastAuth,
- );
- }
-}
+import '../models.dart';
class LinkedRpsNotifier extends AsyncNotifier> {
@override
diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart
index 3d7e9d06..9af72ebb 100644
--- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart
+++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart
@@ -21,7 +21,7 @@ import '../../../../core/ui/layout_breakpoints.dart';
import '../../../../core/ui/toast_service.dart';
import '../../profile/domain/notifiers/profile_notifier.dart';
import '../domain/dashboard_providers.dart';
-import '../domain/models.dart' hide LinkedRp;
+import '../domain/models.dart';
import 'audit_device_utils.dart';
import 'package:userfront/i18n.dart';
diff --git a/userfront/test/linked_rp_launch_test.dart b/userfront/test/linked_rp_launch_test.dart
index c6846a91..f8c7e5d4 100644
--- a/userfront/test/linked_rp_launch_test.dart
+++ b/userfront/test/linked_rp_launch_test.dart
@@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:userfront/features/dashboard/domain/linked_rp_launch.dart';
-import 'package:userfront/features/dashboard/domain/providers/linked_rps_provider.dart';
+import 'package:userfront/features/dashboard/domain/models.dart';
LinkedRp _linkedRp({
required String status,