1
0
forked from baron/baron-sso

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

This commit is contained in:
2026-02-24 12:42:02 +09:00
106 changed files with 3373 additions and 802 deletions

4
.gitignore vendored
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/

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

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개 계층으로 분리됩니다.

View File

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

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");

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)

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

View File

@@ -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"])
}
}

View File

@@ -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)
}
}

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
}

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,

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))

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
}

View File

@@ -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"])
}

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

File diff suppressed because one or more lines are too long

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>

View File

@@ -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>

View File

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

View File

@@ -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>

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>

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;

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"

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"

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

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(

View File

@@ -1,5 +1,13 @@
# AGENTS 가이드 (Baron SSO)
## 버그 수정 절차 대원칙 (강제)
- 버그 대응 시 **재현 테스트를 먼저 작성**합니다.
- 재현 테스트가 실패하는 상태를 확인한 뒤에만 수정 작업을 시작합니다.
- 수정 후에는 테스트를 반복 실행하여 재현 테스트가 안정적으로 통과할 때까지 계속 보완합니다.
- 재현 테스트 없이 “감으로 수정”하거나, 실패 테스트를 남긴 채 성공으로 보고하지 않습니다.
- 이슈 종료 전에는 최소 1회 이상 실제 사용자 경로(예: 로그인/새로고침/리다이렉트)를 확인합니다.
- 테스트/원인/조치 내역은 문서(`docs/test-plan/*`, `docs/trouble-shooting/*`)에 반영합니다.
## 목적
- 인증/인가 허브로서 **Backend + Ory Stack** 중심 아키텍처를 유지
- 사용자 플로우(UserFront)와 관리 플로우(Admin/DevFront)를 명확히 분리

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) 유지 원칙
- 신규 기능은 관련 테스트를 반드시 추가합니다.

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` | 오류/예외/거부 경로 검증 |

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` | 기본 앱 렌더링 스모크 검증 |

View File

@@ -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) 운영 원칙
- 버그는 반드시 재현 테스트를 먼저 추가합니다.
- 재현 테스트가 실패하는 상태를 확인한 뒤 수정합니다.
- 수정 후 동일 테스트를 반복 실행해 안정 통과까지 완료합니다.

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`에서 별도 관리
| 파일 | 테스트 | 역할 |
|---|---|---|

View File

@@ -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 동시 수집

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"

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"

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
scripts/map_wasm_stack.py Normal file
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

BIN
userfront/assets/baron.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

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"

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 = "로그인하기"

View File

@@ -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

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();
}
}
Future<void> _applyLocale() async {
final normalized = normalizeLocaleCode(widget.localeCode);
LocaleStorage.write(normalized);
webWindow.setTitle(tr('ui.userfront.app_title'));
if (context.locale.languageCode == normalized) {
void _scheduleLocaleSync() {
if (_syncScheduled) {
return;
}
_syncScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_syncScheduled = false;
if (!mounted) {
return;
}
unawaited(_applyLocale());
});
}
Future<void> _applyLocale() async {
if (!mounted) {
return;
}
final normalized = normalizeLocaleCode(widget.localeCode);
LocaleStorage.write(normalized);
final localization = EasyLocalization.of(context);
if (localization == null) {
return;
}
if (localization.currentLocale?.languageCode == normalized) {
webWindow.setTitle(tr('ui.userfront.app_title'));
return;
}
await localization.setLocale(Locale(normalized));
if (!mounted) {
return;
}
await context.setLocale(Locale(normalized));
webWindow.setTitle(tr('ui.userfront.app_title'));
}

View File

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

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

View File

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

View File

@@ -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() {

View File

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

View File

@@ -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(

View File

@@ -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,

View File

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

View File

@@ -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];
}

View File

@@ -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"),
),
],

View File

@@ -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);

View File

@@ -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(

View File

@@ -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(),

View File

@@ -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'),

View File

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

View File

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

View File

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

View File

@@ -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: '승인 화면으로 이동'),
),
),
],
),
),
);
}
}

View File

@@ -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: '승인 화면으로 이동'),
),
),
],
),
),
);
}
}

View File

@@ -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) {

View File

@@ -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')),
),
],

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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),

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

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:

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

BIN
userfront/web/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Some files were not shown because too many files have changed in this diff Show More