Merge branch 'dev' into feat/org-chart-rebac

This commit is contained in:
chan committed 2026-02-24 12:42:02 +09:00
commit c54dcccf49
106 files changed
+3370 -799

No files matched your search

+4
View File
@@ -11,6 +11,8 @@
*.log
*.out
*.exe
reports
reports/*
# Docker Services Data (Volumes)
postgres_data/
@@ -36,3 +38,5 @@ userfront/.env
# Frontend test artifacts
adminfront/test-results/
devfront/test-results/
adminfront/playwright-report/
devfront/playwright-report/
+77
View File
@@ -105,3 +105,80 @@ logs-ory:
logs-app:
docker compose -f $(COMPOSE_APP) logs -f
# --- 로컬 통합 코드 체크 ---
.PHONY: code-check code-check-i18n code-check-go-lint code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests
code-check: code-check-i18n code-check-go-lint code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests
@echo "code-check complete."
code-check-i18n:
@echo "==> i18n resource check"
@mkdir -p reports
node tools/i18n-scanner/index.js
node tools/i18n-scanner/report.js
@cat reports/i18n-report.txt
code-check-go-lint:
@echo "==> go lint/format check"
@if command -v golangci-lint >/dev/null 2>&1; then \
cd backend && golangci-lint run --enable-only=gofmt,gofumpt; \
else \
echo "WARN: golangci-lint not found, fallback to gofmt check only."; \
unformatted="$$(cd backend && gofmt -l .)"; \
if [ -n "$$unformatted" ]; then \
echo "gofmt required:"; \
echo "$$unformatted"; \
exit 1; \
fi; \
fi
code-check-userfront-lint:
@echo "==> userfront format/analyze"
cd userfront && flutter pub get
cd userfront && dart format --output=show --set-exit-if-changed lib test
cd userfront && flutter analyze --no-fatal-warnings --no-fatal-infos
code-check-front-lint:
@echo "==> adminfront biome lint/format check"
cd adminfront && npm ci
cd adminfront && npx biome check src tests playwright.config.ts --formatter-enabled=false --organize-imports-enabled=false
cd adminfront && npx biome check src tests playwright.config.ts --linter-enabled=false --organize-imports-enabled=false
@echo "==> devfront biome lint/format check"
cd devfront && npm ci
cd devfront && npx biome check src tests playwright.config.ts --formatter-enabled=false --organize-imports-enabled=false
cd devfront && npx biome check src tests playwright.config.ts --linter-enabled=false --organize-imports-enabled=false
code-check-backend-tests:
@echo "==> backend tests"
cd backend && go test -v ./...
code-check-userfront-tests:
@echo "==> userfront tests"
cd userfront && flutter test
code-check-adminfront-tests:
@echo "==> adminfront tests"
@mkdir -p reports/adminfront
@rm -rf reports/adminfront/playwright-report reports/adminfront/test-results
@status=0; \
(cd adminfront && npx playwright install) || status=$$?; \
if [ $$status -eq 0 ]; then \
(cd adminfront && npm test) || status=$$?; \
fi; \
[ -d adminfront/playwright-report ] && cp -R adminfront/playwright-report reports/adminfront/ || true; \
[ -d adminfront/test-results ] && cp -R adminfront/test-results reports/adminfront/ || true; \
exit $$status
code-check-devfront-tests:
@echo "==> devfront tests"
@mkdir -p reports/devfront
@rm -rf reports/devfront/playwright-report reports/devfront/test-results
@status=0; \
(cd devfront && npx playwright install) || status=$$?; \
if [ $$status -eq 0 ]; then \
(cd devfront && npm test) || status=$$?; \
fi; \
[ -d devfront/playwright-report ] && cp -R devfront/playwright-report reports/devfront/ || true; \
[ -d devfront/test-results ] && cp -R devfront/test-results reports/devfront/ || true; \
exit $$status
+7
View File
@@ -2,6 +2,13 @@
**Baron 로그인**은 화이트 라벨링된 가족사의 모든 소프트웨어 Auth를 총괄하는 사용자 인증/인가 허브입니다.
## 버그 대응 대원칙 (필수)
- 모든 버그 수정은 반드시 **재현 테스트를 먼저 작성**합니다. (Failing test first)
- 재현 테스트 없이 코드만 먼저 고치는 행위를 금지합니다.
- 수정 후에는 **해당 재현 테스트가 통과할 때까지 반복**해서 원인 분석/수정/검증을 수행합니다.
- “테스트 통과”는 최소 기준입니다. 실제 재현 시나리오(로그인, 새로고침, 리다이렉트 등)까지 확인한 뒤에만 이슈를 종료합니다.
- 관련 변경이 발생하면 테스트 문서(`docs/test-plan/*`, `docs/trouble-shooting/*`)를 함께 업데이트합니다.
* Ory Stack으로 모든 구성요소를 self-hosting 합니다.
* Backend는 Go (Fiber)로 구성된 Ory Stack의 유일한 Command 전송 포인트입니다. 모든 Command는 ClickHouse로 강제 전송되며 Audit Log 시스템을 구성합니다.
* Front는 Backend를 통해서만 연동하며 자체가 Ory Stack의 RP기도 합니다. 크게 3개 계층으로 분리됩니다.
@@ -0,0 +1,145 @@
import { useQuery } from "@tanstack/react-query";
import { Building2, Plus, Users } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import {
fetchGroups,
fetchTenants,
type TenantSummary,
} from "../../../lib/adminApi";
export default function GlobalUserGroupListPage() {
const { data: tenantList, isLoading: isTenantsLoading } = useQuery({
queryKey: ["admin-tenants"],
queryFn: () => fetchTenants(100, 0),
});
if (isTenantsLoading)
return <div className="p-8">Loading tenants and groups...</div>;
return (
<div className="space-y-8">
<header className="flex items-start justify-between">
<div className="space-y-1">
<h2 className="text-3xl font-bold tracking-tight">User Groups</h2>
<p className="text-muted-foreground">
모든 테넌트의 유저 그룹을 관리합니다. 권한 상속의 주체가 되는 그룹을
설정하세요.
</p>
</div>
</header>
<div className="grid gap-6">
{tenantList?.items.map((tenant) => (
<TenantGroupCard key={tenant.id} tenant={tenant} />
))}
</div>
</div>
);
}
function TenantGroupCard({ tenant }: { tenant: TenantSummary }) {
const { data: groups, isLoading } = useQuery({
queryKey: ["tenant-user-groups", tenant.id],
queryFn: () => fetchGroups(tenant.id),
});
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="space-y-1">
<CardTitle className="text-xl flex items-center gap-2">
<Building2 size={20} className="text-muted-foreground" />
{tenant.name}
<Badge variant="outline" className="ml-2">
{tenant.slug}
</Badge>
</CardTitle>
<CardDescription>
이 테넌트에 정의된 유저 그룹 목록입니다.
</CardDescription>
</div>
<Button size="sm" variant="outline" asChild>
<Link to={`/tenants/${tenant.id}/user-groups`}>
<Plus size={16} className="mr-2" />
그룹 관리
</Link>
</Button>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[250px]">그룹명</TableHead>
<TableHead>설명</TableHead>
<TableHead className="w-[100px]">멤버 수</TableHead>
<TableHead className="text-right">작업</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={4} className="text-center">
Loading...
</TableCell>
</TableRow>
) : groups?.length === 0 ? (
<TableRow>
<TableCell
colSpan={4}
className="text-center text-muted-foreground py-4"
>
등록된 유저 그룹이 없습니다.
</TableCell>
</TableRow>
) : (
groups?.map((group) => (
<TableRow key={group.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Users size={14} className="text-primary" />
<Link
to={`/tenants/${tenant.id}/user-groups/${group.id}`}
className="hover:underline"
>
{group.name}
</Link>
</div>
</TableCell>
<TableCell>{group.description || "-"}</TableCell>
<TableCell>{group.members?.length || 0} 명</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" asChild>
<Link
to={`/tenants/${tenant.id}/user-groups/${group.id}`}
>
상세보기
</Link>
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
+90 -19
View File
@@ -23,27 +23,99 @@ type UserCreatePayload = {
department?: string;
};
test.use({
storageState: {
cookies: [],
origins: [
{
origin: "http://localhost:5173",
localStorage: [
{
name: "admin_session",
value: "playwright-admin-session",
},
],
},
],
},
});
test("user create and delete flow", async ({ page }) => {
const nowInSeconds = Math.floor(Date.now() / 1000);
await page.addInitScript((issuedAt) => {
const mockOidcUser = {
id_token: "playwright-id-token",
session_state: "playwright-session",
access_token: "playwright-access-token",
refresh_token: "playwright-refresh-token",
token_type: "Bearer",
scope: "openid profile email",
profile: {
sub: "playwright-admin",
email: "admin@example.com",
name: "Playwright Admin",
},
expires_at: issuedAt + 3600,
};
window.localStorage.setItem("admin_session", mockOidcUser.access_token);
window.localStorage.setItem(
"oidc.user:http://localhost:5000/oidc:adminfront",
JSON.stringify(mockOidcUser),
);
window.localStorage.setItem(
"oidc.user:http://localhost:5000/oidc/:adminfront",
JSON.stringify(mockOidcUser),
);
}, nowInSeconds);
const users: UserSummary[] = [];
let idSeq = 1;
await page.route("**/api/v1/admin/tenants**", async (route) => {
const request = route.request();
if (request.method() !== "GET") {
await route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ error: "Not found" }),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
id: "tenant-e2e",
name: "E2E Tenant",
slug: "e2e",
description: "Playwright tenant",
status: "active",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
limit: 100,
offset: 0,
total: 1,
}),
});
});
await page.route("**/api/v1/admin/tenants/*", async (route) => {
const request = route.request();
if (request.method() !== "GET") {
await route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ error: "Not found" }),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
id: "tenant-e2e",
name: "E2E Tenant",
slug: "e2e",
description: "Playwright tenant",
status: "active",
config: { userSchema: [] },
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}),
});
});
await page.route("**/api/v1/admin/users**", async (route) => {
const request = route.request();
const url = new URL(request.url());
@@ -133,7 +205,7 @@ test("user create and delete flow", async ({ page }) => {
const addUserLink = page.getByRole("link", { name: "사용자 추가" });
await expect(addUserLink).toBeVisible();
await addUserLink.click();
await page.goto("/users/new");
await expect(page).toHaveURL(/\/users\/new$/);
const uniqueEmail = `playwright-${Date.now()}@example.com`;
@@ -143,7 +215,6 @@ test("user create and delete flow", async ({ page }) => {
await page.getByLabel("비밀번호").fill("Test1234!");
await page.getByLabel("이름").fill("Playwright User");
await page.getByLabel("전화번호").fill("010-0000-0000");
await page.getByLabel("회사 코드").fill("E2E");
await page.getByLabel("부서").fill("QA");
await page.getByLabel("역할 (Role)").selectOption("admin");
+5
View File
@@ -276,8 +276,13 @@ func main() {
auditHandler := handler.NewAuditHandler(auditRepo)
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService)
adminHandler := handler.NewAdminHandler(ketoService)
<<<<<<< HEAD
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService)
tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService, ketoOutboxRepo, kratosAdminService)
=======
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, authHandler)
tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService, kratosAdminService)
>>>>>>> dev
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo)
+40 -22
View File
@@ -4,6 +4,7 @@ import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/logger"
"baron-sso-backend/internal/repository"
"baron-sso-backend/internal/response"
"baron-sso-backend/internal/service"
"baron-sso-backend/internal/utils"
"bytes"
@@ -1562,13 +1563,12 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
ale.LatencyMs = time.Since(startTime)
ale.ProviderError = err.Error()
ale.Log(slog.LevelError, "Body parse error")
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
return response.Error(c, fiber.StatusBadRequest, "bad_request", "Invalid request body")
}
loginID := strings.TrimSpace(req.LoginID)
ale.LoginIDs["loginId"] = req.LoginID // 원문
ale.LoginIDs["loginId_normalized"] = loginID
ale.NewPassword = req.Password // For test only, logging password (sensitive)
ale.Log(slog.LevelInfo, "Attempting to login")
@@ -1577,22 +1577,22 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
ale.LatencyMs = time.Since(startTime)
ale.ProviderError = "IDP Provider is nil"
ale.Log(slog.LevelError, "IDP Provider is nil")
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"})
return response.Error(c, fiber.StatusInternalServerError, "service_unavailable", "Authentication service not configured")
}
authInfo, err := h.IdpProvider.SignIn(loginID, req.Password)
if err != nil {
if errors.Is(err, domain.ErrNotSupported) {
return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"})
return response.Error(c, fiber.StatusNotImplemented, "not_supported", "Login method not supported")
}
ale.Status = fiber.StatusUnauthorized
ale.LatencyMs = time.Since(startTime)
ale.ProviderError = err.Error()
ale.Log(slog.LevelWarn, "IDP sign-in failed", slog.String("provider", h.IdpProvider.Name()))
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "identity") {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not registered"})
return response.Error(c, fiber.StatusNotFound, "not_found", "User not registered")
}
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"})
return response.Error(c, fiber.StatusUnauthorized, "password_or_email_mismatch", "Invalid credentials")
}
subject, resolveErr := h.resolveKratosIdentityIDFromLoginID(c.Context(), loginID)
@@ -1604,7 +1604,6 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
ale.Status = fiber.StatusOK
ale.LatencyMs = time.Since(startTime)
ale.SessionJwt = authInfo.SessionToken.JWT
setSessionIDLocal(c, authInfo.SessionToken)
ale.Log(slog.LevelInfo, "Login successful", slog.String("provider", h.IdpProvider.Name()), slog.String("subject", authInfo.Subject))
@@ -1856,11 +1855,23 @@ func (h *AuthHandler) ProcessPasswordResetToken(c *fiber.Ctx) error {
ale.LoginIDs["loginId"] = loginID
ale.LoginIDs["loginId_normalized"] = loginID
redirectURL := fmt.Sprintf("%s/reset-password?loginId=%s&token=%s",
os.Getenv("USERFRONT_URL"),
loginID,
token,
)
userfrontURL := strings.TrimRight(os.Getenv("USERFRONT_URL"), "/")
if userfrontURL == "" {
userfrontURL = "https://sso.hmac.kr"
}
redirectBase, parseErr := url.Parse(userfrontURL + "/reset-password")
if parseErr != nil {
ale.Status = fiber.StatusInternalServerError
ale.LatencyMs = time.Since(startTime)
ale.ProviderError = parseErr.Error()
ale.Log(slog.LevelError, "Failed to compose reset redirect URL")
return c.Status(fiber.StatusInternalServerError).SendString("Failed to compose redirect URL")
}
query := redirectBase.Query()
query.Set("loginId", loginID)
query.Set("token", token)
redirectBase.RawQuery = query.Encode()
redirectURL := redirectBase.String()
ale.RedirectTo = redirectURL
ale.Status = fiber.StatusFound
@@ -1894,22 +1905,29 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
}
// loginID는 URL 쿼리 파라미터 또는 토큰 조회로 받습니다.
loginID := c.Query("loginId")
resetToken := c.Query("token")
if loginID == "" && resetToken != "" {
if val, err := h.RedisService.Get(prefixPwdResetToken + resetToken); err == nil && val != "" {
loginID = val
loginID := strings.TrimSpace(c.Query("loginId"))
resetToken := strings.TrimSpace(c.Query("token"))
if resetToken != "" {
val, err := h.RedisService.Get(prefixPwdResetToken + resetToken)
if err != nil || strings.TrimSpace(val) == "" {
ale.Status = fiber.StatusUnauthorized
ale.LatencyMs = time.Since(startTime)
ale.ProviderError = "Invalid or expired reset token"
ale.Token = resetToken
ale.Log(slog.LevelWarn, "Reset token invalid or expired")
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired reset token"})
}
loginID = strings.TrimSpace(val)
ale.Token = resetToken
}
if loginID != "" && !strings.Contains(loginID, "@") {
loginID = normalizePhoneForLoginID(loginID)
}
ale.LoginIDs["loginId"] = loginID
ale.RequestBody = fmt.Sprintf("{\"newPassword\": \"%s\"}", req.NewPassword) // Log request body (for test only)
ale.NewPassword = req.NewPassword // Log new password (for test only)
// Request cookie logging (minimal)
// 요청 쿠키는 원문을 기록하지 않고 존재 여부만 기록합니다.
if cookieHeader := c.Get(fiber.HeaderCookie); cookieHeader != "" {
ale.Headers["Request-Cookie-Header"] = cookieHeader
if dsrfCookie := c.Cookies("DSRF"); dsrfCookie != "" {
ale.ParsedCookieDSRF = dsrfCookie
ale.HasCookieDSRF = true
@@ -1926,7 +1944,7 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Login ID and new password are required"})
}
// 디버깅을 위해 요청된 새 비밀번호를 로그로 출력
// 새 비밀번호 값은 기록하지 않고, 요청 수신 이벤트만 남깁니다.
ale.Log(slog.LevelInfo, "Received new password for reset")
policy := h.resolvePasswordPolicy()
@@ -300,3 +300,44 @@ func TestPasswordLogin_NoOIDC_Success(t *testing.T) {
t.Errorf("expected no redirectTo, got %s", got["redirectTo"])
}
}
func TestPasswordLogin_InvalidCredentials_ReturnsCode(t *testing.T) {
mockIdp := new(MockIdentityProvider)
mockIdp.On("SignIn", "user@example.com", "wrong-password").Return(nil, errors.New("비밀번호가 일치하지 않습니다"))
h := &AuthHandler{
IdpProvider: mockIdp,
KratosAdmin: service.NewKratosAdminService(),
Hydra: service.NewHydraAdminService(),
}
app := newAuthLoginTestApp(h)
body, _ := json.Marshal(map[string]string{
"loginId": "user@example.com",
"password": "wrong-password",
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", resp.StatusCode)
}
var got map[string]any
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if got["code"] != "password_or_email_mismatch" {
t.Fatalf("expected code=password_or_email_mismatch, got=%v", got["code"])
}
if got["error"] != "Invalid credentials" {
t.Fatalf("expected error=Invalid credentials, got=%v", got["error"])
}
}
@@ -3,9 +3,11 @@ package handler
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gofiber/fiber/v2"
)
@@ -17,6 +19,51 @@ func newTestApp(h *AuthHandler) *fiber.App {
return app
}
func newResetFlowTestApp(h *AuthHandler) *fiber.App {
app := fiber.New()
app.Post("/api/v1/auth/password/reset/verify", h.ProcessPasswordResetToken)
app.Post("/api/v1/auth/password/reset/complete", h.CompletePasswordReset)
return app
}
type testRedisRepo struct {
values map[string]string
}
func (m *testRedisRepo) Set(key string, value string, expiration time.Duration) error {
if m.values == nil {
m.values = map[string]string{}
}
m.values[key] = value
return nil
}
func (m *testRedisRepo) Get(key string) (string, error) {
if m.values == nil {
return "", nil
}
return m.values[key], nil
}
func (m *testRedisRepo) Delete(key string) error {
if m.values != nil {
delete(m.values, key)
}
return nil
}
func (m *testRedisRepo) StoreVerificationCode(phone, code string) error {
return m.Set("sms:"+phone, code, time.Minute)
}
func (m *testRedisRepo) GetVerificationCode(phone string) (string, error) {
return m.Get("sms:" + phone)
}
func (m *testRedisRepo) DeleteVerificationCode(phone string) error {
return m.Delete("sms:" + phone)
}
func TestCompletePasswordReset_MissingLoginID(t *testing.T) {
h := &AuthHandler{}
app := newTestApp(h)
@@ -106,3 +153,136 @@ func TestCompletePasswordReset_NilIDPProvider(t *testing.T) {
t.Fatalf("unexpected error message: %v", got["error"])
}
}
func TestCompletePasswordReset_TokenValueOverridesLoginIDQuery(t *testing.T) {
const resetToken = "tok-reset-1"
const tokenLoginID = "user@example.com"
const wrongLoginID = "wrong@example.com"
const newPassword = "StrongPass1!"
redis := &testRedisRepo{
values: map[string]string{
prefixPwdResetToken + resetToken: tokenLoginID,
},
}
idp := &mockIdpProvider{
userExists: true,
err: nil,
}
h := &AuthHandler{
RedisService: redis,
IdpProvider: idp,
}
app := newResetFlowTestApp(h)
body, _ := json.Marshal(map[string]string{
"newPassword": newPassword,
})
url := fmt.Sprintf(
"/api/v1/auth/password/reset/complete?loginId=%s&token=%s",
wrongLoginID,
resetToken,
)
req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
if !idp.updateCalled {
t.Fatal("expected UpdateUserPassword to be called")
}
if idp.updatedLoginID != tokenLoginID {
t.Fatalf("expected loginId from token(%s), got %s", tokenLoginID, idp.updatedLoginID)
}
if idp.updatedPassword != newPassword {
t.Fatalf("expected newPassword propagated, got %s", idp.updatedPassword)
}
}
func TestCompletePasswordReset_InvalidTokenRejectedEvenWhenLoginIDExists(t *testing.T) {
const resetToken = "invalid-token"
redis := &testRedisRepo{
values: map[string]string{},
}
idp := &mockIdpProvider{
userExists: true,
err: nil,
}
h := &AuthHandler{
RedisService: redis,
IdpProvider: idp,
}
app := newResetFlowTestApp(h)
body, _ := json.Marshal(map[string]string{
"newPassword": "StrongPass1!",
})
req := httptest.NewRequest(
http.MethodPost,
"/api/v1/auth/password/reset/complete?loginId=user@example.com&token="+resetToken,
bytes.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401 for invalid token, got %d", resp.StatusCode)
}
if idp.updateCalled {
t.Fatal("UpdateUserPassword must not be called when token is invalid")
}
}
func TestProcessPasswordResetToken_EncodesLoginIDInRedirect(t *testing.T) {
const token = "tok-enc"
const loginID = "user+alias@example.com"
t.Setenv("USERFRONT_URL", "https://sss.hmac.kr")
redis := &testRedisRepo{
values: map[string]string{
prefixPwdResetToken + token: loginID,
},
}
h := &AuthHandler{
RedisService: redis,
}
app := newResetFlowTestApp(h)
req := httptest.NewRequest(
http.MethodPost,
"/api/v1/auth/password/reset/verify?token="+token,
nil,
)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Fatalf("expected 302, got %d", resp.StatusCode)
}
location := resp.Header.Get("Location")
if location == "" {
t.Fatal("missing redirect location")
}
redirectReq := httptest.NewRequest(http.MethodGet, location, nil)
gotLoginID := redirectReq.URL.Query().Get("loginId")
if gotLoginID != loginID {
t.Fatalf("expected encoded loginId round-trip=%s, got %s (location=%s)", loginID, gotLoginID, location)
}
}
+6
View File
@@ -19,6 +19,9 @@ type mockIdpProvider struct {
verifyCodeInfo *domain.AuthInfo
err error
initiateLinkErr error
updateCalled bool
updatedLoginID string
updatedPassword string
}
func (m *mockIdpProvider) Name() string {
@@ -63,6 +66,9 @@ func (m *mockIdpProvider) VerifyPasswordResetToken(token string) (*domain.AuthIn
}
func (m *mockIdpProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
m.updateCalled = true
m.updatedLoginID = loginID
m.updatedPassword = newPassword
return m.err
}
+265 -13
View File
@@ -7,8 +7,11 @@ import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"strings"
"time"
@@ -22,15 +25,39 @@ type DevHandler struct {
SecretRepo domain.ClientSecretRepository
KratosAdmin service.KratosAdminService
ConsentRepo repository.ClientConsentRepository
Keto service.KetoService
RPSvc service.RelyingPartyService
Auth interface {
GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error)
}
}
func NewDevHandler(redis domain.RedisRepository, secretRepo domain.ClientSecretRepository, consentRepo repository.ClientConsentRepository, rpSvc service.RelyingPartyService) *DevHandler {
func NewDevHandler(
redis domain.RedisRepository,
secretRepo domain.ClientSecretRepository,
consentRepo repository.ClientConsentRepository,
rpSvc service.RelyingPartyService,
keto service.KetoService,
auth ...interface {
GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error)
},
) *DevHandler {
var authProvider interface {
GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error)
}
if len(auth) > 0 {
authProvider = auth[0]
}
return &DevHandler{
Hydra: service.NewHydraAdminService(),
Redis: redis,
SecretRepo: secretRepo,
KratosAdmin: service.NewKratosAdminService(),
ConsentRepo: consentRepo,
Keto: keto,
RPSvc: rpSvc,
Auth: authProvider,
}
}
@@ -94,6 +121,142 @@ type clientUpsertRequest struct {
Metadata *map[string]interface{} `json:"metadata"`
}
func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
if (!ok || profile == nil) && h.Auth != nil {
enriched, err := h.Auth.GetEnrichedProfile(c)
if err == nil && enriched != nil {
profile = enriched
ok = true
c.Locals("user_profile", enriched)
}
}
if ok && profile != nil {
// Super Admin bypass
if profile.Role == domain.RoleSuperAdmin {
slog.Info("Dev private permission granted by super_admin role", "user_id", profile.ID)
return true, nil
}
if isAdminEmail(profile.Email) {
slog.Info("Dev private permission granted by ADMIN_EMAIL match", "email", profile.Email)
return true, nil
}
// Check with Keto: System:AppManager#member
allowed, err := h.Keto.CheckPermission(c.Context(), profile.ID, "System", "AppManager", "member")
if err != nil {
return false, err
}
slog.Info("Dev private permission evaluated by Keto", "user_id", profile.ID, "allowed", allowed)
return allowed, nil
}
tokenSubject, tokenEmail := extractAuthClaimsFromBearer(c.Get("Authorization"))
if isAdminEmail(tokenEmail) {
slog.Info("Dev private permission granted by token email", "email", tokenEmail)
return true, nil
}
if tokenSubject == "" {
if isTrustedLocalDevfrontRequest(c) {
// Local devfront fallback: allow localhost developer flow even if auth context is missing.
slog.Warn("Dev private permission fallback granted for trusted local devfront request", "path", c.Path(), "origin", c.Get("Origin"))
return true, nil
}
return false, nil
}
// Fallback: resolve role from Kratos identity traits when user_profile is not injected.
if h.KratosAdmin != nil {
identity, err := h.KratosAdmin.GetIdentity(c.Context(), tokenSubject)
if err == nil && identity != nil {
if rawRole, ok := identity.Traits["role"].(string); ok && rawRole == domain.RoleSuperAdmin {
slog.Info("Dev private permission granted by Kratos role", "subject", tokenSubject)
return true, nil
}
if email, ok := identity.Traits["email"].(string); ok && isAdminEmail(email) {
slog.Info("Dev private permission granted by Kratos email", "subject", tokenSubject, "email", email)
return true, nil
}
}
}
// Check with Keto: System:AppManager#member
allowed, err := h.Keto.CheckPermission(c.Context(), tokenSubject, "System", "AppManager", "member")
if err != nil {
return false, err
}
slog.Info("Dev private permission evaluated by Keto(subject)", "subject", tokenSubject, "allowed", allowed)
return allowed, nil
}
func extractAuthClaimsFromBearer(authHeader string) (string, string) {
authHeader = strings.TrimSpace(authHeader)
if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
return "", ""
}
token := strings.TrimSpace(authHeader[len("Bearer "):])
if token == "" || strings.Count(token, ".") != 2 {
return "", ""
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
return "", ""
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
payload, err = base64.URLEncoding.DecodeString(parts[1])
if err != nil {
return "", ""
}
}
var claims map[string]interface{}
if err := json.Unmarshal(payload, &claims); err != nil {
return "", ""
}
sub := ""
if sub, ok := claims["sub"].(string); ok {
sub = strings.TrimSpace(sub)
}
email := ""
if claimEmail, ok := claims["email"].(string); ok {
email = strings.TrimSpace(claimEmail)
}
return sub, email
}
func isAdminEmail(email string) bool {
adminEmail := strings.TrimSpace(os.Getenv("ADMIN_EMAIL"))
return adminEmail != "" && strings.EqualFold(strings.TrimSpace(email), adminEmail)
}
func isTrustedLocalDevfrontRequest(c *fiber.Ctx) bool {
if c == nil {
return false
}
origin := strings.ToLower(strings.TrimSpace(c.Get("Origin")))
referer := strings.ToLower(strings.TrimSpace(c.Get("Referer")))
allowedPrefixes := []string{
"http://localhost:5174",
"https://localhost:5174",
}
for _, prefix := range allowedPrefixes {
if strings.HasPrefix(origin, prefix) || strings.HasPrefix(referer, prefix) {
return true
}
}
return false
}
func (h *DevHandler) ListClients(c *fiber.Ctx) error {
limit := c.QueryInt("limit", 50)
offset := c.QueryInt("offset", 0)
@@ -104,6 +267,11 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
offset = 0
}
isAppManager, err := h.checkAppManagerPermission(c)
if err != nil {
slog.Error("Failed to check app manager permission", "error", err)
}
clients, err := h.Hydra.ListClients(c.Context(), limit, offset)
if err != nil {
if errors.Is(err, service.ErrHydraNotFound) {
@@ -120,7 +288,12 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
items := make([]clientSummary, 0, len(clients))
for _, client := range clients {
items = append(items, h.mapClientSummary(client))
summary := h.mapClientSummary(client)
// Filter out 'private' clients if user is not an AppManager
if summary.Type == "private" && !isAppManager {
continue
}
items = append(items, summary)
}
return c.JSON(clientListResponse{
@@ -145,6 +318,18 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error {
}
summary := h.mapClientSummary(*client)
// Check permission for private clients
if summary.Type == "private" {
isAppManager, err := h.checkAppManagerPermission(c)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "permission check error"})
}
if !isAppManager {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"})
}
}
return c.JSON(clientDetailResponse{
Client: summary,
Endpoints: clientEndpoints{
@@ -175,6 +360,18 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "status must be active or inactive"})
}
// [Security] Check permission before patching
current, err := h.Hydra.GetClient(c.Context(), clientID)
if err == nil {
summary := h.mapClientSummary(*current)
if summary.Type == "private" {
isAppManager, _ := h.checkAppManagerPermission(c)
if !isAppManager {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"})
}
}
}
updated, err := h.Hydra.PatchClientStatus(c.Context(), clientID, status)
if err != nil {
if errors.Is(err, service.ErrHydraNotFound) {
@@ -221,9 +418,20 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
grantTypes := derefSlice(req.GrantTypes, defaultGrantTypes())
responseTypes := derefSlice(req.ResponseTypes, defaultResponseTypes())
clientType := strings.ToLower(strings.TrimSpace(valueOr(req.Type, "confidential")))
if clientType != "public" && clientType != "confidential" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type must be public or confidential"})
clientType := strings.ToLower(strings.TrimSpace(valueOr(req.Type, "private")))
if clientType != "pkce" && clientType != "private" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type must be pkce or private"})
}
// [Security] Check permission for private clients
if clientType == "private" {
isAppManager, err := h.checkAppManagerPermission(c)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "permission check error"})
}
if !isAppManager {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions to create private client"})
}
}
status := strings.ToLower(strings.TrimSpace(valueOr(req.Status, "active")))
@@ -236,10 +444,11 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
metadata = map[string]interface{}{}
}
metadata["status"] = status
metadata["created_at"] = time.Now().Format(time.RFC3339)
tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, ""))
if tokenAuthMethod == "" {
if clientType == "public" {
if clientType == "pkce" {
tokenAuthMethod = "none"
} else {
tokenAuthMethod = "client_secret_basic"
@@ -310,8 +519,20 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
clientType := ""
if req.Type != nil {
clientType = strings.ToLower(strings.TrimSpace(*req.Type))
if clientType != "public" && clientType != "confidential" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type must be public or confidential"})
if clientType != "pkce" && clientType != "private" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type must be pkce or private"})
}
}
// [Security] Check permission for private clients (both current and new type)
currentSummary := h.mapClientSummary(*current)
if currentSummary.Type == "private" || clientType == "private" {
isAppManager, err := h.checkAppManagerPermission(c)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "permission check error"})
}
if !isAppManager {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"})
}
}
@@ -325,7 +546,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, ""))
if tokenAuthMethod == "" && clientType != "" {
if clientType == "public" {
if clientType == "pkce" {
tokenAuthMethod = "none"
} else {
tokenAuthMethod = "client_secret_basic"
@@ -382,6 +603,18 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"})
}
// [Security] Check permission for private clients
current, err := h.Hydra.GetClient(c.Context(), clientID)
if err == nil {
summary := h.mapClientSummary(*current)
if summary.Type == "private" {
isAppManager, _ := h.checkAppManagerPermission(c)
if !isAppManager {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"})
}
}
}
if err := h.Hydra.DeleteClient(c.Context(), clientID); err != nil {
if errors.Is(err, service.ErrHydraNotFound) {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"})
@@ -517,14 +750,25 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"})
}
// [Security] Check permission for private clients
current, err := h.Hydra.GetClient(c.Context(), clientID)
if err == nil {
summary := h.mapClientSummary(*current)
if summary.Type == "private" {
isAppManager, _ := h.checkAppManagerPermission(c)
if !isAppManager {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"})
}
}
}
// 1. Generate new secret
newSecret, err := generateRandomSecret(20)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate secret"})
}
// 2. Get current client to preserve other fields
current, err := h.Hydra.GetClient(c.Context(), clientID)
// 2. Get current client to preserve other fields (already fetched above)
if err != nil {
if errors.Is(err, service.ErrHydraNotFound) {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"})
@@ -578,15 +822,22 @@ func generateRandomSecret(length int) (string, error) {
func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
status := "active"
var createdAt *time.Time
if client.Metadata != nil {
if value, ok := client.Metadata["status"].(string); ok && strings.ToLower(value) == "inactive" {
status = "inactive"
}
if value, ok := client.Metadata["created_at"].(string); ok {
if t, err := time.Parse(time.RFC3339, value); err == nil {
createdAt = &t
}
}
}
clientType := "confidential"
clientType := "private"
if strings.EqualFold(client.TokenEndpointAuthMethod, "none") {
clientType = "public"
clientType = "pkce"
}
name := strings.TrimSpace(client.ClientName)
@@ -627,6 +878,7 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
Name: name,
Type: clientType,
Status: status,
CreatedAt: createdAt,
RedirectURIs: client.RedirectURIs,
Scopes: scopes,
ClientSecret: clientSecret,
+53 -21
View File
@@ -1,8 +1,10 @@
package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -10,8 +12,36 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
type MockKetoService struct {
mock.Mock
}
func (m *MockKetoService) CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error) {
args := m.Called(ctx, subject, namespace, object, relation)
return args.Bool(0), args.Error(1)
}
func (m *MockKetoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error {
return m.Called(ctx, namespace, object, relation, subject).Error(0)
}
func (m *MockKetoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
return m.Called(ctx, namespace, object, relation, subject).Error(0)
}
func (m *MockKetoService) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]service.RelationTuple, error) {
args := m.Called(ctx, namespace, object, relation, subject)
return args.Get(0).([]service.RelationTuple), args.Error(1)
}
func (m *MockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
args := m.Called(ctx, namespace, relation, subject)
return args.Get(0).([]string), args.Error(1)
}
func TestListClients_Success(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/clients" {
@@ -23,13 +53,22 @@ func TestListClients_Success(t *testing.T) {
return httpJSONAny(r, http.StatusNotFound, map[string]string{"error": "not found"}), nil
})
mockKeto := new(MockKetoService)
// For simplicity, always allow in basic success test
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "AppManager", "member").Return(true, nil)
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
Keto: mockKeto,
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Get("/api/v1/dev/clients", h.ListClients)
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
@@ -58,14 +97,21 @@ func TestGetClient_Success(t *testing.T) {
return httpJSONAny(r, http.StatusNotFound, map[string]string{"error": "not found"}), nil
})
mockKeto := new(MockKetoService)
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
PublicURL: "http://hydra-public.test", // PublicURL 추가
HTTPClient: &http.Client{Transport: transport},
},
Keto: mockKeto,
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Get("/api/v1/dev/clients/:id", h.GetClient)
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-123", nil)
@@ -80,26 +126,6 @@ func TestGetClient_Success(t *testing.T) {
assert.Equal(t, "http://hydra-public.test/oauth2/auth", res.Endpoints.Authorization)
}
func TestGetClient_NotFound(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
return httpJSONAny(r, http.StatusNotFound, map[string]string{"error": "not found"}), nil
})
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
}
app := fiber.New()
app.Get("/api/v1/dev/clients/:id", h.GetClient)
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/non-existent", nil)
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
}
func TestCreateClient_Success(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
@@ -112,6 +138,7 @@ func TestCreateClient_Success(t *testing.T) {
return httpJSONAny(r, http.StatusInternalServerError, map[string]string{"error": "hydra error"}), nil
})
mockKeto := new(MockKetoService)
secretRepo := &mockSecretRepo{secrets: make(map[string]string)}
redisRepo := &mockRedisRepo{data: make(map[string]string)}
@@ -122,13 +149,18 @@ func TestCreateClient_Success(t *testing.T) {
},
SecretRepo: secretRepo,
Redis: redisRepo,
Keto: mockKeto,
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Post("/api/v1/dev/clients", h.CreateClient)
body, _ := json.Marshal(map[string]interface{}{
"client_name": "New App",
"type": "confidential",
"type": "private",
"redirectUris": []string{"http://localhost/cb"},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
+47 -21
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/gofiber/fiber/v2"
@@ -24,7 +25,7 @@ type AuditLogEntry struct {
Origin string
Referer string
Query map[string]string
Headers map[string]string // Core headers like Host, Cookie, Set-Cookie
Headers map[string]string // 핵심 헤더(민감 키는 마스킹됨)
LoginIDs map[string]string // loginId and loginId_normalized
Token string // For reset tokens, magic link tokens
ProviderError string
@@ -43,8 +44,6 @@ type AuditLogEntry struct {
RedirectTo string
HasCookieDSRF bool
ParsedCookieDSRF string
RequestBody string // For complete stage
NewPassword string // For complete stage (test only, sensitive)
// ... potentially more fields specific to different stages
}
@@ -55,16 +54,14 @@ func NewAuditLogEntry(c *fiber.Ctx, stage string) *AuditLogEntry {
// Extract query parameters
queryParams := make(map[string]string)
c.Context().QueryArgs().VisitAll(func(key, value []byte) {
queryParams[string(key)] = string(value)
k := string(key)
queryParams[k] = maskSensitiveByKey(k, string(value))
})
// Extract relevant headers
headers := make(map[string]string)
headers["Host"] = c.Get("Host")
headers["User-Agent"] = c.Get("User-Agent")
if cookie := c.Get("Cookie"); cookie != "" {
headers["Cookie"] = cookie
}
headers["Origin"] = c.Get("Origin")
headers["Referer"] = c.Get("Referer")
@@ -122,14 +119,14 @@ func (ale *AuditLogEntry) Log(level slog.Level, msg string, args ...any) {
if len(ale.Query) > 0 {
queryGroupArgs := make([]any, 0, len(ale.Query))
for k, v := range ale.Query {
queryGroupArgs = append(queryGroupArgs, slog.String(k, v))
queryGroupArgs = append(queryGroupArgs, slog.String(k, maskSensitiveByKey(k, v)))
}
attrs = append(attrs, slog.Group("query", queryGroupArgs...))
}
if len(ale.Headers) > 0 {
headersGroupArgs := make([]any, 0, len(ale.Headers))
for k, v := range ale.Headers {
headersGroupArgs = append(headersGroupArgs, slog.String(k, v))
headersGroupArgs = append(headersGroupArgs, slog.String(k, maskSensitiveByKey(k, v)))
}
attrs = append(attrs, slog.Group("headers", headersGroupArgs...))
}
@@ -141,7 +138,7 @@ func (ale *AuditLogEntry) Log(level slog.Level, msg string, args ...any) {
attrs = append(attrs, slog.Group("login_ids", loginIDGroupArgs...))
}
if ale.Token != "" {
attrs = append(attrs, slog.String("token", ale.Token))
attrs = append(attrs, slog.Bool("has_token", true))
}
if ale.ProviderError != "" {
attrs = append(attrs, slog.String("provider_error", ale.ProviderError))
@@ -153,13 +150,13 @@ func (ale *AuditLogEntry) Log(level slog.Level, msg string, args ...any) {
attrs = append(attrs, slog.String("provider_response_body", ale.ProviderBody))
}
if ale.RefreshToken != "" {
attrs = append(attrs, slog.String("refresh_token", ale.RefreshToken))
attrs = append(attrs, slog.Bool("has_refresh_token", true))
}
if ale.SessionJwt != "" {
attrs = append(attrs, slog.String("session_jwt", ale.SessionJwt))
attrs = append(attrs, slog.Bool("has_session_jwt", true))
}
if ale.AccessJwt != "" {
attrs = append(attrs, slog.String("access_jwt", ale.AccessJwt))
attrs = append(attrs, slog.Bool("has_access_jwt", true))
}
if ale.UserLoginId != "" {
attrs = append(attrs, slog.String("user_login_id", ale.UserLoginId))
@@ -175,7 +172,9 @@ func (ale *AuditLogEntry) Log(level slog.Level, msg string, args ...any) {
}
if ale.SetCookieName != "" {
attrs = append(attrs, slog.String("set_cookie_name", ale.SetCookieName))
attrs = append(attrs, slog.String("set_cookie_value", ale.SetCookieValue))
if ale.SetCookieValue != "" {
attrs = append(attrs, slog.Bool("has_set_cookie_value", true))
}
if len(ale.SetCookieAttrs) > 0 {
cookieAttrsGroupArgs := make([]any, 0, len(ale.SetCookieAttrs))
for k, v := range ale.SetCookieAttrs {
@@ -191,13 +190,7 @@ func (ale *AuditLogEntry) Log(level slog.Level, msg string, args ...any) {
attrs = append(attrs, slog.Bool("has_cookie_DSRF", ale.HasCookieDSRF))
}
if ale.ParsedCookieDSRF != "" {
attrs = append(attrs, slog.String("parsed_cookie_DSRF", ale.ParsedCookieDSRF))
}
if ale.RequestBody != "" {
attrs = append(attrs, slog.String("request_body", ale.RequestBody))
}
if ale.NewPassword != "" { // FOR TEST ONLY - DO NOT LOG IN PRODUCTION
attrs = append(attrs, slog.String("new_password", ale.NewPassword))
attrs = append(attrs, slog.Bool("has_parsed_cookie_DSRF", true))
}
// Convert variadic args to slog.Attr before appending
@@ -212,3 +205,36 @@ func (ale *AuditLogEntry) Log(level slog.Level, msg string, args ...any) {
slog.Default().LogAttrs(context.Background(), level, msg, attrs...)
}
var sensitiveAuditKeys = map[string]struct{}{
"password": {},
"currentpassword": {},
"newpassword": {},
"oldpassword": {},
"token": {},
"accesstoken": {},
"refreshtoken": {},
"authorization": {},
"cookie": {},
"setcookie": {},
"verificationcode": {},
"code": {},
"loginchallenge": {},
"loginverifier": {},
"sessionjwt": {},
"accessjwt": {},
"refreshjwt": {},
}
func maskSensitiveByKey(key, value string) string {
if value == "" {
return value
}
k := strings.ToLower(key)
k = strings.ReplaceAll(k, "-", "")
k = strings.ReplaceAll(k, "_", "")
if _, ok := sensitiveAuditKeys[k]; ok {
return "*****"
}
return value
}
@@ -0,0 +1,80 @@
package logger
import (
"bytes"
"encoding/json"
"log/slog"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAuditLogEntry_RedactsSensitiveFields(t *testing.T) {
buf := &bytes.Buffer{}
previous := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(buf, nil)))
defer slog.SetDefault(previous)
ale := &AuditLogEntry{
RequestID: "req-1",
Stage: "login",
Token: "tok-secret",
RefreshToken: "refresh-secret",
SessionJwt: "session-secret",
AccessJwt: "access-secret",
SetCookieName: "sid",
SetCookieValue: "cookie-secret",
ParsedCookieDSRF: "dsrf-secret",
LoginIDs: map[string]string{
"loginId": "user@example.com",
},
Query: map[string]string{
"token": "query-token",
"locale": "ko",
},
Headers: map[string]string{
"Authorization": "Bearer secret",
"Cookie": "session=secret",
},
}
ale.Log(slog.LevelInfo, "test")
line := strings.TrimSpace(buf.String())
require.NotEmpty(t, line)
var payload map[string]any
require.NoError(t, json.Unmarshal([]byte(line), &payload))
assert.NotContains(t, payload, "token")
assert.NotContains(t, payload, "refresh_token")
assert.NotContains(t, payload, "session_jwt")
assert.NotContains(t, payload, "access_jwt")
assert.NotContains(t, payload, "set_cookie_value")
assert.NotContains(t, payload, "parsed_cookie_DSRF")
assert.NotContains(t, payload, "request_body")
assert.NotContains(t, payload, "new_password")
assert.Equal(t, true, payload["has_token"])
assert.Equal(t, true, payload["has_refresh_token"])
assert.Equal(t, true, payload["has_session_jwt"])
assert.Equal(t, true, payload["has_access_jwt"])
assert.Equal(t, true, payload["has_set_cookie_value"])
assert.Equal(t, true, payload["has_parsed_cookie_DSRF"])
loginIDs, ok := payload["login_ids"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "user@example.com", loginIDs["loginId"])
query, ok := payload["query"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "*****", query["token"])
assert.Equal(t, "ko", query["locale"])
headers, ok := payload["headers"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "*****", headers["Authorization"])
assert.Equal(t, "*****", headers["Cookie"])
}
+188 -180
View File
@@ -56,9 +56,9 @@
}
},
"node_modules/@babel/code-frame": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
"integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -71,9 +71,9 @@
}
},
"node_modules/@babel/compat-data": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz",
"integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -81,21 +81,21 @@
}
},
"node_modules/@babel/core": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz",
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
"@babel/helper-compilation-targets": "^7.28.6",
"@babel/helper-module-transforms": "^7.28.6",
"@babel/helpers": "^7.28.6",
"@babel/parser": "^7.28.6",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/traverse": "^7.28.6",
"@babel/types": "^7.28.6",
"@babel/traverse": "^7.29.0",
"@babel/types": "^7.29.0",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
@@ -112,14 +112,14 @@
}
},
"node_modules/@babel/generator": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz",
"integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==",
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.6",
"@babel/types": "^7.28.6",
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -242,13 +242,13 @@
}
},
"node_modules/@babel/parser": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz",
"integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.6"
"@babel/types": "^7.29.0"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -305,18 +305,18 @@
}
},
"node_modules/@babel/traverse": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz",
"integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.28.6",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/types": "^7.28.6",
"@babel/types": "^7.29.0",
"debug": "^4.3.1"
},
"engines": {
@@ -324,9 +324,9 @@
}
},
"node_modules/@babel/types": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz",
"integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -661,13 +661,13 @@
}
},
"node_modules/@playwright/test": {
"version": "1.58.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz",
"integrity": "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==",
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.0"
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
@@ -1363,9 +1363,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.53",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
"integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==",
"version": "1.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
"integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==",
"dev": true,
"license": "MIT"
},
@@ -1380,9 +1380,9 @@
}
},
"node_modules/@tanstack/query-devtools": {
"version": "5.92.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.92.0.tgz",
"integrity": "sha512-N8D27KH1vEpVacvZgJL27xC6yPFUy0Zkezn5gnB3L3gRCxlDeSuiya7fKge8Y91uMTnC8aSxBQhcK6ocY7alpQ==",
"version": "5.93.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.93.0.tgz",
"integrity": "sha512-+kpsx1NQnOFTZsw6HAFCW3HkKg0+2cepGtAWXjiiSOJJ1CtQpt72EE2nyZb+AjAbLRPoeRmPJ8MtQd8r8gsPdg==",
"license": "MIT",
"funding": {
"type": "github",
@@ -1390,9 +1390,9 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.90.20",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.20.tgz",
"integrity": "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==",
"version": "5.90.21",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.90.20"
@@ -1406,19 +1406,19 @@
}
},
"node_modules/@tanstack/react-query-devtools": {
"version": "5.91.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.2.tgz",
"integrity": "sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg==",
"version": "5.91.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.3.tgz",
"integrity": "sha512-nlahjMtd/J1h7IzOOfqeyDh5LNfG0eULwlltPEonYy0QL+nqrBB+nyzJfULV+moL7sZyxc2sHdNJki+vLA9BSA==",
"license": "MIT",
"dependencies": {
"@tanstack/query-devtools": "5.92.0"
"@tanstack/query-devtools": "5.93.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "^5.90.14",
"@tanstack/react-query": "^5.90.20",
"react": "^18 || ^19"
}
},
@@ -1479,9 +1479,9 @@
}
},
"node_modules/@types/node": {
"version": "24.10.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz",
"integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==",
"version": "24.10.13",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz",
"integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1489,9 +1489,9 @@
}
},
"node_modules/@types/react": {
"version": "19.2.9",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz",
"integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==",
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@@ -1509,16 +1509,16 @@
}
},
"node_modules/@vitejs/plugin-react": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz",
"integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==",
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz",
"integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.28.5",
"@babel/core": "^7.29.0",
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
"@rolldown/pluginutils": "1.0.0-beta.53",
"@rolldown/pluginutils": "1.0.0-rc.3",
"@types/babel__core": "^7.20.5",
"react-refresh": "^0.18.0"
},
@@ -1550,19 +1550,6 @@
"node": ">= 8"
}
},
"node_modules/anymatch/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/arg": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
@@ -1577,9 +1564,9 @@
"license": "MIT"
},
"node_modules/autoprefixer": {
"version": "10.4.23",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
"integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==",
"version": "10.4.24",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz",
"integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==",
"dev": true,
"funding": [
{
@@ -1598,7 +1585,7 @@
"license": "MIT",
"dependencies": {
"browserslist": "^4.28.1",
"caniuse-lite": "^1.0.30001760",
"caniuse-lite": "^1.0.30001766",
"fraction.js": "^5.3.4",
"picocolors": "^1.1.1",
"postcss-value-parser": "^4.2.0"
@@ -1614,24 +1601,27 @@
}
},
"node_modules/axios": {
"version": "1.13.3",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.3.tgz",
"integrity": "sha512-ERT8kdX7DZjtUm7IitEyV7InTHAF42iJuMArIiDIV5YtPanJkgw4hw5Dyg9fh0mihdWNn1GKaeIWErfe56UQ1g==",
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.18",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.18.tgz",
"integrity": "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==",
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
"baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/binary-extensions": {
@@ -1718,9 +1708,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001766",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz",
"integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==",
"version": "1.0.30001774",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz",
"integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==",
"dev": true,
"funding": [
{
@@ -1912,9 +1902,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.278",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.278.tgz",
"integrity": "sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw==",
"version": "1.5.302",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
"integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==",
"dev": true,
"license": "ISC"
},
@@ -2013,24 +2003,6 @@
"reusify": "^1.0.4"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -2095,9 +2067,9 @@
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -2676,19 +2648,6 @@
"node": ">=8.6"
}
},
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@@ -2812,13 +2771,13 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
@@ -2845,13 +2804,13 @@
}
},
"node_modules/playwright": {
"version": "1.58.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz",
"integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==",
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.0"
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
@@ -2864,9 +2823,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.58.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz",
"integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==",
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -2876,21 +2835,6 @@
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -3082,30 +3026,30 @@
"license": "MIT"
},
"node_modules/react": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.3"
"react": "^19.2.4"
}
},
"node_modules/react-hook-form": {
"version": "7.71.1",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz",
"integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==",
"version": "7.71.2",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
"integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
@@ -3196,19 +3140,6 @@
"node": ">=8.10.0"
}
},
"node_modules/readdirp/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -3368,9 +3299,9 @@
}
},
"node_modules/tailwind-merge": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
"integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
"integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==",
"license": "MIT",
"funding": {
"type": "github",
@@ -3465,6 +3396,37 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyglobby/node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -3638,6 +3600,52 @@
}
}
},
"node_modules/vite/node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/vite/node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
-85
View File
@@ -1,85 +0,0 @@
<!DOCTYPE html>
<html style='scrollbar-gutter: stable both-edges;'>
<head>
<meta charset='UTF-8'>
<meta name='color-scheme' content='dark light'>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
<title>Playwright Test Report</title>
<script type="module">var NA=Object.defineProperty;var BA=(l,u,c)=>u in l?NA(l,u,{enumerable:!0,configurable:!0,writable:!0,value:c}):l[u]=c;var yn=(l,u,c)=>BA(l,typeof u!="symbol"?u+"":u,c);(function(){const u=document.createElement("link").relList;if(u&&u.supports&&u.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))f(r);new MutationObserver(r=>{for(const o of r)if(o.type==="childList")for(const h of o.addedNodes)h.tagName==="LINK"&&h.rel==="modulepreload"&&f(h)}).observe(document,{childList:!0,subtree:!0});function c(r){const o={};return r.integrity&&(o.integrity=r.integrity),r.referrerPolicy&&(o.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?o.credentials="include":r.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function f(r){if(r.ep)return;r.ep=!0;const o=c(r);fetch(r.href,o)}})();function UA(l){return l&&l.__esModule&&Object.prototype.hasOwnProperty.call(l,"default")?l.default:l}var Mf={exports:{}},Ei={};/**
* @license React
* react-jsx-runtime.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var D1;function QA(){if(D1)return Ei;D1=1;var l=Symbol.for("react.transitional.element"),u=Symbol.for("react.fragment");function c(f,r,o){var h=null;if(o!==void 0&&(h=""+o),r.key!==void 0&&(h=""+r.key),"key"in r){o={};for(var v in r)v!=="key"&&(o[v]=r[v])}else o=r;return r=o.ref,{$$typeof:l,type:f,key:h,ref:r!==void 0?r:null,props:o}}return Ei.Fragment=u,Ei.jsx=c,Ei.jsxs=c,Ei}var M1;function zA(){return M1||(M1=1,Mf.exports=QA()),Mf.exports}var m=zA();const YA=15,xt=0,bn=1,LA=2,ye=-2,Ut=-3,j1=-4,xn=-5,Me=[0,1,3,7,15,31,63,127,255,511,1023,2047,4095,8191,16383,32767,65535],L2=1440,GA=0,XA=4,VA=9,ZA=5,qA=[96,7,256,0,8,80,0,8,16,84,8,115,82,7,31,0,8,112,0,8,48,0,9,192,80,7,10,0,8,96,0,8,32,0,9,160,0,8,0,0,8,128,0,8,64,0,9,224,80,7,6,0,8,88,0,8,24,0,9,144,83,7,59,0,8,120,0,8,56,0,9,208,81,7,17,0,8,104,0,8,40,0,9,176,0,8,8,0,8,136,0,8,72,0,9,240,80,7,4,0,8,84,0,8,20,85,8,227,83,7,43,0,8,116,0,8,52,0,9,200,81,7,13,0,8,100,0,8,36,0,9,168,0,8,4,0,8,132,0,8,68,0,9,232,80,7,8,0,8,92,0,8,28,0,9,152,84,7,83,0,8,124,0,8,60,0,9,216,82,7,23,0,8,108,0,8,44,0,9,184,0,8,12,0,8,140,0,8,76,0,9,248,80,7,3,0,8,82,0,8,18,85,8,163,83,7,35,0,8,114,0,8,50,0,9,196,81,7,11,0,8,98,0,8,34,0,9,164,0,8,2,0,8,130,0,8,66,0,9,228,80,7,7,0,8,90,0,8,26,0,9,148,84,7,67,0,8,122,0,8,58,0,9,212,82,7,19,0,8,106,0,8,42,0,9,180,0,8,10,0,8,138,0,8,74,0,9,244,80,7,5,0,8,86,0,8,22,192,8,0,83,7,51,0,8,118,0,8,54,0,9,204,81,7,15,0,8,102,0,8,38,0,9,172,0,8,6,0,8,134,0,8,70,0,9,236,80,7,9,0,8,94,0,8,30,0,9,156,84,7,99,0,8,126,0,8,62,0,9,220,82,7,27,0,8,110,0,8,46,0,9,188,0,8,14,0,8,142,0,8,78,0,9,252,96,7,256,0,8,81,0,8,17,85,8,131,82,7,31,0,8,113,0,8,49,0,9,194,80,7,10,0,8,97,0,8,33,0,9,162,0,8,1,0,8,129,0,8,65,0,9,226,80,7,6,0,8,89,0,8,25,0,9,146,83,7,59,0,8,121,0,8,57,0,9,210,81,7,17,0,8,105,0,8,41,0,9,178,0,8,9,0,8,137,0,8,73,0,9,242,80,7,4,0,8,85,0,8,21,80,8,258,83,7,43,0,8,117,0,8,53,0,9,202,81,7,13,0,8,101,0,8,37,0,9,170,0,8,5,0,8,133,0,8,69,0,9,234,80,7,8,0,8,93,0,8,29,0,9,154,84,7,83,0,8,125,0,8,61,0,9,218,82,7,23,0,8,109,0,8,45,0,9,186,0,8,13,0,8,141,0,8,77,0,9,250,80,7,3,0,8,83,0,8,19,85,8,195,83,7,35,0,8,115,0,8,51,0,9,198,81,7,11,0,8,99,0,8,35,0,9,166,0,8,3,0,8,131,0,8,67,0,9,230,80,7,7,0,8,91,0,8,27,0,9,150,84,7,67,0,8,123,0,8,59,0,9,214,82,7,19,0,8,107,0,8,43,0,9,182,0,8,11,0,8,139,0,8,75,0,9,246,80,7,5,0,8,87,0,8,23,192,8,0,83,7,51,0,8,119,0,8,55,0,9,206,81,7,15,0,8,103,0,8,39,0,9,174,0,8,7,0,8,135,0,8,71,0,9,238,80,7,9,0,8,95,0,8,31,0,9,158,84,7,99,0,8,127,0,8,63,0,9,222,82,7,27,0,8,111,0,8,47,0,9,190,0,8,15,0,8,143,0,8,79,0,9,254,96,7,256,0,8,80,0,8,16,84,8,115,82,7,31,0,8,112,0,8,48,0,9,193,80,7,10,0,8,96,0,8,32,0,9,161,0,8,0,0,8,128,0,8,64,0,9,225,80,7,6,0,8,88,0,8,24,0,9,145,83,7,59,0,8,120,0,8,56,0,9,209,81,7,17,0,8,104,0,8,40,0,9,177,0,8,8,0,8,136,0,8,72,0,9,241,80,7,4,0,8,84,0,8,20,85,8,227,83,7,43,0,8,116,0,8,52,0,9,201,81,7,13,0,8,100,0,8,36,0,9,169,0,8,4,0,8,132,0,8,68,0,9,233,80,7,8,0,8,92,0,8,28,0,9,153,84,7,83,0,8,124,0,8,60,0,9,217,82,7,23,0,8,108,0,8,44,0,9,185,0,8,12,0,8,140,0,8,76,0,9,249,80,7,3,0,8,82,0,8,18,85,8,163,83,7,35,0,8,114,0,8,50,0,9,197,81,7,11,0,8,98,0,8,34,0,9,165,0,8,2,0,8,130,0,8,66,0,9,229,80,7,7,0,8,90,0,8,26,0,9,149,84,7,67,0,8,122,0,8,58,0,9,213,82,7,19,0,8,106,0,8,42,0,9,181,0,8,10,0,8,138,0,8,74,0,9,245,80,7,5,0,8,86,0,8,22,192,8,0,83,7,51,0,8,118,0,8,54,0,9,205,81,7,15,0,8,102,0,8,38,0,9,173,0,8,6,0,8,134,0,8,70,0,9,237,80,7,9,0,8,94,0,8,30,0,9,157,84,7,99,0,8,126,0,8,62,0,9,221,82,7,27,0,8,110,0,8,46,0,9,189,0,8,14,0,8,142,0,8,78,0,9,253,96,7,256,0,8,81,0,8,17,85,8,131,82,7,31,0,8,113,0,8,49,0,9,195,80,7,10,0,8,97,0,8,33,0,9,163,0,8,1,0,8,129,0,8,65,0,9,227,80,7,6,0,8,89,0,8,25,0,9,147,83,7,59,0,8,121,0,8,57,0,9,211,81,7,17,0,8,105,0,8,41,0,9,179,0,8,9,0,8,137,0,8,73,0,9,243,80,7,4,0,8,85,0,8,21,80,8,258,83,7,43,0,8,117,0,8,53,0,9,203,81,7,13,0,8,101,0,8,37,0,9,171,0,8,5,0,8,133,0,8,69,0,9,235,80,7,8,0,8,93,0,8,29,0,9,155,84,7,83,0,8,125,0,8,61,0,9,219,82,7,23,0,8,109,0,8,45,0,9,187,0,8,13,0,8,141,0,8,77,0,9,251,80,7,3,0,8,83,0,8,19,85,8,195,83,7,35,0,8,115,0,8,51,0,9,199,81,7,11,0,8,99,0,8,35,0,9,167,0,8,3,0,8,131,0,8,67,0,9,231,80,7,7,0,8,91,0,8,27,0,9,151,84,7,67,0,8,123,0,8,59,0,9,215,82,7,19,0,8,107,0,8,43,0,9,183,0,8,11,0,8,139,0,8,75,0,9,247,80,7,5,0,8,87,0,8,23,192,8,0,83,7,51,0,8,119,0,8,55,0,9,207,81,7,15,0,8,103,0,8,39,0,9,175,0,8,7,0,8,135,0,8,71,0,9,239,80,7,9,0,8,95,0,8,31,0,9,159,84,7,99,0,8,127,0,8,63,0,9,223,82,7,27,0,8,111,0,8,47,0,9,191,0,8,15,0,8,143,0,8,79,0,9,255],IA=[80,5,1,87,5,257,83,5,17,91,5,4097,81,5,5,89,5,1025,85,5,65,93,5,16385,80,5,3,88,5,513,84,5,33,92,5,8193,82,5,9,90,5,2049,86,5,129,192,5,24577,80,5,2,87,5,385,83,5,25,91,5,6145,81,5,7,89,5,1537,85,5,97,93,5,24577,80,5,4,88,5,769,84,5,49,92,5,12289,82,5,13,90,5,3073,86,5,193,192,5,24577],KA=[3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99,115,131,163,195,227,258,0,0],kA=[0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,112,112],JA=[1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193,257,385,513,769,Line truncated
* @license React
* react.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var T2;function B5(){if(T2)return dt;T2=1;var l=Symbol.for("react.transitional.element"),u=Symbol.for("react.portal"),c=Symbol.for("react.fragment"),f=Symbol.for("react.strict_mode"),r=Symbol.for("react.profiler"),o=Symbol.for("react.consumer"),h=Symbol.for("react.context"),v=Symbol.for("react.forward_ref"),y=Symbol.for("react.suspense"),A=Symbol.for("react.memo"),E=Symbol.for("react.lazy"),S=Symbol.for("react.activity"),O=Symbol.iterator;function X(C){return C===null||typeof C!="object"?null:(C=O&&C[O]||C["@@iterator"],typeof C=="function"?C:null)}var B={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},b=Object.assign,p={};function x(C,L,W){this.props=C,this.context=L,this.refs=p,this.updater=W||B}x.prototype.isReactComponent={},x.prototype.setState=function(C,L){if(typeof C!="object"&&typeof C!="function"&&C!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,C,L,"setState")},x.prototype.forceUpdate=function(C){this.updater.enqueueForceUpdate(this,C,"forceUpdate")};function R(){}R.prototype=x.prototype;function U(C,L,W){this.props=C,this.context=L,this.refs=p,this.updater=W||B}var Z=U.prototype=new R;Z.constructor=U,b(Z,x.prototype),Z.isPureReactComponent=!0;var F=Array.isArray;function j(){}var D={H:null,A:null,T:null,S:null},N=Object.prototype.hasOwnProperty;function K(C,L,W){var et=W.ref;return{$$typeof:l,type:C,key:L,ref:et!==void 0?et:null,props:W}}function J(C,L){return K(C.type,L,C.props)}function k(C){return typeof C=="object"&&C!==null&&C.$$typeof===l}function nt(C){var L={"=":"=0",":":"=2"};return"$"+C.replace(/[=:]/g,function(W){return L[W]})}var P=/\/+/g;function st(C,L){return typeof C=="object"&&C!==null&&C.key!=null?nt(""+C.key):L.toString(36)}function it(C){switch(C.status){case"fulfilled":return C.value;case"rejected":throw C.reason;default:switch(typeof C.status=="string"?C.then(j,j):(C.status="pending",C.then(function(L){C.status==="pending"&&(C.status="fulfilled",C.value=L)},function(L){C.status==="pending"&&(C.status="rejected",C.reason=L)})),C.status){case"fulfilled":return C.value;case"rejected":throw C.reason}}throw C}function H(C,L,W,et,rt){var ot=typeof C;(ot==="undefined"||ot==="boolean")&&(C=null);var gt=!1;if(C===null)gt=!0;else switch(ot){case"bigint":case"string":case"number":gt=!0;break;case"object":switch(C.$$typeof){case l:case u:gt=!0;break;case E:return gt=C._init,H(gt(C._payload),L,W,et,rt)}}if(gt)return rt=rt(C),gt=et===""?"."+st(C,0):et,F(rt)?(W="",gt!=null&&(W=gt.replace(P,"$&/")+"/"),H(rt,L,W,"",function(On){return On})):rt!=null&&(k(rt)&&(rt=J(rt,W+(rt.key==null||C&&C.key===rt.key?"":(""+rt.key).replace(P,"$&/")+"/")+gt)),L.push(rt)),1;gt=0;var Ft=et===""?".":et+":";if(F(C))for(var Qt=0;Qt<C.length;Qt++)et=C[Qt],ot=Ft+st(et,Qt),gt+=H(et,L,W,ot,rt);else if(Qt=X(C),typeof Qt=="function")for(C=Qt.call(C),Qt=0;!(et=C.next()).done;)et=et.value,ot=Ft+st(et,Qt++),gt+=H(et,L,W,ot,rt);else if(ot==="object"){if(typeof C.then=="function")return H(it(C),L,W,et,rt);throw L=String(C),Error("Objects are not valid as a React child (found: "+(L==="[object Object]"?"object with keys {"+Object.keys(C).join(", ")+"}":L)+"). If you meant to render a collection of children, use an array instead.")}return gt}function _(C,L,W){if(C==null)return C;var et=[],rt=0;return H(C,et,"","",function(ot){return L.call(W,ot,rt++)}),et}function $(C){if(C._status===-1){var L=C._result;L=L(),L.then(function(W){(C._status===0||C._status===-1)&&(C._status=1,C._result=W)},function(W){(C._status===0||C._status===-1)&&(C._status=2,C._result=W)}),C._status===-1&&(C._status=0,C._result=L)}if(C._status===1)return C._result.default;throw C._result}var ht=typeof reportError=="function"?reportError:function(C){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var L=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof C=="object"&&C!==null&&typeof C.message=="string"?String(C.message):String(C),error:C});if(!window.dispatchEvent(L))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",C);return}console.error(C)},tt={map:_,forEach:function(C,L,W){_(C,function(){L.apply(this,arguments)},W)},count:function(C){var L=0;return _(C,function(){L++}),L},toArray:function(C){return _(C,function(L){return L})||[]},only:function(C){if(!k(C))throw Error("React.Children.only expected to receive a single React element child.");return C}};return dt.Activity=S,dt.Children=tt,dt.Component=x,dt.Fragment=c,dt.Profiler=r,dt.PureComponent=U,dt.StrictMode=f,dt.Suspense=y,dt.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=D,dt.__COMPILER_RUNTIME={__proto__:null,c:function(C){return D.H.useMemoCache(C)}},dt.cache=function(C){return function(){return C.apply(null,arguments)}},dt.cacheSignal=function(){return null},dt.cloneElement=function(C,L,W){if(C==nullLine truncated
* @license React
* scheduler.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var O2;function U5(){return O2||(O2=1,(function(l){function u(H,_){var $=H.length;H.push(_);t:for(;0<$;){var ht=$-1>>>1,tt=H[ht];if(0<r(tt,_))H[ht]=_,H[$]=tt,$=ht;else break t}}function c(H){return H.length===0?null:H[0]}function f(H){if(H.length===0)return null;var _=H[0],$=H.pop();if($!==_){H[0]=$;t:for(var ht=0,tt=H.length,C=tt>>>1;ht<C;){var L=2*(ht+1)-1,W=H[L],et=L+1,rt=H[et];if(0>r(W,$))et<tt&&0>r(rt,W)?(H[ht]=rt,H[et]=$,ht=et):(H[ht]=W,H[L]=$,ht=L);else if(et<tt&&0>r(rt,$))H[ht]=rt,H[et]=$,ht=et;else break t}}return _}function r(H,_){var $=H.sortIndex-_.sortIndex;return $!==0?$:H.id-_.id}if(l.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var o=performance;l.unstable_now=function(){return o.now()}}else{var h=Date,v=h.now();l.unstable_now=function(){return h.now()-v}}var y=[],A=[],E=1,S=null,O=3,X=!1,B=!1,b=!1,p=!1,x=typeof setTimeout=="function"?setTimeout:null,R=typeof clearTimeout=="function"?clearTimeout:null,U=typeof setImmediate<"u"?setImmediate:null;function Z(H){for(var _=c(A);_!==null;){if(_.callback===null)f(A);else if(_.startTime<=H)f(A),_.sortIndex=_.expirationTime,u(y,_);else break;_=c(A)}}function F(H){if(b=!1,Z(H),!B)if(c(y)!==null)B=!0,j||(j=!0,nt());else{var _=c(A);_!==null&&it(F,_.startTime-H)}}var j=!1,D=-1,N=5,K=-1;function J(){return p?!0:!(l.unstable_now()-K<N)}function k(){if(p=!1,j){var H=l.unstable_now();K=H;var _=!0;try{t:{B=!1,b&&(b=!1,R(D),D=-1),X=!0;var $=O;try{e:{for(Z(H),S=c(y);S!==null&&!(S.expirationTime>H&&J());){var ht=S.callback;if(typeof ht=="function"){S.callback=null,O=S.priorityLevel;var tt=ht(S.expirationTime<=H);if(H=l.unstable_now(),typeof tt=="function"){S.callback=tt,Z(H),_=!0;break e}S===c(y)&&f(y),Z(H)}else f(y);S=c(y)}if(S!==null)_=!0;else{var C=c(A);C!==null&&it(F,C.startTime-H),_=!1}}break t}finally{S=null,O=$,X=!1}_=void 0}}finally{_?nt():j=!1}}}var nt;if(typeof U=="function")nt=function(){U(k)};else if(typeof MessageChannel<"u"){var P=new MessageChannel,st=P.port2;P.port1.onmessage=k,nt=function(){st.postMessage(null)}}else nt=function(){x(k,0)};function it(H,_){D=x(function(){H(l.unstable_now())},_)}l.unstable_IdlePriority=5,l.unstable_ImmediatePriority=1,l.unstable_LowPriority=4,l.unstable_NormalPriority=3,l.unstable_Profiling=null,l.unstable_UserBlockingPriority=2,l.unstable_cancelCallback=function(H){H.callback=null},l.unstable_forceFrameRate=function(H){0>H||125<H?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):N=0<H?Math.floor(1e3/H):5},l.unstable_getCurrentPriorityLevel=function(){return O},l.unstable_next=function(H){switch(O){case 1:case 2:case 3:var _=3;break;default:_=O}var $=O;O=_;try{return H()}finally{O=$}},l.unstable_requestPaint=function(){p=!0},l.unstable_runWithPriority=function(H,_){switch(H){case 1:case 2:case 3:case 4:case 5:break;default:H=3}var $=O;O=H;try{return _()}finally{O=$}},l.unstable_scheduleCallback=function(H,_,$){var ht=l.unstable_now();switch(typeof $=="object"&&$!==null?($=$.delay,$=typeof $=="number"&&0<$?ht+$:ht):$=ht,H){case 1:var tt=-1;break;case 2:tt=250;break;case 5:tt=1073741823;break;case 4:tt=1e4;break;default:tt=5e3}return tt=$+tt,H={id:E++,callback:_,priorityLevel:H,startTime:$,expirationTime:tt,sortIndex:-1},$>ht?(H.sortIndex=$,u(A,H),c(y)===null&&H===c(A)&&(b?(R(D),D=-1):b=!0,it(F,$-ht))):(H.sortIndex=tt,u(y,H),B||X||(B=!0,j||(j=!0,nt()))),H},l.unstable_shouldYield=J,l.unstable_wrapCallback=function(H){var _=O;return function(){var $=O;O=_;try{return H.apply(this,arguments)}finally{O=$}}}})(qf)),qf}var w2;function Q5(){return w2||(w2=1,Zf.exports=U5()),Zf.exports}var If={exports:{}},ce={};/**
* @license React
* react-dom.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var R2;function z5(){if(R2)return ce;R2=1;var l=xr();function u(y){var A="https://react.dev/errors/"+y;if(1<arguments.length){A+="?args[]="+encodeURIComponent(arguments[1]);for(var E=2;E<arguments.length;E++)A+="&args[]="+encodeURIComponent(arguments[E])}return"Minified React error #"+y+"; visit "+A+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function c(){}var f={d:{f:c,r:function(){throw Error(u(522))},D:c,C:c,L:c,m:c,X:c,S:c,M:c},p:0,findDOMNode:null},r=Symbol.for("react.portal");function o(y,A,E){var S=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:r,key:S==null?null:""+S,children:y,containerInfo:A,implementation:E}}var h=l.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function v(y,A){if(y==="font")return"";if(typeof A=="string")return A==="use-credentials"?A:""}return ce.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=f,ce.createPortal=function(y,A){var E=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!A||A.nodeType!==1&&A.nodeType!==9&&A.nodeType!==11)throw Error(u(299));return o(y,A,null,E)},ce.flushSync=function(y){var A=h.T,E=f.p;try{if(h.T=null,f.p=2,y)return y()}finally{h.T=A,f.p=E,f.d.f()}},ce.preconnect=function(y,A){typeof y=="string"&&(A?(A=A.crossOrigin,A=typeof A=="string"?A==="use-credentials"?A:"":void 0):A=null,f.d.C(y,A))},ce.prefetchDNS=function(y){typeof y=="string"&&f.d.D(y)},ce.preinit=function(y,A){if(typeof y=="string"&&A&&typeof A.as=="string"){var E=A.as,S=v(E,A.crossOrigin),O=typeof A.integrity=="string"?A.integrity:void 0,X=typeof A.fetchPriority=="string"?A.fetchPriority:void 0;E==="style"?f.d.S(y,typeof A.precedence=="string"?A.precedence:void 0,{crossOrigin:S,integrity:O,fetchPriority:X}):E==="script"&&f.d.X(y,{crossOrigin:S,integrity:O,fetchPriority:X,nonce:typeof A.nonce=="string"?A.nonce:void 0})}},ce.preinitModule=function(y,A){if(typeof y=="string")if(typeof A=="object"&&A!==null){if(A.as==null||A.as==="script"){var E=v(A.as,A.crossOrigin);f.d.M(y,{crossOrigin:E,integrity:typeof A.integrity=="string"?A.integrity:void 0,nonce:typeof A.nonce=="string"?A.nonce:void 0})}}else A==null&&f.d.M(y)},ce.preload=function(y,A){if(typeof y=="string"&&typeof A=="object"&&A!==null&&typeof A.as=="string"){var E=A.as,S=v(E,A.crossOrigin);f.d.L(y,E,{crossOrigin:S,integrity:typeof A.integrity=="string"?A.integrity:void 0,nonce:typeof A.nonce=="string"?A.nonce:void 0,type:typeof A.type=="string"?A.type:void 0,fetchPriority:typeof A.fetchPriority=="string"?A.fetchPriority:void 0,referrerPolicy:typeof A.referrerPolicy=="string"?A.referrerPolicy:void 0,imageSrcSet:typeof A.imageSrcSet=="string"?A.imageSrcSet:void 0,imageSizes:typeof A.imageSizes=="string"?A.imageSizes:void 0,media:typeof A.media=="string"?A.media:void 0})}},ce.preloadModule=function(y,A){if(typeof y=="string")if(A){var E=v(A.as,A.crossOrigin);f.d.m(y,{as:typeof A.as=="string"&&A.as!=="script"?A.as:void 0,crossOrigin:E,integrity:typeof A.integrity=="string"?A.integrity:void 0})}else f.d.m(y)},ce.requestFormReset=function(y){f.d.r(y)},ce.unstable_batchedUpdates=function(y,A){return y(A)},ce.useFormState=function(y,A,E){return h.H.useFormState(y,A,E)},ce.useFormStatus=function(){return h.H.useHostTransitionStatus()},ce.version="19.2.1",ce}var D2;function Y5(){if(D2)return If.exports;D2=1;function l(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(l)}catch(u){console.error(u)}}return l(),If.exports=z5(),If.exports}/**
* @license React
* react-dom-client.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var M2;function L5(){if(M2)return xi;M2=1;var l=Q5(),u=xr(),c=Y5();function f(t){var e="https://react.dev/errors/"+t;if(1<arguments.length){e+="?args[]="+encodeURIComponent(arguments[1]);for(var n=2;n<arguments.length;n++)e+="&args[]="+encodeURIComponent(arguments[n])}return"Minified React error #"+t+"; visit "+e+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function r(t){return!(!t||t.nodeType!==1&&t.nodeType!==9&&t.nodeType!==11)}function o(t){var e=t,n=t;if(t.alternate)for(;e.return;)e=e.return;else{t=e;do e=t,(e.flags&4098)!==0&&(n=e.return),t=e.return;while(t)}return e.tag===3?n:null}function h(t){if(t.tag===13){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function v(t){if(t.tag===31){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function y(t){if(o(t)!==t)throw Error(f(188))}function A(t){var e=t.alternate;if(!e){if(e=o(t),e===null)throw Error(f(188));return e!==t?null:t}for(var n=t,a=e;;){var i=n.return;if(i===null)break;var s=i.alternate;if(s===null){if(a=i.return,a!==null){n=a;continue}break}if(i.child===s.child){for(s=i.child;s;){if(s===n)return y(i),t;if(s===a)return y(i),e;s=s.sibling}throw Error(f(188))}if(n.return!==a.return)n=i,a=s;else{for(var d=!1,g=i.child;g;){if(g===n){d=!0,n=i,a=s;break}if(g===a){d=!0,a=i,n=s;break}g=g.sibling}if(!d){for(g=s.child;g;){if(g===n){d=!0,n=s,a=i;break}if(g===a){d=!0,a=s,n=i;break}g=g.sibling}if(!d)throw Error(f(189))}}if(n.alternate!==a)throw Error(f(190))}if(n.tag!==3)throw Error(f(188));return n.stateNode.current===n?t:e}function E(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t;for(t=t.child;t!==null;){if(e=E(t),e!==null)return e;t=t.sibling}return null}var S=Object.assign,O=Symbol.for("react.element"),X=Symbol.for("react.transitional.element"),B=Symbol.for("react.portal"),b=Symbol.for("react.fragment"),p=Symbol.for("react.strict_mode"),x=Symbol.for("react.profiler"),R=Symbol.for("react.consumer"),U=Symbol.for("react.context"),Z=Symbol.for("react.forward_ref"),F=Symbol.for("react.suspense"),j=Symbol.for("react.suspense_list"),D=Symbol.for("react.memo"),N=Symbol.for("react.lazy"),K=Symbol.for("react.activity"),J=Symbol.for("react.memo_cache_sentinel"),k=Symbol.iterator;function nt(t){return t===null||typeof t!="object"?null:(t=k&&t[k]||t["@@iterator"],typeof t=="function"?t:null)}var P=Symbol.for("react.client.reference");function st(t){if(t==null)return null;if(typeof t=="function")return t.$$typeof===P?null:t.displayName||t.name||null;if(typeof t=="string")return t;switch(t){case b:return"Fragment";case x:return"Profiler";case p:return"StrictMode";case F:return"Suspense";case j:return"SuspenseList";case K:return"Activity"}if(typeof t=="object")switch(t.$$typeof){case B:return"Portal";case U:return t.displayName||"Context";case R:return(t._context.displayName||"Context")+".Consumer";case Z:var e=t.render;return t=t.displayName,t||(t=e.displayName||e.name||"",t=t!==""?"ForwardRef("+t+")":"ForwardRef"),t;case D:return e=t.displayName||null,e!==null?e:st(t.type)||"Memo";case N:e=t._payload,t=t._init;try{return st(t(e))}catch{}}return null}var it=Array.isArray,H=u.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,_=c.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,$={pending:!1,data:null,method:null,action:null},ht=[],tt=-1;function C(t){return{current:t}}function L(t){0>tt||(t.current=ht[tt],ht[tt]=null,tt--)}function W(t,e){tt++,ht[tt]=t.current,t.current=e}var et=C(null),rt=C(null),ot=C(null),gt=C(null);function Ft(t,e){switch(W(ot,e),W(rt,t),W(et,null),e.nodeType){case 9:case 11:t=(t=e.documentElement)&&(t=t.namespaceURI)?Pd(t):0;break;default:if(t=e.tagName,e=e.namespaceURI)e=Pd(e),t=$d(e,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}L(et),W(et,t)}function Qt(){L(et),L(rt),L(ot)}function On(t){t.memoizedState!==null&&W(gt,t);var e=et.current,n=$d(e,t.type);e!==n&&(W(rt,t),W(et,n))}function ra(t){rt.current===t&&(L(et),L(rt)),gt.current===t&&(L(gt),gi._currentValue=$)}var wn,Ba;function Ee(t){if(wn===void 0)try{throw Error()}catch(n){var e=n.stack.trim().match(/\n( *(at )?)/);wn=e&&e[1]||"",Ba=-1<n.stack.indexOf(`
at`)?" (<anonymous>)":-1<n.stack.indexOf("@")?"@unknown:0:0":""}return`
`+wn+t+Ba}var Ua=!1;function Rn(t,e){if(!t||Ua)return"";Ua=!0;var n=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var a={DetermineComponentFrameRoot:function(){try{if(e){var I=function(){throw Error()};if(Object.defineProperty(I.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(I,[])}catch(G){var Y=G}Reflect.construct(t,[],I)}else{try{I.call()}catch(G){Y=G}t.call(I.prototype)}}else{try{throw Error()}catch(G){Y=G}(I=t())&&typeof I.catch=="function"&&I.catch(function(){})}}catch(G){if(G&&Y&&typeof G.stack=="string")return[G.stack,Y.stack]}return[null,null]}};a.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var i=Object.getOwnPropertyDescriptor(a.DetermineComponentFrameRoot,"name");i&&i.configurable&&Object.defineProperty(a.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var s=a.DetermineComponentFrameRoot(),d=s[0],g=s[1];if(d&&g){var T=d.split(`
`),z=g.split(`
`);for(i=a=0;a<T.length&&!T[a].includes("DetermineComponentFrameRoot");)a++;for(;i<z.length&&!z[i].includes("DetermineComponentFrameRoot");)i++;if(a===T.length||i===z.length)for(a=T.length-1,i=z.length-1;1<=a&&0<=i&&T[a]!==z[i];)i--;for(;1<=a&&0<=i;a--,i--)if(T[a]!==z[i]){if(a!==1||i!==1)do if(a--,i--,0>i||T[a]!==z[i]){var V=`
`+T[a].replace(" at new "," at ");return t.displayName&&V.includes("<anonymous>")&&(V=V.replace("<anonymous>",t.displayName)),V}while(1<=a&&0<=i);break}}}finally{Ua=!1,Error.prepareStackTrace=n}return(n=t?t.displayName||t.name:"")?Ee(n):""}function Qa(t,e){switch(t.tag){case 26:case 27:case 5:return Ee(t.type);case 16:return Ee("Lazy");case 13:return t.child!==e&&e!==null?Ee("Suspense Fallback"):Ee("Suspense");case 19:return Ee("SuspenseList");case 0:case 15:return Rn(t.type,!1);case 11:return Rn(t.type.render,!1);case 1:return Rn(t.type,!0);case 31:return Ee("Activity");default:return""}}function Bi(t){try{var e="",n=null;do e+=Qa(t,n),n=t,t=t.return;while(t);return e}catch(a){return`
Error generating stack: `+a.message+`
`+a.stack}}var mc=Object.prototype.hasOwnProperty,gc=l.unstable_scheduleCallback,Ac=l.unstable_cancelCallback,om=l.unstable_shouldYield,dm=l.unstable_requestPaint,pe=l.unstable_now,hm=l.unstable_getCurrentPriorityLevel,Dr=l.unstable_ImmediatePriority,Mr=l.unstable_UserBlockingPriority,Ui=l.unstable_NormalPriority,mm=l.unstable_LowPriority,jr=l.unstable_IdlePriority,gm=l.log,Am=l.unstable_setDisableYieldValue,wl=null,be=null;function Dn(t){if(typeof gm=="function"&&Am(t),be&&typeof be.setStrictMode=="function")try{be.setStrictMode(wl,t)}catch{}}var xe=Math.clz32?Math.clz32:Em,vm=Math.log,ym=Math.LN2;function Em(t){return t>>>=0,t===0?32:31-(vm(t)/ym|0)|0}var Qi=256,zi=262144,Yi=4194304;function oa(t){var e=t&42;if(e!==0)return e;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&261888;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function Li(t,e,n){var a=t.pendingLanes;if(a===0)return 0;var i=0,s=t.suspendedLanes,d=t.pingedLanes;t=t.warmLanes;var g=a&134217727;return g!==0?(a=g&~s,a!==0?i=oa(a):(d&=g,d!==0?i=oa(d):n||(n=g&~t,n!==0&&(i=oa(n))))):(g=a&~s,g!==0?i=oa(g):d!==0?i=oa(d):n||(n=a&~t,n!==0&&(i=oa(n)))),i===0?0:e!==0&&e!==i&&(e&s)===0&&(s=i&-i,n=e&-e,s>=n||s===32&&(n&4194048)!==0)?e:i}function Rl(t,e){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&e)===0}function pm(t,e){switch(t){case 1:case 2:case 4:case 8:case 64:return e+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Hr(){var t=Yi;return Yi<<=1,(Yi&62914560)===0&&(Yi=4194304),t}function vc(t){for(var e=[],n=0;31>n;n++)e.push(t);return e}function Dl(t,e){t.pendingLanes|=e,e!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function bm(t,e,n,a,i,s){var d=t.pendingLanes;t.pendingLanes=n,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=n,t.entangledLanes&=n,t.errorRecoveryDisabledLanes&=n,t.shellSuspendCounter=0;var g=t.entanglements,T=t.expirationTimes,z=t.hiddenUpdates;for(n=d&~n;0<n;){var V=31-xe(n),I=1<<V;g[V]=0,T[V]=-1;var Y=z[V];if(Y!==null)for(z[V]=null,V=0;V<Y.length;V++){var G=Y[V];G!==null&&(G.lane&=-536870913)}n&=~I}a!==0&&Nr(t,a,0),s!==0&&i===0&&t.tag!==0&&(t.suspendedLanes|=s&~(d&~e))}function Nr(t,e,n){t.pendingLanes|=e,t.suspendedLanes&=~e;var a=31-xe(e);t.entangledLanes|=e,t.entanglements[a]=t.entanglements[a]|1073741824|n&261930}function Br(t,e){var n=t.entangledLanes|=e;for(t=t.entanglements;n;){var a=31-xe(n),i=1<<a;i&e|t[a]&e&&(t[a]|=e),n&=~i}}function Ur(t,e){var n=e&-e;return n=(n&42)!==0?1:yc(n),(n&(t.suspendedLanes|e))!==0?0:n}function yc(t){switch(t){case 2:t=1;break;case 8:t=4;break;case 32:t=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:t=128;break;case 268435456:t=134217728;break;default:t=0}return t}function Ec(t){return t&=-t,2<t?8<t?(t&134217727)!==0?32:268435456:8:2}function Qr(){var t=_.p;return t!==0?t:(t=window.event,t===void 0?32:x1(t.type))}function zr(t,e){var n=_.p;try{return _.p=t,e()}finally{_.p=n}}var Mn=Math.random().toString(36).slice(2),te="__reactFiber$"+Mn,oe="__reactProps$"+Mn,za="__reactContainer$"+Mn,pc="__reactEvents$"+Mn,xm="__reactListeners$"+Mn,Sm="__reactHandles$"+Mn,Yr="__reactResources$"+Mn,Ml="__reactMarker$"+Mn;function bc(t){delete t[te],delete t[oe],delete t[pc],delete t[xm],delete t[Sm]}function Ya(t){var e=t[te];if(e)return e;for(var n=t.parentNode;n;){if(e=n[za]||n[te]){if(n=e.alternate,e.child!==null||n!==null&&n.child!==null)for(t=u1(t);t!==null;){if(n=t[te])return n;t=u1(t)}return e}t=n,n=t.parentNode}return null}function La(t){if(t=t[te]||t[za]){var e=t.tag;if(e===5||e===6||e===13||e===31||e===26||e===27||e===3)return t}return null}function jl(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t.stateNode;throw Error(f(33))}function Ga(t){var e=t[Yr];return e||(e=t[Yr]={hoistableStyles:new Map,hoistableScripts:new Map}),e}function Wt(t){t[Ml]=!0}var Lr=new Set,Gr={};function da(t,e){Xa(t,e),Xa(t+"Capture",e)}function Xa(t,e){for(Gr[t]=e,t=0;t<e.length;t++)Lr.add(e[t])}var Tm=RegExp("^[:A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u21Line truncated
`).replace(eA,"")}function Wd(t,e){return e=Fd(e),Fd(t)===e}function Dt(t,e,n,a,i,s){switch(n){case"children":typeof a=="string"?e==="body"||e==="textarea"&&a===""||Za(t,a):(typeof a=="number"||typeof a=="bigint")&&e!=="body"&&Za(t,""+a);break;case"className":Xi(t,"class",a);break;case"tabIndex":Xi(t,"tabindex",a);break;case"dir":case"role":case"viewBox":case"width":case"height":Xi(t,n,a);break;case"style":Fr(t,a,s);break;case"data":if(e!=="object"){Xi(t,"data",a);break}case"src":case"href":if(a===""&&(e!=="a"||n!=="href")){t.removeAttribute(n);break}if(a==null||typeof a=="function"||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(n);break}a=Zi(""+a),t.setAttribute(n,a);break;case"action":case"formAction":if(typeof a=="function"){t.setAttribute(n,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof s=="function"&&(n==="formAction"?(e!=="input"&&Dt(t,e,"name",i.name,i,null),Dt(t,e,"formEncType",i.formEncType,i,null),Dt(t,e,"formMethod",i.formMethod,i,null),Dt(t,e,"formTarget",i.formTarget,i,null)):(Dt(t,e,"encType",i.encType,i,null),Dt(t,e,"method",i.method,i,null),Dt(t,e,"target",i.target,i,null)));if(a==null||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(n);break}a=Zi(""+a),t.setAttribute(n,a);break;case"onClick":a!=null&&(t.onclick=tn);break;case"onScroll":a!=null&&yt("scroll",t);break;case"onScrollEnd":a!=null&&yt("scrollend",t);break;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(n=a.__html,n!=null){if(i.children!=null)throw Error(f(60));t.innerHTML=n}}break;case"multiple":t.multiple=a&&typeof a!="function"&&typeof a!="symbol";break;case"muted":t.muted=a&&typeof a!="function"&&typeof a!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(a==null||typeof a=="function"||typeof a=="boolean"||typeof a=="symbol"){t.removeAttribute("xlink:href");break}n=Zi(""+a),t.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",n);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(n,""+a):t.removeAttribute(n);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":a&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(n,""):t.removeAttribute(n);break;case"capture":case"download":a===!0?t.setAttribute(n,""):a!==!1&&a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(n,a):t.removeAttribute(n);break;case"cols":case"rows":case"size":case"span":a!=null&&typeof a!="function"&&typeof a!="symbol"&&!isNaN(a)&&1<=a?t.setAttribute(n,a):t.removeAttribute(n);break;case"rowSpan":case"start":a==null||typeof a=="function"||typeof a=="symbol"||isNaN(a)?t.removeAttribute(n):t.setAttribute(n,a);break;case"popover":yt("beforetoggle",t),yt("toggle",t),Gi(t,"popover",a);break;case"xlinkActuate":$e(t,"http://www.w3.org/1999/xlink","xlink:actuate",a);break;case"xlinkArcrole":$e(t,"http://www.w3.org/1999/xlink","xlink:arcrole",a);break;case"xlinkRole":$e(t,"http://www.w3.org/1999/xlink","xlink:role",a);break;case"xlinkShow":$e(t,"http://www.w3.org/1999/xlink","xlink:show",a);break;case"xlinkTitle":$e(t,"http://www.w3.org/1999/xlink","xlink:title",a);break;case"xlinkType":$e(t,"http://www.w3.org/1999/xlink","xlink:type",a);break;case"xmlBase":$e(t,"http://www.w3.org/XML/1998/namespace","xml:base",a);break;case"xmlLang":$e(t,"http://www.w3.org/XML/1998/namespace","xml:lang",a);break;case"xmlSpace":$e(t,"http://www.w3.org/XML/1998/namespace","xml:space",a);break;case"is":Gi(t,"is",a);break;case"innerText":case"textContent":break;default:(!(2<n.length)||n[0]!=="o"&&n[0]!=="O"||n[1]!=="n"&&n[1]!=="N")&&(n=Dm.get(n)||n,Gi(t,n,a))}}function df(t,e,n,a,i,s){switch(n){case"style":Fr(t,a,s);break;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(n=a.__html,n!=null){if(i.children!=null)throw Error(f(60));t.innerHTML=n}}break;case"children":typeof a=="string"?Za(t,a):(typeof a=="number"||typeof a=="bigint")&&Za(t,""+a);break;case"onScroll":a!=null&&yt("scroll",t);break;case"onScrollEnd":a!=null&&yt("scrollend",t);break;case"onClick":a!=null&&(t.onclick=tn);break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"inneLine truncated
linear-gradient(-45deg, #80808020 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #80808020 75%),
linear-gradient(-45deg, transparent 75%, #80808020 75%)`,backgroundSize:"20px 20px",backgroundPosition:"0 0, 0 10px, 10px -10px, -10px 0px",boxShadow:`rgb(0 0 0 / 10%) 0px 1.8px 1.9px,
rgb(0 0 0 / 15%) 0px 6.1px 6.3px,
rgb(0 0 0 / 10%) 0px -2px 4px,
rgb(0 0 0 / 15%) 0px -6.1px 12px,
rgb(0 0 0 / 25%) 0px 6px 12px`},um=({diff:l,noTargetBlank:u,hideDetails:c})=>{const[f,r]=ct.useState(l.diff?"diff":"actual"),[o,h]=ct.useState(!1),[v,y]=ct.useState(null),[A,E]=ct.useState("Expected"),[S,O]=ct.useState(null),[X,B]=ct.useState(null),[b,p]=Wh();ct.useEffect(()=>{(async()=>{var K,J,k,nt;y(await kf((K=l.expected)==null?void 0:K.attachment.path)),E(((J=l.expected)==null?void 0:J.title)||"Expected"),O(await kf((k=l.actual)==null?void 0:k.attachment.path)),B(await kf((nt=l.diff)==null?void 0:nt.attachment.path))})()},[l]);const x=v&&S&&X,R=x?Math.max(v.naturalWidth,S.naturalWidth,200):500,U=x?Math.max(v.naturalHeight,S.naturalHeight,200):500,Z=Math.min(1,(b.width-30)/R),F=Math.min(1,(b.width-50)/R/2),j=R*Z,D=U*Z,N={flex:"none",margin:"0 10px",cursor:"pointer",userSelect:"none"};return m.jsx("div",{"data-testid":"test-result-image-mismatch",style:{display:"flex",flexDirection:"column",alignItems:"center",flex:"auto"},ref:p,children:x&&m.jsxs(m.Fragment,{children:[m.jsxs("div",{"data-testid":"test-result-image-mismatch-tabs",style:{display:"flex",margin:"10px 0 20px"},children:[l.diff&&m.jsx("div",{style:{...N,fontWeight:f==="diff"?600:"initial"},onClick:()=>r("diff"),children:"Diff"}),m.jsx("div",{style:{...N,fontWeight:f==="actual"?600:"initial"},onClick:()=>r("actual"),children:"Actual"}),m.jsx("div",{style:{...N,fontWeight:f==="expected"?600:"initial"},onClick:()=>r("expected"),children:A}),m.jsx("div",{style:{...N,fontWeight:f==="sxs"?600:"initial"},onClick:()=>r("sxs"),children:"Side by side"}),m.jsx("div",{style:{...N,fontWeight:f==="slider"?600:"initial"},onClick:()=>r("slider"),children:"Slider"})]}),m.jsxs("div",{style:{display:"flex",justifyContent:"center",flex:"auto",minHeight:D+60},children:[l.diff&&f==="diff"&&m.jsx(En,{image:X,alt:"Diff",hideSize:c,canvasWidth:j,canvasHeight:D,scale:Z}),l.diff&&f==="actual"&&m.jsx(En,{image:S,alt:"Actual",hideSize:c,canvasWidth:j,canvasHeight:D,scale:Z}),l.diff&&f==="expected"&&m.jsx(En,{image:v,alt:A,hideSize:c,canvasWidth:j,canvasHeight:D,scale:Z}),l.diff&&f==="slider"&&m.jsx(Dv,{expectedImage:v,actualImage:S,hideSize:c,canvasWidth:j,canvasHeight:D,scale:Z,expectedTitle:A}),l.diff&&f==="sxs"&&m.jsxs("div",{style:{display:"flex"},children:[m.jsx(En,{image:v,title:A,hideSize:c,canvasWidth:F*R,canvasHeight:F*U,scale:F}),m.jsx(En,{image:o?X:S,title:o?"Diff":"Actual",onClick:()=>h(!o),hideSize:c,canvasWidth:F*R,canvasHeight:F*U,scale:F})]}),!l.diff&&f==="actual"&&m.jsx(En,{image:S,title:"Actual",hideSize:c,canvasWidth:j,canvasHeight:D,scale:Z}),!l.diff&&f==="expected"&&m.jsx(En,{image:v,title:A,hideSize:c,canvasWidth:j,canvasHeight:D,scale:Z}),!l.diff&&f==="sxs"&&m.jsxs("div",{style:{display:"flex"},children:[m.jsx(En,{image:v,title:A,canvasWidth:F*R,canvasHeight:F*U,scale:F}),m.jsx(En,{image:S,title:"Actual",canvasWidth:F*R,canvasHeight:F*U,scale:F})]})]}),!c&&m.jsxs("div",{style:{alignSelf:"start",lineHeight:"18px",marginLeft:"15px"},children:[m.jsx("div",{children:l.diff&&m.jsx("a",{target:"_blank",href:l.diff.attachment.path,rel:"noreferrer",children:l.diff.attachment.name})}),m.jsx("div",{children:m.jsx("a",{target:u?"":"_blank",href:l.actual.attachment.path,rel:"noreferrer",children:l.actual.attachment.name})}),m.jsx("div",{children:m.jsx("a",{target:u?"":"_blank",href:l.expected.attachment.path,rel:"noreferrer",children:l.expected.attachment.name})})]})]})})},Dv=({expectedImage:l,actualImage:u,canvasWidth:c,canvasHeight:f,scale:r,expectedTitle:o,hideSize:h})=>{const v={position:"absolute",top:0,left:0},[y,A]=ct.useState(c/2),E=l.naturalWidth===u.naturalWidth&&l.naturalHeight===u.naturalHeight;return m.jsxs("div",{style:{flex:"none",display:"flex",alignItems:"center",flexDirection:"column",userSelect:"none"},children:[!h&&m.jsxs("div",{style:{margin:5},children:[!E&&m.jsx("span",{style:{flex:"none",margin:"0 5px"},children:"Expected "}),m.jsx("span",{children:l.naturalWidth}),m.jsx("span",{style:{flex:"none",margin:"0 5px"},children:"x"}),m.jsx("span",{children:l.naturalHeight}),!E&&m.jsx("span",{style:{flex:"none",margin:"0 5px 0 15px"},children:"Actual "}),!E&&m.jsx("span",{children:u.naturalWidth}),!E&&m.jsx("span",{style:{flex:"none",margin:"0 5px"},children:"x"}),!E&&m.jsx("span",{children:u.naturalHeight})]}),m.jsxs("div",{style:{position:"relative",width:c,height:f,margin:15,...fr},children:[m.jsx(Rv,{orientation:"horizontal",offsets:[y],setOffsets:S=>A(S[0]),resizerColor:"#57606a80",resizerWidth:6}),m.jsx("img",{alt:o,style:{width:l.naturalWidth*r,height:l.naturalHeight*r},draggable:"false",src:l.src}),m.jsx("div",{style:{...v,bottom:0,overflow:"hidden",width:y,...fr},children:m.jsx("img",{alt:"Actual",style:{width:u.naturalWidth*r,height:u.naturalHeight*r},draggable:"false",src:u.src})})]})]})},En=({image:l,title:u,alt:c,hideSize:f,canvasWidth:r,canvasHeight:o,scale:h,onClick:v})=>m.jsxs("div",{style:{flex:"none",display:"flex",alignItems:"center",flexDirection:"column"},children:[!f&&m.jsxs("div",{style:{margin:5},childrenLine truncated
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
`.trimStart();async function zv({testInfo:l,metadata:u,errorContext:c,errors:f,buildCodeFrame:r,stdout:o,stderr:h}){var S;const v=new Set(f.filter(O=>O.message&&!O.message.includes(`
`)).map(O=>O.message));for(const O of f)for(const X of v.keys())(S=O.message)!=null&&S.includes(X)&&v.delete(X);const y=f.filter(O=>!(!O.message||!O.message.includes(`
`)&&!v.has(O.message)));if(!y.length)return;const A=[Qv,"# Test info","",l];o&&A.push("","# Stdout","","```",Jf(o),"```"),h&&A.push("","# Stderr","","```",Jf(h),"```"),A.push("","# Error details");for(const O of y)A.push("","```",Jf(O.message||""),"```");c&&A.push(c);const E=await r(y[y.length-1]);return E&&A.push("","# Test source","","```ts",E,"```"),u!=null&&u.gitDiff&&A.push("","# Local changes","","```diff",u.gitDiff,"```"),A.join(`
`)}const Yv=new RegExp("([\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~])))","g");function Jf(l){return l.replace(Yv,"")}function Lv(l,u){var f;const c=new Map;for(const r of l){const o=r.name.match(/^(.*)-(expected|actual|diff|previous)(\.[^.]+)?$/);if(!o)continue;const[,h,v,y=""]=o,A=h+y;let E=c.get(A);E||(E={name:A,anchors:[`attachment-${h}`]},c.set(A,E)),E.anchors.push(`attachment-${u.attachments.indexOf(r)}`),v==="actual"&&(E.actual={attachment:r}),v==="expected"&&(E.expected={attachment:r,title:"Expected"}),v==="previous"&&(E.expected={attachment:r,title:"Previous"}),v==="diff"&&(E.diff={attachment:r})}for(const[r,o]of c)!o.actual||!o.expected?c.delete(r):(l.delete(o.actual.attachment),l.delete(o.expected.attachment),l.delete((f=o.diff)==null?void 0:f.attachment));return[...c.values()]}const Gv=({test:l,result:u,testRunMetadata:c,options:f})=>{const{screenshots:r,videos:o,traces:h,otherAttachments:v,diffs:y,errors:A,otherAttachmentAnchors:E,screenshotAnchors:S,errorContext:O}=ct.useMemo(()=>{const B=u.attachments.filter(N=>!N.name.startsWith("_")),b=new Set(B.filter(N=>N.contentType.startsWith("image/"))),p=[...b].map(N=>`attachment-${B.indexOf(N)}`),x=B.filter(N=>N.contentType.startsWith("video/")),R=B.filter(N=>N.name==="trace"),U=B.find(N=>N.name==="error-context"),Z=new Set(B);[...b,...x,...R].forEach(N=>Z.delete(N));const F=[...Z].map(N=>`attachment-${B.indexOf(N)}`),j=Lv(b,u),D=u.errors.map(N=>N.message);return{screenshots:[...b],videos:x,traces:R,otherAttachments:Z,diffs:j,errors:D,otherAttachmentAnchors:F,screenshotAnchors:p,errorContext:U}},[u]),X=P5(async()=>{if(f!=null&&f.noCopyPrompt)return;const B=u.attachments.find(R=>R.name==="stdout"),b=u.attachments.find(R=>R.name==="stderr"),p=B!=null&&B.body&&B.contentType==="text/plain"?B.body:void 0,x=b!=null&&b.body&&b.contentType==="text/plain"?b.body:void 0;return await zv({testInfo:[`- Name: ${l.path.join(" >> ")} >> ${l.title}`,`- Location: ${l.location.file}:${l.location.line}:${l.location.column}`].join(`
`),metadata:c,errorContext:O!=null&&O.path?await fetch(O.path).then(R=>R.text()):O==null?void 0:O.body,errors:u.errors,buildCodeFrame:async R=>R.codeframe,stdout:p,stderr:x})},[l,O,c,u],void 0);return m.jsxs("div",{className:"test-result",children:[!!A.length&&m.jsxs(ke,{header:"Errors",children:[X&&m.jsx("div",{style:{position:"absolute",right:"16px",padding:"10px",zIndex:1},children:m.jsx(Nv,{prompt:X})}),A.map((B,b)=>{const p=Xv(B,y);return m.jsxs(m.Fragment,{children:[m.jsx(wr,{code:B},"test-result-error-message-"+b),p&&m.jsx(Bv,{diff:p})]})})]}),!!u.steps.length&&m.jsx(ke,{header:"Test Steps",children:u.steps.map((B,b)=>m.jsx(cm,{step:B,result:u,test:l,depth:0},`step-${b}`))}),y.map((B,b)=>m.jsx(Si,{id:B.anchors,children:m.jsx(ke,{dataTestId:"test-results-image-diff",header:`Image mismatch: ${B.name}`,revealOnAnchorId:B.anchors,children:m.jsx(um,{diff:B})})},`diff-${b}`)),!!r.length&&m.jsx(ke,{header:"Screenshots",revealOnAnchorId:S,children:r.map((B,b)=>m.jsxs(Si,{id:`attachment-${u.attachments.indexOf(B)}`,children:[m.jsx("a",{href:Ve(B.path),children:m.jsx("img",{className:"screenshot",src:Ve(B.path)})}),m.jsx(nc,{attachment:B,result:u})]},`screenshot-${b}`))}),!!h.length&&m.jsx(Si,{id:"attachment-trace",children:m.jsx(ke,{header:"Traces",revealOnAnchorId:"attachment-trace",children:m.jsxs("div",{children:[m.jsx("a",{href:Ve(nm(h)),children:m.jsx("img",{className:"screenshot",src:Cv,style:{width:192,height:117,marginLeft:20}})}),h.map((B,b)=>m.jsx(nc,{attachment:B,result:u,linkName:h.length===1?"trace":`trace-${b+1}`},`trace-${b}`))]})})}),!!o.length&&m.jsx(Si,{id:"attachment-video",children:m.jsx(ke,{header:"Videos",revealOnAnchorId:"attachment-video",children:o.map(B=>m.jsxs("div",{children:[m.jsx("video",{controls:!0,children:m.jsx("source",{src:Ve(B.path),type:B.contentType})}),m.jsx(nc,{attachment:B,result:u})]},B.path))})}),!!v.size&&m.jsx(ke,{header:"Attachments",revealOnAnchorId:E,dataTestId:"attachments",children:[...v].map((B,b)=>m.jsx(Si,{id:`attachment-${u.attachments.indexOf(B)}`,children:m.jsx(nc,{attachment:B,result:u,openInNewTab:B.contentType.startsWith("text/html")})},`attachment-link-${b}`))})]})};function Xv(l,u){const c=l.split(`
`)[0];if(!(!c.includes("toHaveScreenshot")&&!c.includes("toMatchSnapshot")))return u.find(f=>l.includes(f.name))}const cm=({test:l,step:u,result:c,depth:f})=>{const r=se();return m.jsx(Tv,{title:m.jsxs("div",{"aria-label":u.title,className:"step-title-container",children:[hc(u.error||u.duration===-1?"failed":u.skipped?"skipped":"passed"),m.jsxs("span",{className:"step-title-text",children:[m.jsx("span",{children:u.title}),u.count>1&&m.jsxs(m.Fragment,{children:[" ✕ ",m.jsx("span",{className:"test-result-counter",children:u.count})]}),u.location&&m.jsxs("span",{className:"test-result-path",children:["— ",u.location.file,":",u.location.line]})]}),m.jsx("span",{className:"step-spacer"}),u.attachments.length>0&&m.jsx("a",{className:"step-attachment-link",title:"reveal attachment",href:Ve(Cn({test:l,result:c,anchor:`attachment-${u.attachments[0]}`},r)),onClick:o=>{o.stopPropagation()},children:Ih()}),m.jsx("span",{className:"step-duration",children:Ol(u.duration)})]}),loadChildren:u.steps.length||u.snippet?()=>{const o=u.snippet?[m.jsx(wr,{testId:"test-snippet",code:u.snippet},"line")]:[],h=u.steps.map((v,y)=>m.jsx(cm,{step:v,depth:f+1,result:c,test:l},y));return o.concat(h)}:void 0,depth:f})},Vv=({projectNames:l,test:u,testRunMetadata:c,run:f,next:r,prev:o,options:h})=>{const[v,y]=ct.useState(f),A=se(),E=u.annotations.filter(S=>!S.type.startsWith("_"))??[];return m.jsxs(m.Fragment,{children:[m.jsx(Or,{title:u.title,leftSuperHeader:m.jsx("div",{className:"test-case-path",children:u.path.join(" › ")}),rightSuperHeader:m.jsxs(m.Fragment,{children:[m.jsx("div",{className:Ze(!o&&"hidden"),children:m.jsx(Tn,{href:Cn({test:o},A),children:"« previous"})}),m.jsx("div",{style:{width:10}}),m.jsx("div",{className:Ze(!r&&"hidden"),children:m.jsx(Tn,{href:Cn({test:r},A),children:"next »"})})]})}),m.jsxs("div",{className:"hbox",style:{lineHeight:"24px"},children:[m.jsx("div",{className:"test-case-location",children:m.jsxs(Sr,{value:`${u.location.file}:${u.location.line}`,children:[u.location.file,":",u.location.line]})}),m.jsx("div",{style:{flex:"auto"}}),m.jsx(tm,{test:u,trailingSeparator:!0}),m.jsx("div",{className:"test-case-duration",children:Ol(u.duration)})]}),m.jsx($h,{style:{marginLeft:"6px"},projectNames:l,activeProjectName:u.projectName,otherLabels:u.tags}),u.results.length===0&&E.length!==0&&m.jsx(ke,{header:"Annotations",dataTestId:"test-case-annotations",children:E.map((S,O)=>m.jsx(z2,{annotation:S},O))}),m.jsx(Sv,{tabs:u.results.map((S,O)=>({id:String(O),title:m.jsxs("div",{style:{display:"flex",alignItems:"center"},children:[hc(S.status)," ",Zv(O),u.results.length>1&&m.jsx("span",{className:"test-case-run-duration",children:Ol(S.duration)})]}),render:()=>{const X=S.annotations.filter(B=>!B.type.startsWith("_"));return m.jsxs(m.Fragment,{children:[!!X.length&&m.jsx(ke,{header:"Annotations",dataTestId:"test-case-annotations",children:X.map((B,b)=>m.jsx(z2,{annotation:B},b))}),m.jsx(Gv,{test:u,result:S,testRunMetadata:c,options:h})]})}}))||[],selectedTab:String(v),setSelectedTab:S=>y(+S)})]})};function z2({annotation:{type:l,description:u}}){return m.jsxs("div",{className:"test-case-annotation",children:[m.jsx("span",{style:{fontWeight:"bold"},children:l}),u&&m.jsxs(Sr,{value:u,children:[": ",Di(u)]})]})}function Zv(l){return l?`Retry #${l}`:"Run"}const sm=({file:l,projectNames:u,isFileExpanded:c,setFileExpanded:f,footer:r})=>{const o=se();return m.jsx(im,{expanded:c?c(l.fileId):void 0,noInsets:!0,setExpanded:f?(h=>f(l.fileId,h)):void 0,header:m.jsx("span",{className:"chip-header-allow-selection",children:l.fileName}),footer:r,children:l.tests.map(h=>m.jsxs("div",{className:Ze("test-file-test","test-file-test-outcome-"+h.outcome),children:[m.jsxs("div",{className:"hbox",style:{alignItems:"flex-start"},children:[m.jsxs("div",{className:"hbox",children:[m.jsx("span",{className:"test-file-test-status-icon",children:hc(h.outcome)}),m.jsxs("span",{children:[m.jsx(Tn,{href:Cn({test:h},o),title:[...h.path,h.title].join(" › "),children:m.jsx("span",{className:"test-file-title",children:[...h.path,h.title].join(" › ")})}),m.jsx($h,{style:{marginLeft:"6px"},projectNames:u,activeProjectName:h.projectName,otherLabels:h.tags})]})]}),m.jsx("span",{"data-testid":"test-duration",style:{minWidth:"50px",textAlign:"right"},children:Ol(h.duration)})]}),m.jsx("div",{className:"test-file-details-row",children:m.jsxs("div",{className:"test-file-details-row-items",children:[m.jsx(Tn,{href:Cn({test:h},o),title:[...h.path,h.title].join(" › "),className:"test-file-path-link",children:m.jsxs("span",{className:"test-file-path",children:[h.location.file,":",h.location.line]})}),m.jsx(qv,{test:h}),m.jsx(Iv,{test:h}),m.jsx(tm,{test:h,dim:!0})]})})]},`test-${h.testId}`))})};function qv({test:l}){const u=se();for(const c of l.results)for(const f of c.attachments)if(f.contentType.startsWith("image/")&&f.name.match(/-(expected|actual|diff)/))return m.jsx(Tr,{href:Cn({test:l,result:c,anchor:`attachment-${c.attachments.indexOf(f)}`},u),Line truncated
</script>
<style type='text/css'>:root{--color-canvas-default-transparent: rgba(255,255,255,0);--color-marketing-icon-primary: #218bff;--color-marketing-icon-secondary: #54aeff;--color-diff-blob-addition-num-text: #24292f;--color-diff-blob-addition-fg: #24292f;--color-diff-blob-addition-num-bg: #CCFFD8;--color-diff-blob-addition-line-bg: #E6FFEC;--color-diff-blob-addition-word-bg: #ABF2BC;--color-diff-blob-deletion-num-text: #24292f;--color-diff-blob-deletion-fg: #24292f;--color-diff-blob-deletion-num-bg: #FFD7D5;--color-diff-blob-deletion-line-bg: #FFEBE9;--color-diff-blob-deletion-word-bg: rgba(255,129,130,.4);--color-diff-blob-hunk-num-bg: rgba(84,174,255,.4);--color-diff-blob-expander-icon: #57606a;--color-diff-blob-selected-line-highlight-mix-blend-mode: multiply;--color-diffstat-deletion-border: rgba(27,31,36,.15);--color-diffstat-addition-border: rgba(27,31,36,.15);--color-diffstat-addition-bg: #2da44e;--color-search-keyword-hl: #fff8c5;--color-prettylights-syntax-comment: #6e7781;--color-prettylights-syntax-constant: #0550ae;--color-prettylights-syntax-entity: #8250df;--color-prettylights-syntax-storage-modifier-import: #24292f;--color-prettylights-syntax-entity-tag: #116329;--color-prettylights-syntax-keyword: #cf222e;--color-prettylights-syntax-string: #0a3069;--color-prettylights-syntax-variable: #953800;--color-prettylights-syntax-brackethighlighter-unmatched: #82071e;--color-prettylights-syntax-invalid-illegal-text: #f6f8fa;--color-prettylights-syntax-invalid-illegal-bg: #82071e;--color-prettylights-syntax-carriage-return-text: #f6f8fa;--color-prettylights-syntax-carriage-return-bg: #cf222e;--color-prettylights-syntax-string-regexp: #116329;--color-prettylights-syntax-markup-list: #3b2300;--color-prettylights-syntax-markup-heading: #0550ae;--color-prettylights-syntax-markup-italic: #24292f;--color-prettylights-syntax-markup-bold: #24292f;--color-prettylights-syntax-markup-deleted-text: #82071e;--color-prettylights-syntax-markup-deleted-bg: #FFEBE9;--color-prettylights-syntax-markup-inserted-text: #116329;--color-prettylights-syntax-markup-inserted-bg: #dafbe1;--color-prettylights-syntax-markup-changed-text: #953800;--color-prettylights-syntax-markup-changed-bg: #ffd8b5;--color-prettylights-syntax-markup-ignored-text: #eaeef2;--color-prettylights-syntax-markup-ignored-bg: #0550ae;--color-prettylights-syntax-meta-diff-range: #8250df;--color-prettylights-syntax-brackethighlighter-angle: #57606a;--color-prettylights-syntax-sublimelinter-gutter-mark: #8c959f;--color-prettylights-syntax-constant-other-reference-link: #0a3069;--color-codemirror-text: #24292f;--color-codemirror-bg: #ffffff;--color-codemirror-gutters-bg: #ffffff;--color-codemirror-guttermarker-text: #ffffff;--color-codemirror-guttermarker-subtle-text: #6e7781;--color-codemirror-linenumber-text: #57606a;--color-codemirror-cursor: #24292f;--color-codemirror-selection-bg: rgba(84,174,255,.4);--color-codemirror-activeline-bg: rgba(234,238,242,.5);--color-codemirror-matchingbracket-text: #24292f;--color-codemirror-lines-bg: #ffffff;--color-codemirror-syntax-comment: #24292f;--color-codemirror-syntax-constant: #0550ae;--color-codemirror-syntax-entity: #8250df;--color-codemirror-syntax-keyword: #cf222e;--color-codemirror-syntax-storage: #cf222e;--color-codemirror-syntax-string: #0a3069;--color-codemirror-syntax-support: #0550ae;--color-codemirror-syntax-variable: #953800;--color-checks-bg: #24292f;--color-checks-run-border-width: 0px;--color-checks-container-border-width: 0px;--color-checks-text-primary: #f6f8fa;--color-checks-text-secondary: #8c959f;--color-checks-text-link: #54aeff;--color-checks-btn-icon: #afb8c1;--color-checks-btn-hover-icon: #f6f8fa;--color-checks-btn-hover-bg: rgba(255,255,255,.125);--color-checks-input-text: #eaeef2;--color-checks-input-placeholder-text: #8c959f;--color-checks-input-focus-text: #8c959f;--color-checks-input-bg: #32383f;--color-checks-input-shadow: none;--color-checks-donut-error: #fa4549;--color-checks-donut-pending: #bf8700;--color-checks-donut-success: #2da44e;--color-checks-donut-neutral: #afb8c1;--color-checks-dropdown-text: #afb8c1;--color-checks-dropdown-bg: #32383f;--color-checks-dropdown-border: #424a53;--color-checks-dropdown-shadow: rgba(27,31,36,.3);--color-checks-dropdown-hover-text: #f6f8fa;--color-checks-dropdown-hover-bg: #424a53;--color-checks-dropdown-btn-hover-text: #f6f8fa;--color-checks-dropdown-btn-hover-bg: #32383f;--color-checks-scrollbar-thumb-bg: #57606a;--color-checks-header-label-text: #d0d7de;--color-checks-header-label-open-text: #f6f8fa;--color-checks-header-border: #32383f;--color-checks-header-icon: #8c959f;--color-checks-line-text: #d0d7de;--color-checks-line-num-text: rgba(140,149,159,.75);--color-checks-line-timestamp-text: #8c959f;--color-checks-line-hover-bg: #32383f;--color-checks-line-selected-bg: rgba(33,139,255,.15);--color-checks-line-selected-num-text: #54aeff;--color-checks-line-dt-fm-text: #24292f;--color-checks-line-dt-fm-bg: #9a6700;--color-checks-gate-bg: rgba(1Line truncated
</style>
</head>
<body>
<div id='root'></div>
</body>
</html>
<script id="playwrightReportBase64" type="application/zip">data:application/zip;base64,UEsDBBQAAAgIAPhtQlyatMXYtQUAAGMtAAAZAAAAZDdjMWFlNDgyZGZhYWE5MDY1ZmUuanNvbu1aT2/bNhz9KgQvcQCVEf9IpFRswNptaIGhhyHYYXMGMDKdaHEsQ6LbBKmBbmsv3R/s0A7d0AwtVmzYsEMPOXRAP9HsfIeBsls7jGPLjpakq3NyZOmFv/fIn94jswfrcUNdr8EQ1niEpWKC1OpSysD1vbqCTv79DbmtYAijRqyaOkNZS0VIZ9CBWmU6g+Fne/mnE2Eu0brCtB5hgWkNBzUq/Jowj8e6MQIMWnJDgUYiaxmIkjRVkW7sQge20uQLFelXo9hMk+24vQ0d2EgiqeOkCcO9fJxjx9iImwqG1IFR0mhvN2GIOw6stdPBk5z7DpTNZqLzC6aaNQdquTH4lLR1lOR/WO20VKRVzYxI6s3+16nK2o0BBxZopmWqV+P8WeIS/5JLLrlk1WUh4yGjSHjep9Ag6HQXhq55QLUGbA6IuaLqSarAtSTZMqVMRfSxQRyOg3ExDvbDeEe3UwWqcD1NbmUqrcIi6JwfRacuHYf+kWw3o00wgC4CLKxhkyAYAq85UGoto81tI2z/QpS0mxqG2IHZVtxqqRoM67KRqc5MNzvjGImSplY7ugAjDGFBrIGPVfFqqqRWYIBcCNc7iov9c+PDrMlCZBDXIgNTPoENg1sI1aaC0rPgYl7ibsib8YapTyegClcG7agQgZQxa31RNrnUGfofG+l/rHNyLQ7MmuZ3DUMIAKDgNjCtvVKd2KSr0AEy221GoLLXv6GzDN55F+xVmwAABm4DAOQtGev8S7SR6KQyys7y5fxGkN848vN5ftkDt+Eoxx/kjRhUoU6uyZtq1VwuxLAvXGsyubg0hvmQYUKKM+znNa+sgMOv7/T2Dw6/uQMOf3rY23+Rl85HqOu/fyqGwWU0Unplpfv8QffpE/DP88fd5497v/wAencfd/++27v/bOUkZo8yLE5m+Ir6JM7idcMw2FD6yu6q2tGVpcMvD7r7L3v7B70fXxzefwG6f/7Rfbq/tFxABX6su5EpS3oGFbA7lIHNMNGD1zJ8/8hU9fsd0P3qAPR+fdn7+QE4vPdd7/4zU+dQG+yO1wYNWarC8TRV4bJR8DW1lWkqTdAO4xm0+zhpqMpSn59NJWsqXXLAHmjKbRWCpd7DJ4cPvu3+9lfvpZmKvXuPlkCniKIBtl7guLxlhb2hoF5hPTEbUDlUJK+9CkeLN23rVfVVeLz8KgSdZSdn2cvxJqrWl8M/PitKkudqzhC4/n5RVYi1zqaYiFlEEXOJwmcX5XXRI1qIolqYRd1ZvnyE//fqWqUFHTVDAbVMDZ1iqAvbpYBSa83MaWtO7WIDavkOMtbWX20k2SwmNmC2H/QunHFbc6BK0yQd3JdpqdsZDGFLZlke9I4FQwvbICRbMNRpu6/ExBiM1yXBtSiIAoyJG9BIMDJvDK7HqaonOyWlYOK7ovwY3EednitLz8G2zyOEeZPX7UxBOLAaa+CPRZ89CAeBDeyeW/CbpYUI317r3J+Q/Yo3EcFtx+7zc2OkYG/3kEsDO2eISXwUysIecplts1zx/wzDPsLEolC4ojT7sgjDzEcBs5skLY/hRRguFIY5woLb83yRhd/cLMwR4Zav98vbxFtk4fmyMEfUbnasxGX2doRhjqiwI6s3xVUXdEwc0YBdiDRsRmIZD05On4Y5YtTybvRMjrguchr2pKBcSVYPahEWRNYZ9+ZNw7fU+lasSwrD2Dc7IGWH4T7qNFdGcblh2EeUWK1PiLGw82RhH1FqHwqzMg6FfUTtsEPPZsWcsoH4yLe7pFfGobDB9a1X2Jnsp50mCfuI23uLzD91EDaoNhXTTNYbmoM5Iq49m4Ipsi9y8Cw5mCPh2rsqJTr2RQ4umIMDe6OP+FPa/SIIX+QgLBCxz3/olJ3bRRD+z4OwQMQ+Gilxlb0dOVggElgcUjzZUBe0SwbZehfNe/xzShc7ZiS4hENhAytm+p+ENz4Fr3X+BVBLAwQUAAAICAD4bUJcIa06vj0EAACOHgAAGQAAAGEzMGE2ZWJhNjMxMmY2Yjg3ZWE1Lmpzb27tmM1u4zYUhV+F4MoBFJoiKepn0AJt0WIKFLPKqnUK0BJtq7FFQ6ImGTjezWpWXXQZFC3QB8i2r9TkIQrKAuIwii3ZbsYo4pWsn2PeY/Hw413AUTqV3ycwgoJiweVQcOqSER8GvhQedKrr78RMwgjKKzGbTyUq5jJGuoAO1LLQBYx+WlRHz8qcDjHHnAoquUt9zhIcjALzeKqnRngiCrA6duA8V7/IWNc/GU9yNUvLGXTgVMVCpyqD0aIaVOOApmkmYUQdGKtpOctg5C4dmJR5/SRnvgNFlildnTBDP3egFuP6SJU6VnWtcxlrmZgRCT1ZXc5lUU7rgi3RQotcn6XVswQTforJKSZnmEXMjxhFQUh/hEZB5x9ghM0Dcl5bV7vwtRypXIK3Sl2YUrYrBkbxYRyMuU2y36VXuswlGMBhri4LmQ9gC/XQpY/VCfeb1H8QZRZPQC3dSpjbwvRB+NyBQmsRT2Yy0/WJWJWZhpEp7iKdz2UCo5GYFnLZ6WanyZFYZVpe6RaOMOQG5PHA3aDJkG9yKbQEtXIrXc/S/Xx+zMVYtjKDYNce9CY3jG4rVWarei/hxa7GvRPv07GpTyswgP1WzlFmvUaU+ptr7BB8bC342PL5IhxYZOa7hhEEAFBwDUyA9wYPUTyADhDFhywGvUX1/4HlCfjiS7AYZAAABq4BAOJSpLq6iMZKq17lwcmb6g5Q3bH2+bk67YFruG7ht1XOggHU6q14L89WP93CR/5kOrpb3pUOPvoPPhLS3kde1dzvg7oqsbKyqq5KBJFmAwgEKMphofM0G6PKFH/NzdXC0zOmnqA1U3r9u9vf7v78A/xze3N3e3P/+6/g/uPN3d8f7z/91X/O88feB+AaLE/ePPL/q5GWecs1h6HAnvaMbF5yWgdKgK31Bu848ffO+cAOIRI2JttUFV1iPiCWbHh0yXbuQJnnKq/vK7TQZQEjOBdFUZHQE3KytI2CuoCRzsvVH7ERCkceFZ5ImEfjhNIk9vzEawWFozSXI3V1ICYk1ADIoaFwpbqd4dyDU6GNVyzcMke7YaG97rPmCNiBC20MYtsWxqMAQw9RL9ywEu1Mhh6i3MYhHBw7GnqI+XbQuWxfNDSq3FYlRxege6EhRzS01sAgfEXDzmjoI4qtKKHbtpivaLgnGvqIc6sdwRtnfedA8RH3yVGgoRmJNUFZY0+kGxoaWd+SfZEOwDGzIROEy2GCueDDwMUyiWPSig0v5fAi1QdCQ5fi/6BfuFLdthiwVXvvYGjIkWdvsXjY+PruQoYcea5NQc0B0BEMOfKINWzXe5Gt055pwRG3G3tsEwe1jQujayXtZ+TkliHOkU+xHZ2N73QXKjSqdnLy/xkV+k+bol7IXqmwOxU+6V25/pbd1CsV7k2FoR+2aabtQIVhgI+ECsPAfrEaOyJdqTAMrB0vP7pkOywUni//BVBLAwQUAAAICAD4bUJchXBHDh0CAADQCAAACwAAAHJlcG9ydC5qc29uzZTBbtswDIZfJeBZLSTLlmy/wS47DdhhyIGWqMaLbBmyjLUI8u6DE6dNsaxdiiKYT6Qskz8/k9xBRwktJoR6B2jShP57iFuKI9T5nsGYMKZvbUdQC60551opoZVkYKeIqQ091KrS4j5XRXV6GLjW0wj1j93B+mKhBquNQMrLzDpErLgqHMHx5lec44PxLfVpvB8HMvdpBAaJxnQMM1t/DXMnHQnpjCiFtKKyslS2nD9vkz8LvBrwgVY+oB1XJsRIJvknYDDE8JNMOqnYxNC1UwcMfDBLicc6Lmr0bU9QSwYm+KnroRb7czhaKwbY9yEdDuZq1gwSPixWmJIJh8T0OJBJZGdFmDbL6y3UKU7EINI4+YUGpoRm081K5mv79Z69h0g0mAlrKlMJkfFKmjLPPorItZFcePwkQpni5X+BqMBSasLcVdaIMkOX6+KjiH5Rs23TJxESKpO3ILQ+zPvs7iCFhP6g6Tnk7Ez9i8sZOI/bp4M1btthWE5P+fYz8+cFgJKjogaVFJlTTakJi9cLgB6xGzy9vQAuhblruOJKoiQlpFa55aU7XwAbHFdH+7pp/1PQO39K5frWrXyRhyskFmjzQhorpTWFtsU/8XhztK/GkUl1k8Z9l0eOmaLGcoWqKQUna0z2TzzemuOrcQjJb9Ienz/H61dc5jznk/PSNAuuCwLUuQB1rQAGFGOIJ0zDQm+3Z9Ch2bQ9HWv/DVBLAQI/AxQAAAgIAPhtQlyatMXYtQUAAGMtAAAZAAAAAAAAAAAAAAC0gQAAAABkN2MxYWU0ODJkZmFhYTkwNjVmZS5qc29uUEsBAj8DFAAACAgA+G1CXCGtOr49BAAAjh4AABkAAAAAAAAAAAAAALSB7AUAAGEzMGE2ZWJhNjMxMmY2Yjg3ZWE1Lmpzb25QSwECPwMUAAAICAD4bUJchXBHDh0CAADQCAAACwAAAAAAAAAAAAAAtIFgCgAAcmVwb3J0Lmpzb25QSwUGAAAAAAMAAwDHAAAApgwAAAAA</script>
+72 -50
View File
@@ -1,7 +1,7 @@
import { BadgeCheck, LogOut, Moon, ShieldHalf, Sun } from "lucide-react";
import { useEffect, useState } from "react";
import { useAuth } from "react-oidc-context";
import { NavLink, Outlet } from "react-router-dom";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { t } from "../../lib/i18n";
import LanguageSelector from "../common/LanguageSelector";
import { Toaster } from "../ui/toaster";
@@ -16,11 +16,20 @@ const navItems = [
];
function AppLayout() {
const auth = useAuth();
const navigate = useNavigate();
const [theme, setTheme] = useState<"light" | "dark">(() => {
const stored = window.localStorage.getItem("admin_theme");
return stored === "dark" ? "dark" : "light";
});
const handleLogout = () => {
if (window.confirm(t("msg.dev.logout_confirm", "로그아웃 하시겠습니까?"))) {
auth.removeUser();
navigate("/login");
}
};
useEffect(() => {
const root = document.documentElement;
root.classList.remove("light", "dark");
@@ -38,60 +47,73 @@ function AppLayout() {
return (
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
<aside className="border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur">
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
<div className="flex items-center gap-3 md:flex-col md:items-start">
<div className="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]">
<ShieldHalf size={20} />
<aside className="border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur flex flex-col justify-between">
<div>
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
<div className="flex items-center gap-3 md:flex-col md:items-start">
<div className="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]">
<ShieldHalf size={20} />
</div>
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
{t("ui.dev.brand", "Baron 로그인")}
</p>
<h1 className="text-lg font-semibold">
{t("ui.dev.console_title", "Developer Console")}
</h1>
</div>
</div>
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
{t("ui.dev.brand", "Baron 로그인")}
</p>
<h1 className="text-lg font-semibold">
{t("ui.dev.console_title", "Developer Console")}
</h1>
<div className="hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2">
<BadgeCheck size={14} />
{t("ui.dev.scope_badge", "Scoped to /dev")}
</div>
</div>
<div className="hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2">
<BadgeCheck size={14} />
{t("ui.dev.scope_badge", "Scoped to /dev")}
<nav className="px-2 pb-4 md:px-3 md:pb-8">
<div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start">
<span className="rounded-full border border-border px-3 py-1">
{t("ui.dev.env_badge", "Env: dev")}
</span>
</div>
<div className="flex flex-col gap-1">
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
[
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
isActive
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
].join(" ")
}
>
<Icon size={18} />
<span>{t(labelKey, labelFallback)}</span>
</NavLink>
))}
</div>
</nav>
<div className="hidden space-y-2 px-5 pb-6 text-xs text-[var(--color-muted)] md:block">
<p>{t("msg.dev.sidebar.notice", "개발자 전용 콘솔입니다.")}</p>
<p>
{t(
"msg.dev.sidebar.notice_detail",
"클라이언트 애플리케이션 등록 및 관리를 수행할 수 있습니다.",
)}
</p>
</div>
</div>
<nav className="px-2 pb-4 md:px-3 md:pb-8">
<div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start">
<span className="rounded-full border border-border px-3 py-1">
{t("ui.dev.env_badge", "Env: dev")}
</span>
</div>
<div className="flex flex-col gap-1">
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
[
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
isActive
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
].join(" ")
}
>
<Icon size={18} />
<span>{t(labelKey, labelFallback)}</span>
</NavLink>
))}
</div>
</nav>
<div className="hidden space-y-2 px-5 pb-6 text-xs text-[var(--color-muted)] md:block">
<p>{t("msg.dev.sidebar.notice", "개발자 전용 콘솔입니다.")}</p>
<p>
{t(
"msg.dev.sidebar.notice_detail",
"클라이언트 애플리케이션 등록 및 관리를 수행할 수 있습니다.",
)}
</p>
<div className="px-2 pb-6 md:px-3">
<button
type="button"
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-muted/10 hover:text-foreground"
>
<LogOut size={18} />
<span>{t("ui.dev.nav.logout", "Logout")}</span>
</button>
</div>
</aside>
@@ -69,7 +69,7 @@ function ClientConsentsPage() {
</Link>
<span>/</span>
<Link to="/clients" className="hover:text-primary">
{t("ui.dev.clients.consents.breadcrumb.clients", "Clients")}
{t("ui.dev.clients.consents.breadcrumb.clients", "Apps")}
</Link>
<span>/</span>
<span>{clientData?.client?.name || clientId}</span>
@@ -124,7 +124,7 @@ function ClientDetailsPage() {
if (isLoading) {
return (
<div className="p-8 text-center">
{t("msg.dev.clients.details.loading", "Loading client...")}
{t("msg.dev.clients.details.loading", "Loading app...")}
</div>
);
}
@@ -137,7 +137,7 @@ function ClientDetailsPage() {
<div className="p-8 text-center text-red-500">
{t(
"msg.dev.clients.details.load_error",
"Error loading client: {{error}}",
"Error loading app: {{error}}",
{ error: errMsg || t("msg.common.unknown_error", "unknown error") },
)}
</div>
@@ -185,7 +185,7 @@ function ClientDetailsPage() {
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<Link to="/clients" className="text-primary hover:underline">
{t("ui.dev.clients.details.breadcrumb.section", "Relying Parties")}
{t("ui.dev.clients.details.breadcrumb.section", "Apps")}
</Link>
<span>/</span>
<span className="text-foreground">
@@ -47,7 +47,7 @@ function ClientGeneralPage() {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [logoUrl, setLogoUrl] = useState("");
const [clientType, setClientType] = useState<ClientType>("confidential");
const [clientType, setClientType] = useState<ClientType>("private");
const [status, setStatus] = useState<ClientStatus>("active");
const [redirectUris, setRedirectUris] = useState("");
const [scopes, setScopes] = useState<ScopeItem[]>(() => [
@@ -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() {
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b border-border text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="px-4 py-3 text-left font-bold">Scope Name</th>
<th className="px-4 py-3 text-left font-bold">
{t(
"ui.dev.clients.general.scopes.table.name",
@@ -395,7 +409,9 @@ function ClientGeneralPage() {
"Mandatory",
)}
</th>
<th className="px-4 py-3 text-right" />
<th className="px-4 py-3 text-right font-bold">
{t("ui.dev.clients.general.scopes.table.delete", "Delete")}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
@@ -489,7 +505,7 @@ function ClientGeneralPage() {
<label
className={cn(
"relative flex cursor-pointer flex-col gap-1 rounded-xl border-2 p-4 transition",
clientType === "confidential"
clientType === "private"
? "border-primary bg-primary/5"
: "border-border bg-card hover:border-muted-foreground/40",
)}
@@ -498,31 +514,28 @@ function ClientGeneralPage() {
className="sr-only"
type="radio"
name="client-type"
checked={clientType === "confidential"}
onChange={() => setClientType("confidential")}
checked={clientType === "private"}
onChange={() => setClientType("private")}
/>
<span className="flex items-center gap-2 text-sm font-bold uppercase text-foreground">
<Shield className="h-4 w-4 text-primary" />
{t(
"ui.dev.clients.general.security.confidential",
"Confidential",
)}
{t("ui.dev.clients.general.security.private", "Private")}
</span>
<span className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.security.confidential_help",
"msg.dev.clients.general.security.private_help",
"서버 사이드 앱(예: Node.js, Java)처럼 비밀키를 안전하게 보관 가능한 경우.",
)}
</span>
<span className="absolute right-4 top-4 text-primary">
{clientType === "confidential" ? "✓" : ""}
{clientType === "private" ? "✓" : ""}
</span>
</label>
<label
className={cn(
"relative flex cursor-pointer flex-col gap-1 rounded-xl border-2 p-4 transition",
clientType === "public"
clientType === "pkce"
? "border-primary bg-primary/5"
: "border-border bg-card hover:border-muted-foreground/40",
)}
@@ -531,21 +544,21 @@ function ClientGeneralPage() {
className="sr-only"
type="radio"
name="client-type"
checked={clientType === "public"}
onChange={() => setClientType("public")}
checked={clientType === "pkce"}
onChange={() => setClientType("pkce")}
/>
<span className="flex items-center gap-2 text-sm font-bold uppercase text-foreground">
<Sparkles className="h-4 w-4" />
{t("ui.dev.clients.general.security.public", "Public")}
{t("ui.dev.clients.general.security.pkce", "PKCE")}
</span>
<span className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.security.public_help",
"msg.dev.clients.general.security.pkce_help",
"SPA/모바일 앱처럼 비밀키 보관이 어려운 경우. PKCE를 기본 사용합니다.",
)}
</span>
<span className="absolute right-4 top-4 text-primary">
{clientType === "public" ? "✓" : ""}
{clientType === "pkce" ? "✓" : ""}
</span>
</label>
</div>
+14 -16
View File
@@ -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() {
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
{client.type === "confidential" ? (
{client.type === "private" ? (
<ServerCog className="h-4 w-4" />
) : (
<ShieldHalf className="h-4 w-4" />
@@ -309,16 +312,11 @@ function ClientsPage() {
</TableCell>
<TableCell>
<Badge
variant={
client.type === "confidential" ? "success" : "muted"
}
variant={client.type === "private" ? "success" : "muted"}
>
{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")}
</Badge>
</TableCell>
<TableCell>
+1 -1
View File
@@ -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;
+21 -15
View File
@@ -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"
+27 -21
View File
@@ -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"
+12 -6
View File
@@ -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 = ""
+2 -2
View File
@@ -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(
+8
View File
@@ -1,5 +1,13 @@
# AGENTS 가이드 (Baron SSO)
## 버그 수정 절차 대원칙 (강제)
- 버그 대응 시 **재현 테스트를 먼저 작성**합니다.
- 재현 테스트가 실패하는 상태를 확인한 뒤에만 수정 작업을 시작합니다.
- 수정 후에는 테스트를 반복 실행하여 재현 테스트가 안정적으로 통과할 때까지 계속 보완합니다.
- 재현 테스트 없이 “감으로 수정”하거나, 실패 테스트를 남긴 채 성공으로 보고하지 않습니다.
- 이슈 종료 전에는 최소 1회 이상 실제 사용자 경로(예: 로그인/새로고침/리다이렉트)를 확인합니다.
- 테스트/원인/조치 내역은 문서(`docs/test-plan/*`, `docs/trouble-shooting/*`)에 반영합니다.
## 목적
- 인증/인가 허브로서 **Backend + Ory Stack** 중심 아키텍처를 유지
- 사용자 플로우(UserFront)와 관리 플로우(Admin/DevFront)를 명확히 분리
+2
View File
@@ -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) 유지 원칙
- 신규 기능은 관련 테스트를 반드시 추가합니다.
+7 -3
View File
@@ -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` | 오류/예외/거부 경로 검증 |
+8 -1
View File
@@ -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` | 기본 앱 렌더링 스모크 검증 |
@@ -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) 운영 원칙
- 버그는 반드시 재현 테스트를 먼저 추가합니다.
- 재현 테스트가 실패하는 상태를 확인한 뒤 수정합니다.
- 수정 후 동일 테스트를 반복 실행해 안정 통과까지 완료합니다.
+1
View File
@@ -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`에서 별도 관리
| 파일 | 테스트 | 역할 |
|---|---|---|
@@ -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 동시 수집
+23 -15
View File
@@ -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"
+29 -21
View File
@@ -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"
+14 -6
View File
@@ -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 = ""
+240
View File
@@ -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()
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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 = "로그인하기"
@@ -38,6 +38,7 @@ approved_device = ""
approved_ip = ""
audit_empty = ""
audit_load_error = ""
render_error = ""
auth_method = ""
client_id = ""
client_id_missing = ""
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 954 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 21 KiB

+33 -5
View File
@@ -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<LocaleGate> {
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();
}
}
void _scheduleLocaleSync() {
if (_syncScheduled) {
return;
}
_syncScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_syncScheduled = false;
if (!mounted) {
return;
}
unawaited(_applyLocale());
});
}
Future<void> _applyLocale() async {
if (!mounted) {
return;
}
final normalized = normalizeLocaleCode(widget.localeCode);
LocaleStorage.write(normalized);
webWindow.setTitle(tr('ui.userfront.app_title'));
if (context.locale.languageCode == 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'));
}
@@ -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;
}
+17 -3
View File
@@ -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';
}
@@ -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;
}
@@ -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() {
@@ -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);
},
@@ -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<CreateUserScreen> {
// 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<CreateUserScreen> {
backgroundColor: Colors.red,
),
);
context.go('/'); // Kick out
context.go(buildLocalizedHomePath(Uri.base)); // Kick out
}
}
}
@@ -178,7 +179,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
title: const Text('Create User'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/'),
onPressed: () => context.go(buildLocalizedHomePath(Uri.base)),
),
),
body: Center(
@@ -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<UserManagementScreen>
);
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<UserManagementScreen>
backgroundColor: Colors.red,
),
);
context.go('/');
context.go(buildLocalizedHomePath(Uri.base));
}
}
}
@@ -365,7 +366,7 @@ class _UserManagementScreenState extends State<UserManagementScreen>
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,
@@ -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;
}
@@ -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];
}
@@ -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<ApproveQrScreen> {
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<bool> _bootstrapCookieSession() async {
@@ -47,18 +52,44 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
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<void> _handleApprove() async {
if (widget.pendingRef == null) return;
@@ -70,7 +101,8 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
}
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<ApproveQrScreen> {
// 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<ApproveQrScreen> {
@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<ApproveQrScreen> {
),
),
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"),
),
],
@@ -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<ConsentScreen> {
if (redirectTo != null) {
webWindow.redirectTo(redirectTo);
} else {
if (mounted) context.go('/');
if (mounted) context.go(buildLocalizedHomePath(Uri.base));
}
} catch (e) {
setState(() => _isSubmitting = false);
@@ -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(
@@ -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<LoginScreen>
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<LoginScreen>
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<LoginScreen>
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<LoginScreen>
}
Future<void> _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<LoginScreen>
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<LoginScreen>
Future<void> _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<LoginScreen>
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<LoginScreen>
}
if (mounted) {
context.go('/');
_goLocalizedHomeOnce();
}
}
void _goLocalizedHomeOnce() {
if (!mounted || _localNavigationCompleted) {
return;
}
_localNavigationCompleted = true;
context.go(buildLocalizedHomePath(Uri.base));
}
Future<void> _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<LoginScreen>
}
Future<bool> _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<LoginScreen>
}
}
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<LoginScreen>
}
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<LoginScreen>
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<LoginScreen>
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<LoginScreen>
"[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<LoginScreen>
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<LoginScreen>
}
// [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<LoginScreen>
return;
}
AuthNotifier.instance.notify();
if (mounted) {
context.go('/');
_goLocalizedHomeOnce();
}
} catch (globalErr) {
// ignore
@@ -1237,7 +1278,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
title: Text(_verificationPageTitle),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/'),
onPressed: () => context.go(buildLocalizedHomePath(Uri.base)),
),
),
body: _buildVerificationResultView(),
@@ -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'),
@@ -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<void> Function();
typedef QrStopCamera = Future<void> Function();
bool isQrPermissionError(Object error) {
final raw = error.toString();
return raw.contains('NotAllowedError') ||
raw.contains('PermissionDeniedError') ||
raw.contains('SecurityError');
}
Future<QrCameraBootstrapResult> 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(),
);
}
}
@@ -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)}';
}
@@ -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<QRScanScreen> createState() => _QRScanScreenState();
}
class _QRScanScreenState extends State<QRScanScreen> {
@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';
@@ -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<QRScanScreen> createState() => _QRScanScreenState();
}
class _QRScanScreenState extends State<QRScanScreen> {
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: '승인 화면으로 이동'),
),
),
],
),
),
);
}
}
@@ -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<QRScanScreen> createState() => _QRScanScreenState();
}
class _QRScanScreenState extends State<QRScanScreen> {
final MobileScannerController _scannerController = MobileScannerController(
autoStart: true,
detectionSpeed: DetectionSpeed.noDuplicates,
facing: CameraFacing.back,
formats: const <BarcodeFormat>[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<void> _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<void> _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: '승인 화면으로 이동'),
),
),
],
),
),
);
}
}
@@ -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<ResetPasswordScreen> {
backgroundColor: Colors.green,
),
);
context.go('/signin');
context.go(buildLocalizedSigninPath(Uri.base));
}
} catch (e) {
if (mounted) {
@@ -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<SignupScreen> {
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')),
),
],
@@ -133,7 +133,8 @@ class AuthTimelineNotifier extends Notifier<AuthTimelineState> {
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);
@@ -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<DashboardScreen> {
bool _auditLoadingMore = false;
bool _isRevoking = false;
bool _redirectingToSignin = false;
bool _authBootstrapInProgress = false;
bool _showAllActivities = false;
final Set<String> _revokedClientIds = {};
@@ -47,11 +50,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
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<DashboardScreen> {
if (closeOnTap) {
Navigator.of(context).pop();
}
context.go('/');
context.go(buildLocalizedHomePath(Uri.base));
},
),
ListTile(
@@ -302,8 +304,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Future<void> _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<DashboardScreen> {
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<DashboardScreen> {
@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<DashboardScreen> {
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<DashboardScreen> {
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<DashboardScreen> {
}
// 카드의 너비를 화면 너비에 맞춰 계산 (여백 고려)
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<DashboardScreen> {
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<DashboardScreen> {
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<DashboardScreen> {
),
);
}
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<DashboardScreen> {
}
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<DashboardScreen> {
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<void> _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<bool> _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 {
@@ -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<ProfilePage> {
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<ProfilePage> {
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),
+78 -24
View File
@@ -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<void> _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
+28 -12
View File
@@ -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:
+1
View File
@@ -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
@@ -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,
);
});
});
}
@@ -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 = <FlutterErrorDetails>[];
final previousOnError = FlutterError.onError;
FlutterError.onError = (details) {
final text = details.exceptionAsString();
if (text.contains('A RenderFlex overflowed')) {
return;
}
recordedErrors.add(details);
};
addTearDown(() {
FlutterError.onError = previousOnError;
});
tester.view.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);
});
}
+27
View File
@@ -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',
);
});
});
}
@@ -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);
});
});
}
@@ -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);
});
}
@@ -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);
});
}
@@ -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);
});
});
}
+27
View File
@@ -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');
});
});
}
+16
View File
@@ -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);
});
}
@@ -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();
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more