Merge branch 'dev' into feat/org-chart-rebac
4
.gitignore
vendored
@@ -11,6 +11,8 @@
|
|||||||
*.log
|
*.log
|
||||||
*.out
|
*.out
|
||||||
*.exe
|
*.exe
|
||||||
|
reports
|
||||||
|
reports/*
|
||||||
|
|
||||||
# Docker Services Data (Volumes)
|
# Docker Services Data (Volumes)
|
||||||
postgres_data/
|
postgres_data/
|
||||||
@@ -36,3 +38,5 @@ userfront/.env
|
|||||||
# Frontend test artifacts
|
# Frontend test artifacts
|
||||||
adminfront/test-results/
|
adminfront/test-results/
|
||||||
devfront/test-results/
|
devfront/test-results/
|
||||||
|
adminfront/playwright-report/
|
||||||
|
devfront/playwright-report/
|
||||||
|
|||||||
77
Makefile
@@ -105,3 +105,80 @@ logs-ory:
|
|||||||
|
|
||||||
logs-app:
|
logs-app:
|
||||||
docker compose -f $(COMPOSE_APP) logs -f
|
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
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
**Baron 로그인**은 화이트 라벨링된 가족사의 모든 소프트웨어 Auth를 총괄하는 사용자 인증/인가 허브입니다.
|
**Baron 로그인**은 화이트 라벨링된 가족사의 모든 소프트웨어 Auth를 총괄하는 사용자 인증/인가 허브입니다.
|
||||||
|
|
||||||
|
## 버그 대응 대원칙 (필수)
|
||||||
|
- 모든 버그 수정은 반드시 **재현 테스트를 먼저 작성**합니다. (Failing test first)
|
||||||
|
- 재현 테스트 없이 코드만 먼저 고치는 행위를 금지합니다.
|
||||||
|
- 수정 후에는 **해당 재현 테스트가 통과할 때까지 반복**해서 원인 분석/수정/검증을 수행합니다.
|
||||||
|
- “테스트 통과”는 최소 기준입니다. 실제 재현 시나리오(로그인, 새로고침, 리다이렉트 등)까지 확인한 뒤에만 이슈를 종료합니다.
|
||||||
|
- 관련 변경이 발생하면 테스트 문서(`docs/test-plan/*`, `docs/trouble-shooting/*`)를 함께 업데이트합니다.
|
||||||
|
|
||||||
* Ory Stack으로 모든 구성요소를 self-hosting 합니다.
|
* Ory Stack으로 모든 구성요소를 self-hosting 합니다.
|
||||||
* Backend는 Go (Fiber)로 구성된 Ory Stack의 유일한 Command 전송 포인트입니다. 모든 Command는 ClickHouse로 강제 전송되며 Audit Log 시스템을 구성합니다.
|
* Backend는 Go (Fiber)로 구성된 Ory Stack의 유일한 Command 전송 포인트입니다. 모든 Command는 ClickHouse로 강제 전송되며 Audit Log 시스템을 구성합니다.
|
||||||
* Front는 Backend를 통해서만 연동하며 자체가 Ory Stack의 RP기도 합니다. 크게 3개 계층으로 분리됩니다.
|
* Front는 Backend를 통해서만 연동하며 자체가 Ory Stack의 RP기도 합니다. 크게 3개 계층으로 분리됩니다.
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Building2, Plus, Users } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Badge } from "../../../components/ui/badge";
|
||||||
|
import { Button } from "../../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../../components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../../../components/ui/table";
|
||||||
|
import {
|
||||||
|
fetchGroups,
|
||||||
|
fetchTenants,
|
||||||
|
type TenantSummary,
|
||||||
|
} from "../../../lib/adminApi";
|
||||||
|
|
||||||
|
export default function GlobalUserGroupListPage() {
|
||||||
|
const { data: tenantList, isLoading: isTenantsLoading } = useQuery({
|
||||||
|
queryKey: ["admin-tenants"],
|
||||||
|
queryFn: () => fetchTenants(100, 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isTenantsLoading)
|
||||||
|
return <div className="p-8">Loading tenants and groups...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<header className="flex items-start justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">User Groups</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
모든 테넌트의 유저 그룹을 관리합니다. 권한 상속의 주체가 되는 그룹을
|
||||||
|
설정하세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid gap-6">
|
||||||
|
{tenantList?.items.map((tenant) => (
|
||||||
|
<TenantGroupCard key={tenant.id} tenant={tenant} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TenantGroupCard({ tenant }: { tenant: TenantSummary }) {
|
||||||
|
const { data: groups, isLoading } = useQuery({
|
||||||
|
queryKey: ["tenant-user-groups", tenant.id],
|
||||||
|
queryFn: () => fetchGroups(tenant.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="text-xl flex items-center gap-2">
|
||||||
|
<Building2 size={20} className="text-muted-foreground" />
|
||||||
|
{tenant.name}
|
||||||
|
<Badge variant="outline" className="ml-2">
|
||||||
|
{tenant.slug}
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
이 테넌트에 정의된 유저 그룹 목록입니다.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" asChild>
|
||||||
|
<Link to={`/tenants/${tenant.id}/user-groups`}>
|
||||||
|
<Plus size={16} className="mr-2" />
|
||||||
|
그룹 관리
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[250px]">그룹명</TableHead>
|
||||||
|
<TableHead>설명</TableHead>
|
||||||
|
<TableHead className="w-[100px]">멤버 수</TableHead>
|
||||||
|
<TableHead className="text-right">작업</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-center">
|
||||||
|
Loading...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : groups?.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={4}
|
||||||
|
className="text-center text-muted-foreground py-4"
|
||||||
|
>
|
||||||
|
등록된 유저 그룹이 없습니다.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
groups?.map((group) => (
|
||||||
|
<TableRow key={group.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users size={14} className="text-primary" />
|
||||||
|
<Link
|
||||||
|
to={`/tenants/${tenant.id}/user-groups/${group.id}`}
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
{group.name}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{group.description || "-"}</TableCell>
|
||||||
|
<TableCell>{group.members?.length || 0} 명</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link
|
||||||
|
to={`/tenants/${tenant.id}/user-groups/${group.id}`}
|
||||||
|
>
|
||||||
|
상세보기
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,27 +23,99 @@ type UserCreatePayload = {
|
|||||||
department?: string;
|
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 }) => {
|
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[] = [];
|
const users: UserSummary[] = [];
|
||||||
let idSeq = 1;
|
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) => {
|
await page.route("**/api/v1/admin/users**", async (route) => {
|
||||||
const request = route.request();
|
const request = route.request();
|
||||||
const url = new URL(request.url());
|
const url = new URL(request.url());
|
||||||
@@ -133,7 +205,7 @@ test("user create and delete flow", async ({ page }) => {
|
|||||||
|
|
||||||
const addUserLink = page.getByRole("link", { name: "사용자 추가" });
|
const addUserLink = page.getByRole("link", { name: "사용자 추가" });
|
||||||
await expect(addUserLink).toBeVisible();
|
await expect(addUserLink).toBeVisible();
|
||||||
await addUserLink.click();
|
await page.goto("/users/new");
|
||||||
await expect(page).toHaveURL(/\/users\/new$/);
|
await expect(page).toHaveURL(/\/users\/new$/);
|
||||||
|
|
||||||
const uniqueEmail = `playwright-${Date.now()}@example.com`;
|
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("Test1234!");
|
||||||
await page.getByLabel("이름").fill("Playwright User");
|
await page.getByLabel("이름").fill("Playwright User");
|
||||||
await page.getByLabel("전화번호").fill("010-0000-0000");
|
await page.getByLabel("전화번호").fill("010-0000-0000");
|
||||||
await page.getByLabel("회사 코드").fill("E2E");
|
|
||||||
await page.getByLabel("부서").fill("QA");
|
await page.getByLabel("부서").fill("QA");
|
||||||
await page.getByLabel("역할 (Role)").selectOption("admin");
|
await page.getByLabel("역할 (Role)").selectOption("admin");
|
||||||
|
|
||||||
|
|||||||
@@ -276,8 +276,13 @@ func main() {
|
|||||||
auditHandler := handler.NewAuditHandler(auditRepo)
|
auditHandler := handler.NewAuditHandler(auditRepo)
|
||||||
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService)
|
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService)
|
||||||
adminHandler := handler.NewAdminHandler(ketoService)
|
adminHandler := handler.NewAdminHandler(ketoService)
|
||||||
|
<<<<<<< HEAD
|
||||||
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService)
|
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService)
|
||||||
tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService, ketoOutboxRepo, kratosAdminService)
|
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)
|
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
|
||||||
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
||||||
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo)
|
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
"baron-sso-backend/internal/logger"
|
"baron-sso-backend/internal/logger"
|
||||||
"baron-sso-backend/internal/repository"
|
"baron-sso-backend/internal/repository"
|
||||||
|
"baron-sso-backend/internal/response"
|
||||||
"baron-sso-backend/internal/service"
|
"baron-sso-backend/internal/service"
|
||||||
"baron-sso-backend/internal/utils"
|
"baron-sso-backend/internal/utils"
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -1562,13 +1563,12 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
|
|||||||
ale.LatencyMs = time.Since(startTime)
|
ale.LatencyMs = time.Since(startTime)
|
||||||
ale.ProviderError = err.Error()
|
ale.ProviderError = err.Error()
|
||||||
ale.Log(slog.LevelError, "Body parse 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)
|
loginID := strings.TrimSpace(req.LoginID)
|
||||||
ale.LoginIDs["loginId"] = req.LoginID // 원문
|
ale.LoginIDs["loginId"] = req.LoginID // 원문
|
||||||
ale.LoginIDs["loginId_normalized"] = loginID
|
ale.LoginIDs["loginId_normalized"] = loginID
|
||||||
ale.NewPassword = req.Password // For test only, logging password (sensitive)
|
|
||||||
|
|
||||||
ale.Log(slog.LevelInfo, "Attempting to login")
|
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.LatencyMs = time.Since(startTime)
|
||||||
ale.ProviderError = "IDP Provider is nil"
|
ale.ProviderError = "IDP Provider is nil"
|
||||||
ale.Log(slog.LevelError, "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)
|
authInfo, err := h.IdpProvider.SignIn(loginID, req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, domain.ErrNotSupported) {
|
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.Status = fiber.StatusUnauthorized
|
||||||
ale.LatencyMs = time.Since(startTime)
|
ale.LatencyMs = time.Since(startTime)
|
||||||
ale.ProviderError = err.Error()
|
ale.ProviderError = err.Error()
|
||||||
ale.Log(slog.LevelWarn, "IDP sign-in failed", slog.String("provider", h.IdpProvider.Name()))
|
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") {
|
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)
|
subject, resolveErr := h.resolveKratosIdentityIDFromLoginID(c.Context(), loginID)
|
||||||
@@ -1604,7 +1604,6 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
ale.Status = fiber.StatusOK
|
ale.Status = fiber.StatusOK
|
||||||
ale.LatencyMs = time.Since(startTime)
|
ale.LatencyMs = time.Since(startTime)
|
||||||
ale.SessionJwt = authInfo.SessionToken.JWT
|
|
||||||
setSessionIDLocal(c, authInfo.SessionToken)
|
setSessionIDLocal(c, authInfo.SessionToken)
|
||||||
ale.Log(slog.LevelInfo, "Login successful", slog.String("provider", h.IdpProvider.Name()), slog.String("subject", authInfo.Subject))
|
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"] = loginID
|
||||||
ale.LoginIDs["loginId_normalized"] = loginID
|
ale.LoginIDs["loginId_normalized"] = loginID
|
||||||
|
|
||||||
redirectURL := fmt.Sprintf("%s/reset-password?loginId=%s&token=%s",
|
userfrontURL := strings.TrimRight(os.Getenv("USERFRONT_URL"), "/")
|
||||||
os.Getenv("USERFRONT_URL"),
|
if userfrontURL == "" {
|
||||||
loginID,
|
userfrontURL = "https://sso.hmac.kr"
|
||||||
token,
|
}
|
||||||
)
|
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.RedirectTo = redirectURL
|
||||||
ale.Status = fiber.StatusFound
|
ale.Status = fiber.StatusFound
|
||||||
@@ -1894,22 +1905,29 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// loginID는 URL 쿼리 파라미터 또는 토큰 조회로 받습니다.
|
// loginID는 URL 쿼리 파라미터 또는 토큰 조회로 받습니다.
|
||||||
loginID := c.Query("loginId")
|
loginID := strings.TrimSpace(c.Query("loginId"))
|
||||||
resetToken := c.Query("token")
|
resetToken := strings.TrimSpace(c.Query("token"))
|
||||||
if loginID == "" && resetToken != "" {
|
if resetToken != "" {
|
||||||
if val, err := h.RedisService.Get(prefixPwdResetToken + resetToken); err == nil && val != "" {
|
val, err := h.RedisService.Get(prefixPwdResetToken + resetToken)
|
||||||
loginID = val
|
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.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.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 != "" {
|
if cookieHeader := c.Get(fiber.HeaderCookie); cookieHeader != "" {
|
||||||
ale.Headers["Request-Cookie-Header"] = cookieHeader
|
|
||||||
if dsrfCookie := c.Cookies("DSRF"); dsrfCookie != "" {
|
if dsrfCookie := c.Cookies("DSRF"); dsrfCookie != "" {
|
||||||
ale.ParsedCookieDSRF = dsrfCookie
|
ale.ParsedCookieDSRF = dsrfCookie
|
||||||
ale.HasCookieDSRF = true
|
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"})
|
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")
|
ale.Log(slog.LevelInfo, "Received new password for reset")
|
||||||
|
|
||||||
policy := h.resolvePasswordPolicy()
|
policy := h.resolvePasswordPolicy()
|
||||||
|
|||||||
@@ -300,3 +300,44 @@ func TestPasswordLogin_NoOIDC_Success(t *testing.T) {
|
|||||||
t.Errorf("expected no redirectTo, got %s", got["redirectTo"])
|
t.Errorf("expected no redirectTo, got %s", got["redirectTo"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPasswordLogin_InvalidCredentials_ReturnsCode(t *testing.T) {
|
||||||
|
mockIdp := new(MockIdentityProvider)
|
||||||
|
mockIdp.On("SignIn", "user@example.com", "wrong-password").Return(nil, errors.New("비밀번호가 일치하지 않습니다"))
|
||||||
|
|
||||||
|
h := &AuthHandler{
|
||||||
|
IdpProvider: mockIdp,
|
||||||
|
KratosAdmin: service.NewKratosAdminService(),
|
||||||
|
Hydra: service.NewHydraAdminService(),
|
||||||
|
}
|
||||||
|
|
||||||
|
app := newAuthLoginTestApp(h)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{
|
||||||
|
"loginId": "user@example.com",
|
||||||
|
"password": "wrong-password",
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("expected 401, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got map[string]any
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
if got["code"] != "password_or_email_mismatch" {
|
||||||
|
t.Fatalf("expected code=password_or_email_mismatch, got=%v", got["code"])
|
||||||
|
}
|
||||||
|
if got["error"] != "Invalid credentials" {
|
||||||
|
t.Fatalf("expected error=Invalid credentials, got=%v", got["error"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
@@ -17,6 +19,51 @@ func newTestApp(h *AuthHandler) *fiber.App {
|
|||||||
return 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) {
|
func TestCompletePasswordReset_MissingLoginID(t *testing.T) {
|
||||||
h := &AuthHandler{}
|
h := &AuthHandler{}
|
||||||
app := newTestApp(h)
|
app := newTestApp(h)
|
||||||
@@ -106,3 +153,136 @@ func TestCompletePasswordReset_NilIDPProvider(t *testing.T) {
|
|||||||
t.Fatalf("unexpected error message: %v", got["error"])
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ type mockIdpProvider struct {
|
|||||||
verifyCodeInfo *domain.AuthInfo
|
verifyCodeInfo *domain.AuthInfo
|
||||||
err error
|
err error
|
||||||
initiateLinkErr error
|
initiateLinkErr error
|
||||||
|
updateCalled bool
|
||||||
|
updatedLoginID string
|
||||||
|
updatedPassword string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockIdpProvider) Name() 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 {
|
func (m *mockIdpProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
|
||||||
|
m.updateCalled = true
|
||||||
|
m.updatedLoginID = loginID
|
||||||
|
m.updatedPassword = newPassword
|
||||||
return m.err
|
return m.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -22,15 +25,39 @@ type DevHandler struct {
|
|||||||
SecretRepo domain.ClientSecretRepository
|
SecretRepo domain.ClientSecretRepository
|
||||||
KratosAdmin service.KratosAdminService
|
KratosAdmin service.KratosAdminService
|
||||||
ConsentRepo repository.ClientConsentRepository
|
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{
|
return &DevHandler{
|
||||||
Hydra: service.NewHydraAdminService(),
|
Hydra: service.NewHydraAdminService(),
|
||||||
Redis: redis,
|
Redis: redis,
|
||||||
SecretRepo: secretRepo,
|
SecretRepo: secretRepo,
|
||||||
KratosAdmin: service.NewKratosAdminService(),
|
KratosAdmin: service.NewKratosAdminService(),
|
||||||
ConsentRepo: consentRepo,
|
ConsentRepo: consentRepo,
|
||||||
|
Keto: keto,
|
||||||
|
RPSvc: rpSvc,
|
||||||
|
Auth: authProvider,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +121,142 @@ type clientUpsertRequest struct {
|
|||||||
Metadata *map[string]interface{} `json:"metadata"`
|
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 {
|
func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
||||||
limit := c.QueryInt("limit", 50)
|
limit := c.QueryInt("limit", 50)
|
||||||
offset := c.QueryInt("offset", 0)
|
offset := c.QueryInt("offset", 0)
|
||||||
@@ -104,6 +267,11 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
|||||||
offset = 0
|
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)
|
clients, err := h.Hydra.ListClients(c.Context(), limit, offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, service.ErrHydraNotFound) {
|
if errors.Is(err, service.ErrHydraNotFound) {
|
||||||
@@ -120,7 +288,12 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
items := make([]clientSummary, 0, len(clients))
|
items := make([]clientSummary, 0, len(clients))
|
||||||
for _, client := range clients {
|
for _, client := range clients {
|
||||||
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{
|
return c.JSON(clientListResponse{
|
||||||
@@ -145,6 +318,18 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
summary := h.mapClientSummary(*client)
|
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{
|
return c.JSON(clientDetailResponse{
|
||||||
Client: summary,
|
Client: summary,
|
||||||
Endpoints: clientEndpoints{
|
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"})
|
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)
|
updated, err := h.Hydra.PatchClientStatus(c.Context(), clientID, status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, service.ErrHydraNotFound) {
|
if errors.Is(err, service.ErrHydraNotFound) {
|
||||||
@@ -221,9 +418,20 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
|||||||
grantTypes := derefSlice(req.GrantTypes, defaultGrantTypes())
|
grantTypes := derefSlice(req.GrantTypes, defaultGrantTypes())
|
||||||
responseTypes := derefSlice(req.ResponseTypes, defaultResponseTypes())
|
responseTypes := derefSlice(req.ResponseTypes, defaultResponseTypes())
|
||||||
|
|
||||||
clientType := strings.ToLower(strings.TrimSpace(valueOr(req.Type, "confidential")))
|
clientType := strings.ToLower(strings.TrimSpace(valueOr(req.Type, "private")))
|
||||||
if clientType != "public" && clientType != "confidential" {
|
if clientType != "pkce" && clientType != "private" {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type must be public or confidential"})
|
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")))
|
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 = map[string]interface{}{}
|
||||||
}
|
}
|
||||||
metadata["status"] = status
|
metadata["status"] = status
|
||||||
|
metadata["created_at"] = time.Now().Format(time.RFC3339)
|
||||||
|
|
||||||
tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, ""))
|
tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, ""))
|
||||||
if tokenAuthMethod == "" {
|
if tokenAuthMethod == "" {
|
||||||
if clientType == "public" {
|
if clientType == "pkce" {
|
||||||
tokenAuthMethod = "none"
|
tokenAuthMethod = "none"
|
||||||
} else {
|
} else {
|
||||||
tokenAuthMethod = "client_secret_basic"
|
tokenAuthMethod = "client_secret_basic"
|
||||||
@@ -310,8 +519,20 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
|||||||
clientType := ""
|
clientType := ""
|
||||||
if req.Type != nil {
|
if req.Type != nil {
|
||||||
clientType = strings.ToLower(strings.TrimSpace(*req.Type))
|
clientType = strings.ToLower(strings.TrimSpace(*req.Type))
|
||||||
if clientType != "public" && clientType != "confidential" {
|
if clientType != "pkce" && clientType != "private" {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type must be public or confidential"})
|
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, ""))
|
tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, ""))
|
||||||
if tokenAuthMethod == "" && clientType != "" {
|
if tokenAuthMethod == "" && clientType != "" {
|
||||||
if clientType == "public" {
|
if clientType == "pkce" {
|
||||||
tokenAuthMethod = "none"
|
tokenAuthMethod = "none"
|
||||||
} else {
|
} else {
|
||||||
tokenAuthMethod = "client_secret_basic"
|
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"})
|
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 err := h.Hydra.DeleteClient(c.Context(), clientID); err != nil {
|
||||||
if errors.Is(err, service.ErrHydraNotFound) {
|
if errors.Is(err, service.ErrHydraNotFound) {
|
||||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"})
|
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"})
|
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
|
// 1. Generate new secret
|
||||||
newSecret, err := generateRandomSecret(20)
|
newSecret, err := generateRandomSecret(20)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate secret"})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate secret"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Get current client to preserve other fields
|
// 2. Get current client to preserve other fields (already fetched above)
|
||||||
current, err := h.Hydra.GetClient(c.Context(), clientID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, service.ErrHydraNotFound) {
|
if errors.Is(err, service.ErrHydraNotFound) {
|
||||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"})
|
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 {
|
func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
|
||||||
status := "active"
|
status := "active"
|
||||||
|
var createdAt *time.Time
|
||||||
|
|
||||||
if client.Metadata != nil {
|
if client.Metadata != nil {
|
||||||
if value, ok := client.Metadata["status"].(string); ok && strings.ToLower(value) == "inactive" {
|
if value, ok := client.Metadata["status"].(string); ok && strings.ToLower(value) == "inactive" {
|
||||||
status = "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") {
|
if strings.EqualFold(client.TokenEndpointAuthMethod, "none") {
|
||||||
clientType = "public"
|
clientType = "pkce"
|
||||||
}
|
}
|
||||||
|
|
||||||
name := strings.TrimSpace(client.ClientName)
|
name := strings.TrimSpace(client.ClientName)
|
||||||
@@ -627,6 +878,7 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
|
|||||||
Name: name,
|
Name: name,
|
||||||
Type: clientType,
|
Type: clientType,
|
||||||
Status: status,
|
Status: status,
|
||||||
|
CreatedAt: createdAt,
|
||||||
RedirectURIs: client.RedirectURIs,
|
RedirectURIs: client.RedirectURIs,
|
||||||
Scopes: scopes,
|
Scopes: scopes,
|
||||||
ClientSecret: clientSecret,
|
ClientSecret: clientSecret,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
"baron-sso-backend/internal/service"
|
"baron-sso-backend/internal/service"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -10,8 +12,36 @@ import (
|
|||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/stretchr/testify/assert"
|
"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) {
|
func TestListClients_Success(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.URL.Path == "/clients" {
|
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
|
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{
|
h := &DevHandler{
|
||||||
Hydra: &service.HydraAdminService{
|
Hydra: &service.HydraAdminService{
|
||||||
AdminURL: "http://hydra.test",
|
AdminURL: "http://hydra.test",
|
||||||
HTTPClient: &http.Client{Transport: transport},
|
HTTPClient: &http.Client{Transport: transport},
|
||||||
},
|
},
|
||||||
|
Keto: mockKeto,
|
||||||
}
|
}
|
||||||
app := fiber.New()
|
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)
|
app.Get("/api/v1/dev/clients", h.ListClients)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
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
|
return httpJSONAny(r, http.StatusNotFound, map[string]string{"error": "not found"}), nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
mockKeto := new(MockKetoService)
|
||||||
|
|
||||||
h := &DevHandler{
|
h := &DevHandler{
|
||||||
Hydra: &service.HydraAdminService{
|
Hydra: &service.HydraAdminService{
|
||||||
AdminURL: "http://hydra.test",
|
AdminURL: "http://hydra.test",
|
||||||
PublicURL: "http://hydra-public.test", // PublicURL 추가
|
PublicURL: "http://hydra-public.test", // PublicURL 추가
|
||||||
HTTPClient: &http.Client{Transport: transport},
|
HTTPClient: &http.Client{Transport: transport},
|
||||||
},
|
},
|
||||||
|
Keto: mockKeto,
|
||||||
}
|
}
|
||||||
app := fiber.New()
|
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)
|
app.Get("/api/v1/dev/clients/:id", h.GetClient)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-123", nil)
|
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)
|
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) {
|
func TestCreateClient_Success(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
|
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
|
return httpJSONAny(r, http.StatusInternalServerError, map[string]string{"error": "hydra error"}), nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
mockKeto := new(MockKetoService)
|
||||||
secretRepo := &mockSecretRepo{secrets: make(map[string]string)}
|
secretRepo := &mockSecretRepo{secrets: make(map[string]string)}
|
||||||
redisRepo := &mockRedisRepo{data: make(map[string]string)}
|
redisRepo := &mockRedisRepo{data: make(map[string]string)}
|
||||||
|
|
||||||
@@ -122,13 +149,18 @@ func TestCreateClient_Success(t *testing.T) {
|
|||||||
},
|
},
|
||||||
SecretRepo: secretRepo,
|
SecretRepo: secretRepo,
|
||||||
Redis: redisRepo,
|
Redis: redisRepo,
|
||||||
|
Keto: mockKeto,
|
||||||
}
|
}
|
||||||
app := fiber.New()
|
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)
|
app.Post("/api/v1/dev/clients", h.CreateClient)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]interface{}{
|
body, _ := json.Marshal(map[string]interface{}{
|
||||||
"client_name": "New App",
|
"client_name": "New App",
|
||||||
"type": "confidential",
|
"type": "private",
|
||||||
"redirectUris": []string{"http://localhost/cb"},
|
"redirectUris": []string{"http://localhost/cb"},
|
||||||
})
|
})
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
@@ -24,7 +25,7 @@ type AuditLogEntry struct {
|
|||||||
Origin string
|
Origin string
|
||||||
Referer string
|
Referer string
|
||||||
Query map[string]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
|
LoginIDs map[string]string // loginId and loginId_normalized
|
||||||
Token string // For reset tokens, magic link tokens
|
Token string // For reset tokens, magic link tokens
|
||||||
ProviderError string
|
ProviderError string
|
||||||
@@ -43,8 +44,6 @@ type AuditLogEntry struct {
|
|||||||
RedirectTo string
|
RedirectTo string
|
||||||
HasCookieDSRF bool
|
HasCookieDSRF bool
|
||||||
ParsedCookieDSRF string
|
ParsedCookieDSRF string
|
||||||
RequestBody string // For complete stage
|
|
||||||
NewPassword string // For complete stage (test only, sensitive)
|
|
||||||
// ... potentially more fields specific to different stages
|
// ... potentially more fields specific to different stages
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,16 +54,14 @@ func NewAuditLogEntry(c *fiber.Ctx, stage string) *AuditLogEntry {
|
|||||||
// Extract query parameters
|
// Extract query parameters
|
||||||
queryParams := make(map[string]string)
|
queryParams := make(map[string]string)
|
||||||
c.Context().QueryArgs().VisitAll(func(key, value []byte) {
|
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
|
// Extract relevant headers
|
||||||
headers := make(map[string]string)
|
headers := make(map[string]string)
|
||||||
headers["Host"] = c.Get("Host")
|
headers["Host"] = c.Get("Host")
|
||||||
headers["User-Agent"] = c.Get("User-Agent")
|
headers["User-Agent"] = c.Get("User-Agent")
|
||||||
if cookie := c.Get("Cookie"); cookie != "" {
|
|
||||||
headers["Cookie"] = cookie
|
|
||||||
}
|
|
||||||
headers["Origin"] = c.Get("Origin")
|
headers["Origin"] = c.Get("Origin")
|
||||||
headers["Referer"] = c.Get("Referer")
|
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 {
|
if len(ale.Query) > 0 {
|
||||||
queryGroupArgs := make([]any, 0, len(ale.Query))
|
queryGroupArgs := make([]any, 0, len(ale.Query))
|
||||||
for k, v := range 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...))
|
attrs = append(attrs, slog.Group("query", queryGroupArgs...))
|
||||||
}
|
}
|
||||||
if len(ale.Headers) > 0 {
|
if len(ale.Headers) > 0 {
|
||||||
headersGroupArgs := make([]any, 0, len(ale.Headers))
|
headersGroupArgs := make([]any, 0, len(ale.Headers))
|
||||||
for k, v := range 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...))
|
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...))
|
attrs = append(attrs, slog.Group("login_ids", loginIDGroupArgs...))
|
||||||
}
|
}
|
||||||
if ale.Token != "" {
|
if ale.Token != "" {
|
||||||
attrs = append(attrs, slog.String("token", ale.Token))
|
attrs = append(attrs, slog.Bool("has_token", true))
|
||||||
}
|
}
|
||||||
if ale.ProviderError != "" {
|
if ale.ProviderError != "" {
|
||||||
attrs = append(attrs, slog.String("provider_error", 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))
|
attrs = append(attrs, slog.String("provider_response_body", ale.ProviderBody))
|
||||||
}
|
}
|
||||||
if ale.RefreshToken != "" {
|
if ale.RefreshToken != "" {
|
||||||
attrs = append(attrs, slog.String("refresh_token", ale.RefreshToken))
|
attrs = append(attrs, slog.Bool("has_refresh_token", true))
|
||||||
}
|
}
|
||||||
if ale.SessionJwt != "" {
|
if ale.SessionJwt != "" {
|
||||||
attrs = append(attrs, slog.String("session_jwt", ale.SessionJwt))
|
attrs = append(attrs, slog.Bool("has_session_jwt", true))
|
||||||
}
|
}
|
||||||
if ale.AccessJwt != "" {
|
if ale.AccessJwt != "" {
|
||||||
attrs = append(attrs, slog.String("access_jwt", ale.AccessJwt))
|
attrs = append(attrs, slog.Bool("has_access_jwt", true))
|
||||||
}
|
}
|
||||||
if ale.UserLoginId != "" {
|
if ale.UserLoginId != "" {
|
||||||
attrs = append(attrs, slog.String("user_login_id", 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 != "" {
|
if ale.SetCookieName != "" {
|
||||||
attrs = append(attrs, slog.String("set_cookie_name", 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 {
|
if len(ale.SetCookieAttrs) > 0 {
|
||||||
cookieAttrsGroupArgs := make([]any, 0, len(ale.SetCookieAttrs))
|
cookieAttrsGroupArgs := make([]any, 0, len(ale.SetCookieAttrs))
|
||||||
for k, v := range 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))
|
attrs = append(attrs, slog.Bool("has_cookie_DSRF", ale.HasCookieDSRF))
|
||||||
}
|
}
|
||||||
if ale.ParsedCookieDSRF != "" {
|
if ale.ParsedCookieDSRF != "" {
|
||||||
attrs = append(attrs, slog.String("parsed_cookie_DSRF", ale.ParsedCookieDSRF))
|
attrs = append(attrs, slog.Bool("has_parsed_cookie_DSRF", true))
|
||||||
}
|
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert variadic args to slog.Attr before appending
|
// 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...)
|
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
|
||||||
|
}
|
||||||
|
|||||||
80
backend/internal/logger/audit_logger_test.go
Normal 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"])
|
||||||
|
}
|
||||||
368
devfront/package-lock.json
generated
@@ -56,9 +56,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||||
"integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==",
|
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -71,9 +71,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/compat-data": {
|
"node_modules/@babel/compat-data": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
|
||||||
"integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==",
|
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -81,21 +81,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/core": {
|
"node_modules/@babel/core": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||||
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.28.6",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.28.6",
|
"@babel/generator": "^7.29.0",
|
||||||
"@babel/helper-compilation-targets": "^7.28.6",
|
"@babel/helper-compilation-targets": "^7.28.6",
|
||||||
"@babel/helper-module-transforms": "^7.28.6",
|
"@babel/helper-module-transforms": "^7.28.6",
|
||||||
"@babel/helpers": "^7.28.6",
|
"@babel/helpers": "^7.28.6",
|
||||||
"@babel/parser": "^7.28.6",
|
"@babel/parser": "^7.29.0",
|
||||||
"@babel/template": "^7.28.6",
|
"@babel/template": "^7.28.6",
|
||||||
"@babel/traverse": "^7.28.6",
|
"@babel/traverse": "^7.29.0",
|
||||||
"@babel/types": "^7.28.6",
|
"@babel/types": "^7.29.0",
|
||||||
"@jridgewell/remapping": "^2.3.5",
|
"@jridgewell/remapping": "^2.3.5",
|
||||||
"convert-source-map": "^2.0.0",
|
"convert-source-map": "^2.0.0",
|
||||||
"debug": "^4.1.0",
|
"debug": "^4.1.0",
|
||||||
@@ -112,14 +112,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/generator": {
|
"node_modules/@babel/generator": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
||||||
"integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==",
|
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.28.6",
|
"@babel/parser": "^7.29.0",
|
||||||
"@babel/types": "^7.28.6",
|
"@babel/types": "^7.29.0",
|
||||||
"@jridgewell/gen-mapping": "^0.3.12",
|
"@jridgewell/gen-mapping": "^0.3.12",
|
||||||
"@jridgewell/trace-mapping": "^0.3.28",
|
"@jridgewell/trace-mapping": "^0.3.28",
|
||||||
"jsesc": "^3.0.2"
|
"jsesc": "^3.0.2"
|
||||||
@@ -242,13 +242,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/parser": {
|
"node_modules/@babel/parser": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
|
||||||
"integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==",
|
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.28.6"
|
"@babel/types": "^7.29.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"parser": "bin/babel-parser.js"
|
"parser": "bin/babel-parser.js"
|
||||||
@@ -305,18 +305,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/traverse": {
|
"node_modules/@babel/traverse": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
|
||||||
"integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==",
|
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.28.6",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.28.6",
|
"@babel/generator": "^7.29.0",
|
||||||
"@babel/helper-globals": "^7.28.0",
|
"@babel/helper-globals": "^7.28.0",
|
||||||
"@babel/parser": "^7.28.6",
|
"@babel/parser": "^7.29.0",
|
||||||
"@babel/template": "^7.28.6",
|
"@babel/template": "^7.28.6",
|
||||||
"@babel/types": "^7.28.6",
|
"@babel/types": "^7.29.0",
|
||||||
"debug": "^4.3.1"
|
"debug": "^4.3.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -324,9 +324,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/types": {
|
"node_modules/@babel/types": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||||
"integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==",
|
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -661,13 +661,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.58.0",
|
"version": "1.58.2",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||||
"integrity": "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==",
|
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.58.0"
|
"playwright": "1.58.2"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -1363,9 +1363,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.53",
|
"version": "1.0.0-rc.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
||||||
"integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==",
|
"integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -1380,9 +1380,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/query-devtools": {
|
"node_modules/@tanstack/query-devtools": {
|
||||||
"version": "5.92.0",
|
"version": "5.93.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.92.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.93.0.tgz",
|
||||||
"integrity": "sha512-N8D27KH1vEpVacvZgJL27xC6yPFUy0Zkezn5gnB3L3gRCxlDeSuiya7fKge8Y91uMTnC8aSxBQhcK6ocY7alpQ==",
|
"integrity": "sha512-+kpsx1NQnOFTZsw6HAFCW3HkKg0+2cepGtAWXjiiSOJJ1CtQpt72EE2nyZb+AjAbLRPoeRmPJ8MtQd8r8gsPdg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -1390,9 +1390,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-query": {
|
"node_modules/@tanstack/react-query": {
|
||||||
"version": "5.90.20",
|
"version": "5.90.21",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.20.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
|
||||||
"integrity": "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==",
|
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-core": "5.90.20"
|
"@tanstack/query-core": "5.90.20"
|
||||||
@@ -1406,19 +1406,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-query-devtools": {
|
"node_modules/@tanstack/react-query-devtools": {
|
||||||
"version": "5.91.2",
|
"version": "5.91.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.3.tgz",
|
||||||
"integrity": "sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg==",
|
"integrity": "sha512-nlahjMtd/J1h7IzOOfqeyDh5LNfG0eULwlltPEonYy0QL+nqrBB+nyzJfULV+moL7sZyxc2sHdNJki+vLA9BSA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-devtools": "5.92.0"
|
"@tanstack/query-devtools": "5.93.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@tanstack/react-query": "^5.90.14",
|
"@tanstack/react-query": "^5.90.20",
|
||||||
"react": "^18 || ^19"
|
"react": "^18 || ^19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1479,9 +1479,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.10.9",
|
"version": "24.10.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz",
|
||||||
"integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==",
|
"integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1489,9 +1489,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.9",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1509,16 +1509,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz",
|
||||||
"integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==",
|
"integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"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-self": "^7.27.1",
|
||||||
"@babel/plugin-transform-react-jsx-source": "^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",
|
"@types/babel__core": "^7.20.5",
|
||||||
"react-refresh": "^0.18.0"
|
"react-refresh": "^0.18.0"
|
||||||
},
|
},
|
||||||
@@ -1550,19 +1550,6 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/arg": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||||
@@ -1577,9 +1564,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/autoprefixer": {
|
"node_modules/autoprefixer": {
|
||||||
"version": "10.4.23",
|
"version": "10.4.24",
|
||||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
|
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz",
|
||||||
"integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==",
|
"integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1598,7 +1585,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"browserslist": "^4.28.1",
|
"browserslist": "^4.28.1",
|
||||||
"caniuse-lite": "^1.0.30001760",
|
"caniuse-lite": "^1.0.30001766",
|
||||||
"fraction.js": "^5.3.4",
|
"fraction.js": "^5.3.4",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"postcss-value-parser": "^4.2.0"
|
"postcss-value-parser": "^4.2.0"
|
||||||
@@ -1614,24 +1601,27 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.13.3",
|
"version": "1.13.5",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.3.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
|
||||||
"integrity": "sha512-ERT8kdX7DZjtUm7IitEyV7InTHAF42iJuMArIiDIV5YtPanJkgw4hw5Dyg9fh0mihdWNn1GKaeIWErfe56UQ1g==",
|
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.6",
|
"follow-redirects": "^1.15.11",
|
||||||
"form-data": "^4.0.4",
|
"form-data": "^4.0.5",
|
||||||
"proxy-from-env": "^1.1.0"
|
"proxy-from-env": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.9.18",
|
"version": "2.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.18.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
||||||
"integrity": "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==",
|
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"baseline-browser-mapping": "dist/cli.js"
|
"baseline-browser-mapping": "dist/cli.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
@@ -1718,9 +1708,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001766",
|
"version": "1.0.30001774",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz",
|
||||||
"integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==",
|
"integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1912,9 +1902,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.278",
|
"version": "1.5.302",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.278.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
|
||||||
"integrity": "sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw==",
|
"integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
@@ -2013,24 +2003,6 @@
|
|||||||
"reusify": "^1.0.4"
|
"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": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
@@ -2095,9 +2067,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -2676,19 +2648,6 @@
|
|||||||
"node": ">=8.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": {
|
"node_modules/mime-db": {
|
||||||
"version": "1.52.0",
|
"version": "1.52.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
@@ -2812,13 +2771,13 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=8.6"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
@@ -2845,13 +2804,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.58.0",
|
"version": "1.58.2",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||||
"integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==",
|
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.58.0"
|
"playwright-core": "1.58.2"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -2864,9 +2823,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.58.0",
|
"version": "1.58.2",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||||
"integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==",
|
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -2876,21 +2835,6 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
@@ -3082,30 +3026,30 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.2.3",
|
"version": "19.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.2.3",
|
"version": "19.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^19.2.3"
|
"react": "^19.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-hook-form": {
|
"node_modules/react-hook-form": {
|
||||||
"version": "7.71.1",
|
"version": "7.71.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
|
||||||
"integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==",
|
"integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
@@ -3196,19 +3140,6 @@
|
|||||||
"node": ">=8.10.0"
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -3368,9 +3299,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tailwind-merge": {
|
"node_modules/tailwind-merge": {
|
||||||
"version": "3.4.0",
|
"version": "3.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
|
||||||
"integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
|
"integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -3465,6 +3396,37 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"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": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"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": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BadgeCheck, LogOut, Moon, ShieldHalf, Sun } from "lucide-react";
|
import { BadgeCheck, LogOut, Moon, ShieldHalf, Sun } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useAuth } from "react-oidc-context";
|
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 { t } from "../../lib/i18n";
|
||||||
import LanguageSelector from "../common/LanguageSelector";
|
import LanguageSelector from "../common/LanguageSelector";
|
||||||
import { Toaster } from "../ui/toaster";
|
import { Toaster } from "../ui/toaster";
|
||||||
@@ -16,11 +16,20 @@ const navItems = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function AppLayout() {
|
function AppLayout() {
|
||||||
|
const auth = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||||
const stored = window.localStorage.getItem("admin_theme");
|
const stored = window.localStorage.getItem("admin_theme");
|
||||||
return stored === "dark" ? "dark" : "light";
|
return stored === "dark" ? "dark" : "light";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
if (window.confirm(t("msg.dev.logout_confirm", "로그아웃 하시겠습니까?"))) {
|
||||||
|
auth.removeUser();
|
||||||
|
navigate("/login");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.classList.remove("light", "dark");
|
root.classList.remove("light", "dark");
|
||||||
@@ -38,60 +47,73 @@ function AppLayout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
|
<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">
|
<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 className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
|
<div>
|
||||||
<div className="flex items-center gap-3 md:flex-col md:items-start">
|
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
|
||||||
<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)]">
|
<div className="flex items-center gap-3 md:flex-col md:items-start">
|
||||||
<ShieldHalf size={20} />
|
<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>
|
||||||
<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">
|
||||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
<BadgeCheck size={14} />
|
||||||
{t("ui.dev.brand", "Baron 로그인")}
|
{t("ui.dev.scope_badge", "Scoped to /dev")}
|
||||||
</p>
|
|
||||||
<h1 className="text-lg font-semibold">
|
|
||||||
{t("ui.dev.console_title", "Developer Console")}
|
|
||||||
</h1>
|
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<nav className="px-2 pb-4 md:px-3 md:pb-8">
|
||||||
<BadgeCheck size={14} />
|
<div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start">
|
||||||
{t("ui.dev.scope_badge", "Scoped to /dev")}
|
<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>
|
||||||
</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">
|
<div className="px-2 pb-6 md:px-3">
|
||||||
<span className="rounded-full border border-border px-3 py-1">
|
<button
|
||||||
{t("ui.dev.env_badge", "Env: dev")}
|
type="button"
|
||||||
</span>
|
onClick={handleLogout}
|
||||||
</div>
|
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"
|
||||||
<div className="flex flex-col gap-1">
|
>
|
||||||
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
|
<LogOut size={18} />
|
||||||
<NavLink
|
<span>{t("ui.dev.nav.logout", "Logout")}</span>
|
||||||
key={to}
|
</button>
|
||||||
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>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ function ClientConsentsPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<Link to="/clients" className="hover:text-primary">
|
<Link to="/clients" className="hover:text-primary">
|
||||||
{t("ui.dev.clients.consents.breadcrumb.clients", "Clients")}
|
{t("ui.dev.clients.consents.breadcrumb.clients", "Apps")}
|
||||||
</Link>
|
</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span>{clientData?.client?.name || clientId}</span>
|
<span>{clientData?.client?.name || clientId}</span>
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ function ClientDetailsPage() {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
{t("msg.dev.clients.details.loading", "Loading client...")}
|
{t("msg.dev.clients.details.loading", "Loading app...")}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -137,7 +137,7 @@ function ClientDetailsPage() {
|
|||||||
<div className="p-8 text-center text-red-500">
|
<div className="p-8 text-center text-red-500">
|
||||||
{t(
|
{t(
|
||||||
"msg.dev.clients.details.load_error",
|
"msg.dev.clients.details.load_error",
|
||||||
"Error loading client: {{error}}",
|
"Error loading app: {{error}}",
|
||||||
{ error: errMsg || t("msg.common.unknown_error", "unknown error") },
|
{ error: errMsg || t("msg.common.unknown_error", "unknown error") },
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -185,7 +185,7 @@ function ClientDetailsPage() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Link to="/clients" className="text-primary hover:underline">
|
<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>
|
</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span className="text-foreground">
|
<span className="text-foreground">
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ function ClientGeneralPage() {
|
|||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [logoUrl, setLogoUrl] = useState("");
|
const [logoUrl, setLogoUrl] = useState("");
|
||||||
const [clientType, setClientType] = useState<ClientType>("confidential");
|
const [clientType, setClientType] = useState<ClientType>("private");
|
||||||
const [status, setStatus] = useState<ClientStatus>("active");
|
const [status, setStatus] = useState<ClientStatus>("active");
|
||||||
const [redirectUris, setRedirectUris] = useState("");
|
const [redirectUris, setRedirectUris] = useState("");
|
||||||
const [scopes, setScopes] = useState<ScopeItem[]>(() => [
|
const [scopes, setScopes] = useState<ScopeItem[]>(() => [
|
||||||
@@ -157,6 +157,21 @@ function ClientGeneralPage() {
|
|||||||
}
|
}
|
||||||
alert(t("msg.dev.clients.general.saved", "설정이 저장되었습니다."));
|
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) {
|
if (!isCreate && isLoading) {
|
||||||
@@ -376,7 +391,6 @@ function ClientGeneralPage() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-muted/50 border-b border-border text-xs uppercase tracking-wider text-muted-foreground">
|
<thead className="bg-muted/50 border-b border-border text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left font-bold">Scope Name</th>
|
|
||||||
<th className="px-4 py-3 text-left font-bold">
|
<th className="px-4 py-3 text-left font-bold">
|
||||||
{t(
|
{t(
|
||||||
"ui.dev.clients.general.scopes.table.name",
|
"ui.dev.clients.general.scopes.table.name",
|
||||||
@@ -395,7 +409,9 @@ function ClientGeneralPage() {
|
|||||||
"Mandatory",
|
"Mandatory",
|
||||||
)}
|
)}
|
||||||
</th>
|
</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
@@ -489,7 +505,7 @@ function ClientGeneralPage() {
|
|||||||
<label
|
<label
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-pointer flex-col gap-1 rounded-xl border-2 p-4 transition",
|
"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-primary bg-primary/5"
|
||||||
: "border-border bg-card hover:border-muted-foreground/40",
|
: "border-border bg-card hover:border-muted-foreground/40",
|
||||||
)}
|
)}
|
||||||
@@ -498,31 +514,28 @@ function ClientGeneralPage() {
|
|||||||
className="sr-only"
|
className="sr-only"
|
||||||
type="radio"
|
type="radio"
|
||||||
name="client-type"
|
name="client-type"
|
||||||
checked={clientType === "confidential"}
|
checked={clientType === "private"}
|
||||||
onChange={() => setClientType("confidential")}
|
onChange={() => setClientType("private")}
|
||||||
/>
|
/>
|
||||||
<span className="flex items-center gap-2 text-sm font-bold uppercase text-foreground">
|
<span className="flex items-center gap-2 text-sm font-bold uppercase text-foreground">
|
||||||
<Shield className="h-4 w-4 text-primary" />
|
<Shield className="h-4 w-4 text-primary" />
|
||||||
{t(
|
{t("ui.dev.clients.general.security.private", "Private")}
|
||||||
"ui.dev.clients.general.security.confidential",
|
|
||||||
"Confidential",
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
"msg.dev.clients.general.security.confidential_help",
|
"msg.dev.clients.general.security.private_help",
|
||||||
"서버 사이드 앱(예: Node.js, Java)처럼 비밀키를 안전하게 보관 가능한 경우.",
|
"서버 사이드 앱(예: Node.js, Java)처럼 비밀키를 안전하게 보관 가능한 경우.",
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="absolute right-4 top-4 text-primary">
|
<span className="absolute right-4 top-4 text-primary">
|
||||||
{clientType === "confidential" ? "✓" : ""}
|
{clientType === "private" ? "✓" : ""}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label
|
<label
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-pointer flex-col gap-1 rounded-xl border-2 p-4 transition",
|
"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-primary bg-primary/5"
|
||||||
: "border-border bg-card hover:border-muted-foreground/40",
|
: "border-border bg-card hover:border-muted-foreground/40",
|
||||||
)}
|
)}
|
||||||
@@ -531,21 +544,21 @@ function ClientGeneralPage() {
|
|||||||
className="sr-only"
|
className="sr-only"
|
||||||
type="radio"
|
type="radio"
|
||||||
name="client-type"
|
name="client-type"
|
||||||
checked={clientType === "public"}
|
checked={clientType === "pkce"}
|
||||||
onChange={() => setClientType("public")}
|
onChange={() => setClientType("pkce")}
|
||||||
/>
|
/>
|
||||||
<span className="flex items-center gap-2 text-sm font-bold uppercase text-foreground">
|
<span className="flex items-center gap-2 text-sm font-bold uppercase text-foreground">
|
||||||
<Sparkles className="h-4 w-4" />
|
<Sparkles className="h-4 w-4" />
|
||||||
{t("ui.dev.clients.general.security.public", "Public")}
|
{t("ui.dev.clients.general.security.pkce", "PKCE")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
"msg.dev.clients.general.security.public_help",
|
"msg.dev.clients.general.security.pkce_help",
|
||||||
"SPA/모바일 앱처럼 비밀키 보관이 어려운 경우. PKCE를 기본 사용합니다.",
|
"SPA/모바일 앱처럼 비밀키 보관이 어려운 경우. PKCE를 기본 사용합니다.",
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="absolute right-4 top-4 text-primary">
|
<span className="absolute right-4 top-4 text-primary">
|
||||||
{clientType === "public" ? "✓" : ""}
|
{clientType === "pkce" ? "✓" : ""}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -87,7 +87,10 @@ function ClientsPage() {
|
|||||||
|
|
||||||
const clients = data?.items || [];
|
const clients = data?.items || [];
|
||||||
const totalClients = clients.length;
|
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 StatTone = "up" | "down" | "stable";
|
||||||
type StatItem = {
|
type StatItem = {
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
@@ -101,7 +104,7 @@ function ClientsPage() {
|
|||||||
const stats: StatItem[] = [
|
const stats: StatItem[] = [
|
||||||
{
|
{
|
||||||
labelKey: "ui.dev.clients.stats.total",
|
labelKey: "ui.dev.clients.stats.total",
|
||||||
labelFallback: "총 클라이언트",
|
labelFallback: "총 애플리케이션",
|
||||||
value: totalClients.toString(),
|
value: totalClients.toString(),
|
||||||
deltaKey: "ui.dev.clients.stats.realtime",
|
deltaKey: "ui.dev.clients.stats.realtime",
|
||||||
deltaFallback: "Realtime",
|
deltaFallback: "Realtime",
|
||||||
@@ -110,10 +113,10 @@ function ClientsPage() {
|
|||||||
{
|
{
|
||||||
labelKey: "ui.dev.clients.stats.active_sessions",
|
labelKey: "ui.dev.clients.stats.active_sessions",
|
||||||
labelFallback: "활성 세션",
|
labelFallback: "활성 세션",
|
||||||
value: "-",
|
value: activeClients.toString(),
|
||||||
deltaKey: "ui.dev.clients.stats.not_impl",
|
deltaKey: "ui.dev.clients.stats.realtime",
|
||||||
deltaFallback: "Not impl",
|
deltaFallback: "Realtime",
|
||||||
tone: "stable" as const,
|
tone: "up" as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
labelKey: "ui.dev.clients.stats.auth_failures",
|
labelKey: "ui.dev.clients.stats.auth_failures",
|
||||||
@@ -266,7 +269,7 @@ function ClientsPage() {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<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" />
|
<ServerCog className="h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
<ShieldHalf className="h-4 w-4" />
|
<ShieldHalf className="h-4 w-4" />
|
||||||
@@ -309,16 +312,11 @@ function ClientsPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={client.type === "private" ? "success" : "muted"}
|
||||||
client.type === "confidential" ? "success" : "muted"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{client.type === "confidential"
|
{client.type === "private"
|
||||||
? t(
|
? t("ui.dev.clients.type.private", "Private")
|
||||||
"ui.dev.clients.type.confidential",
|
: t("ui.dev.clients.type.pkce", "PKCE")}
|
||||||
"기밀(Confidential)",
|
|
||||||
)
|
|
||||||
: t("ui.dev.clients.type.public", "Public")}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import apiClient from "./apiClient";
|
import apiClient from "./apiClient";
|
||||||
|
|
||||||
export type ClientStatus = "active" | "inactive";
|
export type ClientStatus = "active" | "inactive";
|
||||||
export type ClientType = "confidential" | "public";
|
export type ClientType = "private" | "pkce";
|
||||||
|
|
||||||
export type ClientSummary = {
|
export type ClientSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -205,14 +205,15 @@ saving = "Saving..."
|
|||||||
unknown_error = "unknown error"
|
unknown_error = "unknown error"
|
||||||
|
|
||||||
[msg.dev]
|
[msg.dev]
|
||||||
|
logout_confirm = "Are you sure you want to log out?"
|
||||||
|
|
||||||
[msg.dev.clients]
|
[msg.dev.clients]
|
||||||
copy_client_id = "Copy Client Id"
|
copy_client_id = "Copy Client Id"
|
||||||
load_error = "Error loading clients: {{error}}"
|
load_error = "Error loading clients: {{error}}"
|
||||||
loading = "Loading clients..."
|
loading = "Loading apps..."
|
||||||
showing = "Showing {{shown}} of {{total}} clients"
|
showing = "Showing {{shown}} of {{total}} apps"
|
||||||
status_update_error = "Failed to update client status"
|
status_update_error = "Failed to update client status"
|
||||||
status_updated = "Status Updated"
|
status_updated = "The app has been {{status}}."
|
||||||
|
|
||||||
[msg.dev.clients.consents]
|
[msg.dev.clients.consents]
|
||||||
empty = "No consents found."
|
empty = "No consents found."
|
||||||
@@ -260,9 +261,9 @@ empty = "Empty"
|
|||||||
subtitle = "Subtitle"
|
subtitle = "Subtitle"
|
||||||
|
|
||||||
[msg.dev.clients.general.security]
|
[msg.dev.clients.general.security]
|
||||||
confidential_help = "Confidential Help"
|
private_help = "Private App (Server-side): For apps that can safely store a client secret, such as Node.js or Java servers."
|
||||||
public_help = "Public Help"
|
pkce_help = "PKCE App (SPA/Mobile): For apps that cannot safely store a client secret. PKCE is mandatory."
|
||||||
subtitle = "Subtitle"
|
subtitle = "Select application type. Security level determines authentication method."
|
||||||
|
|
||||||
[msg.dev.clients.help]
|
[msg.dev.clients.help]
|
||||||
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."
|
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."
|
||||||
@@ -927,10 +928,14 @@ console_title = "Developer Console"
|
|||||||
env_badge = "Env: dev"
|
env_badge = "Env: dev"
|
||||||
scope_badge = "Scoped to /dev"
|
scope_badge = "Scoped to /dev"
|
||||||
|
|
||||||
|
[ui.dev.nav]
|
||||||
|
clients = "Connected Application"
|
||||||
|
logout = "Logout"
|
||||||
|
|
||||||
[ui.dev.clients]
|
[ui.dev.clients]
|
||||||
copy_client_id = "Copy client id"
|
copy_client_id = "Copy client id"
|
||||||
new = "New"
|
new = "Add Connected Application"
|
||||||
search_placeholder = "Search Placeholder"
|
search_placeholder = "Search by app name or ID..."
|
||||||
tenant_scoped = "Tenant-scoped"
|
tenant_scoped = "Tenant-scoped"
|
||||||
untitled = "Untitled"
|
untitled = "Untitled"
|
||||||
|
|
||||||
@@ -1006,8 +1011,8 @@ consents = "Consent & Users"
|
|||||||
settings = "Settings"
|
settings = "Settings"
|
||||||
|
|
||||||
[ui.dev.clients.general]
|
[ui.dev.clients.general]
|
||||||
create = "Create"
|
create = "Create Application"
|
||||||
display_new = "Display New"
|
display_new = "Add Connected Application"
|
||||||
save = "Settings Save"
|
save = "Settings Save"
|
||||||
title_create = "Create Client"
|
title_create = "Create Client"
|
||||||
title_edit = "Client Settings"
|
title_edit = "Client Settings"
|
||||||
@@ -1043,10 +1048,11 @@ title = "Scopes"
|
|||||||
description = "Description"
|
description = "Description"
|
||||||
mandatory = "Mandatory"
|
mandatory = "Mandatory"
|
||||||
name = "Scope Name"
|
name = "Scope Name"
|
||||||
|
delete = "Delete"
|
||||||
|
|
||||||
[ui.dev.clients.general.security]
|
[ui.dev.clients.general.security]
|
||||||
confidential = "Confidential"
|
private = "Private"
|
||||||
public = "Public"
|
pkce = "PKCE"
|
||||||
title = "Security Settings"
|
title = "Security Settings"
|
||||||
|
|
||||||
[ui.dev.clients.help]
|
[ui.dev.clients.help]
|
||||||
@@ -1055,7 +1061,7 @@ title = "Need help with OIDC configuration?"
|
|||||||
view_guides = "View guides"
|
view_guides = "View guides"
|
||||||
|
|
||||||
[ui.dev.clients.list]
|
[ui.dev.clients.list]
|
||||||
title = "Title"
|
title = "Connected Applications"
|
||||||
|
|
||||||
[ui.dev.clients.owner]
|
[ui.dev.clients.owner]
|
||||||
avatar_alt = "ops user"
|
avatar_alt = "ops user"
|
||||||
@@ -1079,8 +1085,8 @@ status = "Status"
|
|||||||
type = "Type"
|
type = "Type"
|
||||||
|
|
||||||
[ui.dev.clients.type]
|
[ui.dev.clients.type]
|
||||||
confidential = "Confidential"
|
private = "Private"
|
||||||
public = "Public"
|
pkce = "PKCE"
|
||||||
|
|
||||||
[ui.dev.dashboard]
|
[ui.dev.dashboard]
|
||||||
ready_badge = "devfront ready"
|
ready_badge = "devfront ready"
|
||||||
|
|||||||
@@ -205,14 +205,15 @@ saving = "저장 중..."
|
|||||||
unknown_error = "unknown error"
|
unknown_error = "unknown error"
|
||||||
|
|
||||||
[msg.dev]
|
[msg.dev]
|
||||||
|
logout_confirm = "로그아웃 하시겠습니까?"
|
||||||
|
|
||||||
[msg.dev.clients]
|
[msg.dev.clients]
|
||||||
copy_client_id = "클라이언트 ID가 복사되었습니다."
|
copy_client_id = "Client ID가 복사되었습니다."
|
||||||
load_error = "Error loading clients: {{error}}"
|
load_error = "Error loading clients: {{error}}"
|
||||||
loading = "Loading clients..."
|
loading = "Loading apps..."
|
||||||
showing = "Showing {{shown}} of {{total}} clients"
|
showing = "Showing {{shown}} of {{total}} apps"
|
||||||
status_update_error = "Failed to update client status"
|
status_update_error = "Failed to update client status"
|
||||||
status_updated = "클라이언트가 {{status}}되었습니다."
|
status_updated = "앱이 {{status}}되었습니다."
|
||||||
|
|
||||||
[msg.dev.clients.consents]
|
[msg.dev.clients.consents]
|
||||||
empty = "No consents found."
|
empty = "No consents found."
|
||||||
@@ -257,19 +258,19 @@ help = "인증 후 리다이렉트될 URI를 입력하세요. 생성 후 Connect
|
|||||||
|
|
||||||
[msg.dev.clients.general.scopes]
|
[msg.dev.clients.general.scopes]
|
||||||
empty = "등록된 스코프가 없습니다."
|
empty = "등록된 스코프가 없습니다."
|
||||||
subtitle = "이 클라이언트가 요청할 수 있는 권한 범위를 정의합니다."
|
subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다."
|
||||||
|
|
||||||
[msg.dev.clients.general.security]
|
[msg.dev.clients.general.security]
|
||||||
confidential_help = "서버 사이드 앱(예: Node.js, Java)처럼 비밀키를 안전하게 보관 가능한 경우."
|
private_help = "Private 앱 (서버 사이드 앱): Node.js, Java 등 비밀키를 안전하게 보관 가능한 경우 사용합니다."
|
||||||
public_help = "SPA/모바일 앱처럼 비밀키 보관이 어려운 경우. PKCE를 기본 사용합니다."
|
pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다."
|
||||||
subtitle = "클라이언트 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다."
|
subtitle = "앱 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다."
|
||||||
|
|
||||||
[msg.dev.clients.help]
|
[msg.dev.clients.help]
|
||||||
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."
|
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."
|
||||||
subtitle = "Developer guides for Confidential/Public clients, redirect URIs, and auth methods."
|
subtitle = "Developer guides for Confidential/Public clients, redirect URIs, and auth methods."
|
||||||
|
|
||||||
[msg.dev.clients.registry]
|
[msg.dev.clients.registry]
|
||||||
description = "OIDC 클라이언트, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다."
|
description = "OIDC 앱, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다."
|
||||||
|
|
||||||
[msg.dev.clients.scopes]
|
[msg.dev.clients.scopes]
|
||||||
email = "이메일 주소 접근"
|
email = "이메일 주소 접근"
|
||||||
@@ -291,7 +292,7 @@ hydra_health = "Hydra Admin 상태 체크 준비"
|
|||||||
|
|
||||||
[msg.dev.sidebar]
|
[msg.dev.sidebar]
|
||||||
notice = "개발자 전용 콘솔입니다."
|
notice = "개발자 전용 콘솔입니다."
|
||||||
notice_detail = "클라이언트 애플리케이션 등록 및 관리를 수행할 수 있습니다."
|
notice_detail = "연동 앱 등록 및 관리를 수행할 수 있습니다."
|
||||||
|
|
||||||
[msg.info]
|
[msg.info]
|
||||||
saved_success = "저장이 완료되었습니다."
|
saved_success = "저장이 완료되었습니다."
|
||||||
@@ -927,10 +928,14 @@ console_title = "Developer Console"
|
|||||||
env_badge = "Env: dev"
|
env_badge = "Env: dev"
|
||||||
scope_badge = "Scoped to /dev"
|
scope_badge = "Scoped to /dev"
|
||||||
|
|
||||||
|
[ui.dev.nav]
|
||||||
|
clients = "연동 앱"
|
||||||
|
logout = "로그아웃"
|
||||||
|
|
||||||
[ui.dev.clients]
|
[ui.dev.clients]
|
||||||
copy_client_id = "Copy client id"
|
copy_client_id = "Copy client id"
|
||||||
new = "새 클라이언트"
|
new = "연동 앱 추가"
|
||||||
search_placeholder = "클라이언트 이름/ID로 검색..."
|
search_placeholder = "연동 앱 이름/ID로 검색..."
|
||||||
tenant_scoped = "Tenant-scoped"
|
tenant_scoped = "Tenant-scoped"
|
||||||
untitled = "Untitled"
|
untitled = "Untitled"
|
||||||
|
|
||||||
@@ -973,13 +978,13 @@ user = "User"
|
|||||||
[ui.dev.clients.details]
|
[ui.dev.clients.details]
|
||||||
|
|
||||||
[ui.dev.clients.details.breadcrumb]
|
[ui.dev.clients.details.breadcrumb]
|
||||||
current = "클라이언트 상세"
|
current = "연동 앱 상세"
|
||||||
section = "Relying Parties"
|
section = "Relying Parties"
|
||||||
|
|
||||||
[ui.dev.clients.details.credentials]
|
[ui.dev.clients.details.credentials]
|
||||||
client_id = "Client ID"
|
client_id = "Client ID"
|
||||||
client_secret = "Client Secret"
|
client_secret = "Client Secret"
|
||||||
title = "클라이언트 자격 증명"
|
title = "앱 자격 증명"
|
||||||
|
|
||||||
[ui.dev.clients.details.endpoints]
|
[ui.dev.clients.details.endpoints]
|
||||||
read_only = "읽기 전용"
|
read_only = "읽기 전용"
|
||||||
@@ -1006,8 +1011,8 @@ consents = "Consent & Users"
|
|||||||
settings = "Settings"
|
settings = "Settings"
|
||||||
|
|
||||||
[ui.dev.clients.general]
|
[ui.dev.clients.general]
|
||||||
create = "클라이언트 생성"
|
create = "앱 생성"
|
||||||
display_new = "새 클라이언트"
|
display_new = "연동 앱 추가"
|
||||||
save = "설정 저장"
|
save = "설정 저장"
|
||||||
title_create = "Create Client"
|
title_create = "Create Client"
|
||||||
title_edit = "Client Settings"
|
title_edit = "Client Settings"
|
||||||
@@ -1043,10 +1048,11 @@ title = "Scopes"
|
|||||||
description = "Description"
|
description = "Description"
|
||||||
mandatory = "Mandatory"
|
mandatory = "Mandatory"
|
||||||
name = "Scope Name"
|
name = "Scope Name"
|
||||||
|
delete = "Delete"
|
||||||
|
|
||||||
[ui.dev.clients.general.security]
|
[ui.dev.clients.general.security]
|
||||||
confidential = "Confidential"
|
private = "Private"
|
||||||
public = "Public"
|
pkce = "PKCE"
|
||||||
title = "보안 설정"
|
title = "보안 설정"
|
||||||
|
|
||||||
[ui.dev.clients.help]
|
[ui.dev.clients.help]
|
||||||
@@ -1055,7 +1061,7 @@ title = "Need help with OIDC configuration?"
|
|||||||
view_guides = "View guides"
|
view_guides = "View guides"
|
||||||
|
|
||||||
[ui.dev.clients.list]
|
[ui.dev.clients.list]
|
||||||
title = "클라이언트 목록"
|
title = "연동 앱 목록"
|
||||||
|
|
||||||
[ui.dev.clients.owner]
|
[ui.dev.clients.owner]
|
||||||
avatar_alt = "ops user"
|
avatar_alt = "ops user"
|
||||||
@@ -1079,8 +1085,8 @@ status = "상태"
|
|||||||
type = "유형"
|
type = "유형"
|
||||||
|
|
||||||
[ui.dev.clients.type]
|
[ui.dev.clients.type]
|
||||||
confidential = "기밀(Confidential)"
|
private = "Private"
|
||||||
public = "Public"
|
pkce = "PKCE"
|
||||||
|
|
||||||
[ui.dev.dashboard]
|
[ui.dev.dashboard]
|
||||||
ready_badge = "devfront ready"
|
ready_badge = "devfront ready"
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ saving = ""
|
|||||||
unknown_error = ""
|
unknown_error = ""
|
||||||
|
|
||||||
[msg.dev]
|
[msg.dev]
|
||||||
|
logout_confirm = ""
|
||||||
|
|
||||||
[msg.dev.clients]
|
[msg.dev.clients]
|
||||||
copy_client_id = ""
|
copy_client_id = ""
|
||||||
@@ -260,8 +261,8 @@ empty = ""
|
|||||||
subtitle = ""
|
subtitle = ""
|
||||||
|
|
||||||
[msg.dev.clients.general.security]
|
[msg.dev.clients.general.security]
|
||||||
confidential_help = ""
|
private_help = ""
|
||||||
public_help = ""
|
pkce_help = ""
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
|
|
||||||
[msg.dev.clients.help]
|
[msg.dev.clients.help]
|
||||||
@@ -939,6 +940,10 @@ console_title = ""
|
|||||||
env_badge = ""
|
env_badge = ""
|
||||||
scope_badge = ""
|
scope_badge = ""
|
||||||
|
|
||||||
|
[ui.dev.nav]
|
||||||
|
clients = ""
|
||||||
|
logout = ""
|
||||||
|
|
||||||
[ui.dev.clients]
|
[ui.dev.clients]
|
||||||
copy_client_id = ""
|
copy_client_id = ""
|
||||||
new = ""
|
new = ""
|
||||||
@@ -1055,10 +1060,11 @@ title = ""
|
|||||||
description = ""
|
description = ""
|
||||||
mandatory = ""
|
mandatory = ""
|
||||||
name = ""
|
name = ""
|
||||||
|
delete = ""
|
||||||
|
|
||||||
[ui.dev.clients.general.security]
|
[ui.dev.clients.general.security]
|
||||||
confidential = ""
|
private = ""
|
||||||
public = ""
|
pkce = ""
|
||||||
title = ""
|
title = ""
|
||||||
|
|
||||||
[ui.dev.clients.help]
|
[ui.dev.clients.help]
|
||||||
@@ -1091,8 +1097,8 @@ status = ""
|
|||||||
type = ""
|
type = ""
|
||||||
|
|
||||||
[ui.dev.clients.type]
|
[ui.dev.clients.type]
|
||||||
confidential = ""
|
private = ""
|
||||||
public = ""
|
pkce = ""
|
||||||
|
|
||||||
[ui.dev.dashboard]
|
[ui.dev.dashboard]
|
||||||
ready_badge = ""
|
ready_badge = ""
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ test("clients page loads correctly", async ({ page }) => {
|
|||||||
{
|
{
|
||||||
id: "client-playwright",
|
id: "client-playwright",
|
||||||
name: "Playwright Client",
|
name: "Playwright Client",
|
||||||
type: "confidential",
|
type: "private",
|
||||||
status: "active",
|
status: "active",
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
redirectUris: ["http://localhost:5174/callback"],
|
redirectUris: ["http://localhost:5174/callback"],
|
||||||
@@ -68,7 +68,7 @@ test("clients page loads correctly", async ({ page }) => {
|
|||||||
await expect(page).toHaveTitle(/바론 개발자 서비스/);
|
await expect(page).toHaveTitle(/바론 개발자 서비스/);
|
||||||
|
|
||||||
// 페이지 내 주요 텍스트 확인
|
// 페이지 내 주요 텍스트 확인
|
||||||
await expect(page.getByText("클라이언트 목록")).toBeVisible();
|
await expect(page.getByText("연동 앱 목록")).toBeVisible();
|
||||||
|
|
||||||
// 테이블 헤더 확인
|
// 테이블 헤더 확인
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# AGENTS 가이드 (Baron SSO)
|
# AGENTS 가이드 (Baron SSO)
|
||||||
|
|
||||||
|
## 버그 수정 절차 대원칙 (강제)
|
||||||
|
- 버그 대응 시 **재현 테스트를 먼저 작성**합니다.
|
||||||
|
- 재현 테스트가 실패하는 상태를 확인한 뒤에만 수정 작업을 시작합니다.
|
||||||
|
- 수정 후에는 테스트를 반복 실행하여 재현 테스트가 안정적으로 통과할 때까지 계속 보완합니다.
|
||||||
|
- 재현 테스트 없이 “감으로 수정”하거나, 실패 테스트를 남긴 채 성공으로 보고하지 않습니다.
|
||||||
|
- 이슈 종료 전에는 최소 1회 이상 실제 사용자 경로(예: 로그인/새로고침/리다이렉트)를 확인합니다.
|
||||||
|
- 테스트/원인/조치 내역은 문서(`docs/test-plan/*`, `docs/trouble-shooting/*`)에 반영합니다.
|
||||||
|
|
||||||
## 목적
|
## 목적
|
||||||
- 인증/인가 허브로서 **Backend + Ory Stack** 중심 아키텍처를 유지
|
- 인증/인가 허브로서 **Backend + Ory Stack** 중심 아키텍처를 유지
|
||||||
- 사용자 플로우(UserFront)와 관리 플로우(Admin/DevFront)를 명확히 분리
|
- 사용자 플로우(UserFront)와 관리 플로우(Admin/DevFront)를 명확히 분리
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
- Backend 테스트 전수 목록: `docs/test-plan/backend-test-inventory.md`
|
- Backend 테스트 전수 목록: `docs/test-plan/backend-test-inventory.md`
|
||||||
- UserFront 테스트 전수 목록: `docs/test-plan/userfront-test-inventory.md`
|
- UserFront 테스트 전수 목록: `docs/test-plan/userfront-test-inventory.md`
|
||||||
- AdminFront/DevFront E2E 전수 목록: `docs/test-plan/web-e2e-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) 실행 커맨드
|
## 4) 실행 커맨드
|
||||||
- Backend 전체 테스트: `cd backend && go test ./...`
|
- Backend 전체 테스트: `cd backend && go test ./...`
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
- UserFront 테스트: `cd userfront && flutter test`
|
- UserFront 테스트: `cd userfront && flutter test`
|
||||||
- AdminFront E2E: `cd adminfront && npm test`
|
- AdminFront E2E: `cd adminfront && npm test`
|
||||||
- DevFront E2E: `cd devfront && npm test`
|
- DevFront E2E: `cd devfront && npm test`
|
||||||
|
- UserFront WASM E2E(계획): `docs/test-plan/userfront-wasm-e2e-expansion-plan.md` 기준으로 Playwright 워크스페이스를 추가한 뒤 실행
|
||||||
|
|
||||||
## 5) 유지 원칙
|
## 5) 유지 원칙
|
||||||
- 신규 기능은 관련 테스트를 반드시 추가합니다.
|
- 신규 기능은 관련 테스트를 반드시 추가합니다.
|
||||||
|
|||||||
@@ -31,9 +31,12 @@
|
|||||||
| `backend/internal/handler/auth_handler_qr_test.go:107` | `TestScanQRLogin_Success` | 인증/OIDC 플로우 검증 |
|
| `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:150` | `TestResolveConsentSubjects_TokenAndCookie` | 인증/OIDC 플로우 검증 |
|
||||||
| `backend/internal/handler/auth_handler_qr_test.go:57` | `TestQRLoginFlow_Success` | 인증/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:67` | `TestCompletePasswordReset_MissingLoginID` | 오류/예외/거부 경로 검증 |
|
||||||
| `backend/internal/handler/auth_handler_test.go:50` | `TestCompletePasswordReset_InvalidPasswordPolicy` | 오류/예외/거부 경로 검증 |
|
| `backend/internal/handler/auth_handler_test.go:97` | `TestCompletePasswordReset_InvalidPasswordPolicy` | 오류/예외/거부 경로 검증 |
|
||||||
| `backend/internal/handler/auth_handler_test.go:80` | `TestCompletePasswordReset_NilIDPProvider` | 인증/OIDC 플로우 검증 |
|
| `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:103` | `TestCreateClient_Success` | Hydra/RP 연동 검증 |
|
||||||
| `backend/internal/handler/dev_handler_test.go:15` | `TestListClients_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 연동 검증 |
|
| `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:123` | `TestChainedProviderMetadataUnion` | 회귀 방지 기본 동작 검증 |
|
||||||
| `backend/internal/idp/factory_test.go:139` | `TestChainedProviderUpdateUserPasswordFallback` | 복구/격리/회복 탄력성 검증 |
|
| `backend/internal/idp/factory_test.go:139` | `TestChainedProviderUpdateUserPasswordFallback` | 복구/격리/회복 탄력성 검증 |
|
||||||
| `backend/internal/idp/factory_test.go:152` | `TestChainedProviderUpdateUserPasswordAllFail` | 인증/OIDC 플로우 검증 |
|
| `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/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:22` | `TestErrorCodeEnricher_AddsCodeToLegacyErrorResponse` | 오류/예외/거부 경로 검증 |
|
||||||
| `backend/internal/middleware/error_code_enricher_test.go:50` | `TestErrorCodeEnricher_DoesNotOverrideExistingCode` | 오류/예외/거부 경로 검증 |
|
| `backend/internal/middleware/error_code_enricher_test.go:50` | `TestErrorCodeEnricher_DoesNotOverrideExistingCode` | 오류/예외/거부 경로 검증 |
|
||||||
|
|||||||
@@ -43,15 +43,22 @@
|
|||||||
| `userfront/test/login_challenge_resolver_test.dart` | `widget 값이 없으면 URI query에서 복구` | fallback/복구 경로 검증 |
|
| `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` | `widget 값이 있으면 최우선으로 사용` | 핵심 동작 회귀 방지 검증 |
|
||||||
| `userfront/test/login_challenge_resolver_test.dart` | `값이 전부 없으면 missing` | fallback/복구 경로 검증 |
|
| `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` | `http/https 절대 URL만 허용` | 핵심 동작 회귀 방지 검증 |
|
||||||
| `userfront/test/oidc_redirect_guard_test.dart` | `빈 문자열과 파싱 실패를 차단` | 핵심 동작 회귀 방지 검증 |
|
| `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가 없고 jwt가 있으면 로컬 로그인 완료로 진행한다` | 로그인 분기/라우팅 규칙 검증 |
|
||||||
| `userfront/test/password_login_flow_policy_test.dart` | `OIDC challenge가 있고 redirectTo가 없으면 accept를 시도한다` | 로그인 분기/라우팅 규칙 검증 |
|
| `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/jwt 모두 없으면 invalid로 처리한다` | 로그인 분기/라우팅 규칙 검증 |
|
||||||
| `userfront/test/password_login_flow_policy_test.dart` | `redirectTo가 있으면 OIDC redirect를 우선한다` | 로그인 분기/라우팅 규칙 검증 |
|
| `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` | `/login: login_challenge와 redirect_uri를 전달` | 리다이렉트/쿼리 보존 규칙 검증 |
|
||||||
| `userfront/test/router_redirect_widget_test.dart` | `로그인 상태: profile 접근 시 signin으로 리다이렉트하지 않음` | 로그인 분기/라우팅 규칙 검증 |
|
| `userfront/test/router_redirect_widget_test.dart` | `로그인 상태: profile 접근 시 signin으로 리다이렉트하지 않음` | 로그인 분기/라우팅 규칙 검증 |
|
||||||
| `userfront/test/router_redirect_widget_test.dart` | `로그인 후 같은 브라우저 새 창/팝업에서도 세션이 유지된다` | 로그인 세션 지속성(동일 브라우저) 검증 |
|
| `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/login_challenge가 signin으로 전달` | 리다이렉트/쿼리 보존 규칙 검증 |
|
||||||
| `userfront/test/router_redirect_widget_test.dart` | `비로그인: redirect_uri가 없으면 redirect_url을 전달` | 리다이렉트/쿼리 보존 규칙 검증 |
|
| `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` | 기본 앱 렌더링 스모크 검증 |
|
||||||
|
|||||||
69
docs/test-plan/userfront-wasm-e2e-expansion-plan.md
Normal 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) 운영 원칙
|
||||||
|
- 버그는 반드시 재현 테스트를 먼저 추가합니다.
|
||||||
|
- 재현 테스트가 실패하는 상태를 확인한 뒤 수정합니다.
|
||||||
|
- 수정 후 동일 테스트를 반복 실행해 안정 통과까지 완료합니다.
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
- 범위: `adminfront/tests/*.spec.ts`, `devfront/tests/*.spec.ts`
|
- 범위: `adminfront/tests/*.spec.ts`, `devfront/tests/*.spec.ts`
|
||||||
- 기준: Playwright `test(...)` 케이스 전수
|
- 기준: Playwright `test(...)` 케이스 전수
|
||||||
|
- 참고: UserFront WASM E2E 확장 계획은 `docs/test-plan/userfront-wasm-e2e-expansion-plan.md`에서 별도 관리
|
||||||
|
|
||||||
| 파일 | 테스트 | 역할 |
|
| 파일 | 테스트 | 역할 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Issue #277/#302 트러블슈팅 기록: 로그인 후 공백 화면 + 새로고침 시 signin 회귀
|
||||||
|
|
||||||
|
## 기준 시점
|
||||||
|
- 2026-02-23 KST
|
||||||
|
- 재현 환경: `https://sss.hmac.kr` (WASM 배포)
|
||||||
|
|
||||||
|
## 증상
|
||||||
|
- 로그인 직후 URL은 `/{locale}` 또는 `/{locale}/dashboard`로 보이지만 화면이 렌더링되지 않음
|
||||||
|
- 이후 새로고침하면 `/{locale}/signin`으로 되돌아감
|
||||||
|
- 콘솔/백엔드 수집 로그:
|
||||||
|
- `Null check operator used on a null value`
|
||||||
|
- `wasm-function[765]` 포함 스택 반복
|
||||||
|
|
||||||
|
## 스택 매핑 결과 (source-map + no-strip-wasm)
|
||||||
|
- 매핑 커맨드:
|
||||||
|
- `python3 scripts/map_wasm_stack.py --wasm userfront/build/web/main.dart.wasm --sourcemap userfront/build/web/main.dart.wasm.map --frame ...`
|
||||||
|
- 핵심 프레임:
|
||||||
|
- `wasm-function[765]` -> `_TypeError._throwNullCheckErrorWithCurrentStack`
|
||||||
|
- 상위 프레임 -> Flutter `NavigatorState.didUpdateWidget/_updatePages` 경로
|
||||||
|
- 결론:
|
||||||
|
- 단일 위젯 null 접근보다, 라우트 갱신 타이밍/중복 네비게이션 경쟁에서 `Navigator` 내부에서 터지는 양상
|
||||||
|
|
||||||
|
## 지금까지 시행착오와 실패 내역
|
||||||
|
1. `LocaleGate`, `LanguageSelector`의 `EasyLocalization.of(context)` null 방어만 적용
|
||||||
|
- 결과: 동일 예외 재발
|
||||||
|
- 이유: 루트 원인은 로케일 위젯 단일 null 접근이 아니라 네비게이션 경쟁 구간
|
||||||
|
|
||||||
|
2. `/ko` 루트에서 signin 강제 리다이렉트만 강화
|
||||||
|
- 결과: 최초 진입은 일부 개선됐지만 로그인 직후/새로고침 회귀 지속
|
||||||
|
- 이유: 로그인 성공 경로가 루트(`/{locale}`)와 엮이면서 라우트 재평가가 중첩
|
||||||
|
|
||||||
|
3. 로그인 화면에서 `AuthNotifier.notify()` + `context.go(...)` 동시 수행
|
||||||
|
- 결과: 간헐적 경쟁 상태 유발 가능성 확인
|
||||||
|
- 조치: 로컬 네비게이션 1회 가드 도입(`_goLocalizedHomeOnce`)
|
||||||
|
|
||||||
|
4. cookie 세션 승격이 토큰 저장 이후 덮어쓰는 경합
|
||||||
|
- 결과: 일부 흐름에서 저장 상태 불안정 가능성
|
||||||
|
- 조치: `cookie_session_policy` 추가, 토큰 존재 시 불필요한 cookie 승격 차단
|
||||||
|
|
||||||
|
5. `/:locale` 엔트리가 redirect 없이 매칭되는 구조
|
||||||
|
- 결과: `/ko` 직접 진입 시 페이지 스택 재계산 과정에서 `NavigatorState.didUpdateWidget/_updatePages` 경로 null check 재발
|
||||||
|
- 이유: `/ko`는 실질 화면이 아닌 분기 지점인데, 명시적 redirect 경로가 없으면 라우트 갱신 타이밍 경쟁에 취약
|
||||||
|
- 조치: `/:locale`를 redirect 전용 엔트리로 확정(비로그인 `/{locale}/signin`, 로그인 `/{locale}/dashboard`)
|
||||||
|
|
||||||
|
## 최종 반영 방향 (이번 패치)
|
||||||
|
1. 로그인 성공 기본 경로를 명시적으로 `/{locale}/dashboard`로 고정
|
||||||
|
- `buildLocalizedHomePath()` 반환값을 `/{locale}/dashboard`로 변경
|
||||||
|
- `/:locale` 엔트리는 `/:locale/dashboard`로 redirect 전용 처리
|
||||||
|
|
||||||
|
2. 라우터/화면 역할 분리
|
||||||
|
- 보호 경로 검사는 router redirect에서 수행
|
||||||
|
- 대시보드는 필요 시 cookie 세션 복구를 1회 시도 후 signin 이동
|
||||||
|
|
||||||
|
3. 중복 네비게이션 억제
|
||||||
|
- 로그인 성공 시 내부 이동은 1회만 수행
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
- 추가 테스트:
|
||||||
|
- `userfront/test/login_navigation_race_test.dart`
|
||||||
|
- `userfront/test/cookie_session_policy_test.dart`
|
||||||
|
- `userfront/test/router_redirect_widget_test.dart` (`/{locale}` 직접 진입 시 signin/dashboard 분기 검증)
|
||||||
|
- 갱신 테스트:
|
||||||
|
- `userfront/test/locale_utils_test.dart` (home path `/{locale}/dashboard` 기준)
|
||||||
|
- 실행:
|
||||||
|
- `flutter test`
|
||||||
|
- `flutter test --platform chrome test/router_redirect_widget_test.dart test/login_navigation_race_test.dart test/cookie_session_policy_test.dart`
|
||||||
|
|
||||||
|
## 남은 리스크
|
||||||
|
- 실제 브라우저 저장소 정책(localStorage 차단/쿠키 정책)에 따라 세션 판정이 달라질 수 있음
|
||||||
|
- 운영 검증 시 네트워크/스토리지 상태를 함께 수집해야 원인 분리 가능
|
||||||
|
|
||||||
|
## 운영 확인 체크리스트
|
||||||
|
1. 비로그인으로 `/{locale}` 접속 시 즉시 `/{locale}/signin` 이동
|
||||||
|
2. 로그인 성공 시 `/{locale}/dashboard` 진입
|
||||||
|
3. `/{locale}/dashboard`에서 새로고침 후 세션 유지 (동일 브라우저)
|
||||||
|
4. 실패 시 `RECOVERY_NAV_NULL_CHECK`와 wasm frame 동시 수집
|
||||||
@@ -243,14 +243,15 @@ saving = "Saving..."
|
|||||||
unknown_error = "unknown error"
|
unknown_error = "unknown error"
|
||||||
|
|
||||||
[msg.dev]
|
[msg.dev]
|
||||||
|
logout_confirm = "Are you sure you want to log out?"
|
||||||
|
|
||||||
[msg.dev.clients]
|
[msg.dev.clients]
|
||||||
copy_client_id = "Copy Client Id"
|
copy_client_id = "Copy Client Id"
|
||||||
load_error = "Error loading clients: {{error}}"
|
load_error = "Error loading clients: {{error}}"
|
||||||
loading = "Loading clients..."
|
loading = "Loading apps..."
|
||||||
showing = "Showing {{shown}} of {{total}} clients"
|
showing = "Showing {{shown}} of {{total}} apps"
|
||||||
status_update_error = "Failed to update client status"
|
status_update_error = "Failed to update client status"
|
||||||
status_updated = "Status Updated"
|
status_updated = "The app has been {{status}}."
|
||||||
|
|
||||||
[msg.dev.clients.consents]
|
[msg.dev.clients.consents]
|
||||||
empty = "No consents found."
|
empty = "No consents found."
|
||||||
@@ -285,6 +286,7 @@ note = "Note"
|
|||||||
load_error = "Error loading client: {{error}}"
|
load_error = "Error loading client: {{error}}"
|
||||||
loading = "Loading client..."
|
loading = "Loading client..."
|
||||||
saved = "Saved"
|
saved = "Saved"
|
||||||
|
save_error = "Failed to save: {{error}}"
|
||||||
|
|
||||||
[msg.dev.clients.general.identity]
|
[msg.dev.clients.general.identity]
|
||||||
logo_help = "Logo Help"
|
logo_help = "Logo Help"
|
||||||
@@ -298,9 +300,9 @@ empty = "Empty"
|
|||||||
subtitle = "Subtitle"
|
subtitle = "Subtitle"
|
||||||
|
|
||||||
[msg.dev.clients.general.security]
|
[msg.dev.clients.general.security]
|
||||||
confidential_help = "Confidential Help"
|
private_help = "Private App (Server-side): For apps that can safely store a client secret, such as Node.js or Java servers."
|
||||||
public_help = "Public Help"
|
pkce_help = "PKCE App (SPA/Mobile): For apps that cannot safely store a client secret. PKCE is mandatory."
|
||||||
subtitle = "Subtitle"
|
subtitle = "Select application type. Security level determines authentication method."
|
||||||
|
|
||||||
[msg.dev.clients.help]
|
[msg.dev.clients.help]
|
||||||
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."
|
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."
|
||||||
@@ -352,6 +354,7 @@ approved_device = "Approved Device"
|
|||||||
approved_ip = "Approve IP: {{ip}}"
|
approved_ip = "Approve IP: {{ip}}"
|
||||||
audit_empty = "Audit Empty"
|
audit_empty = "Audit Empty"
|
||||||
audit_load_error = "Audit Load Error"
|
audit_load_error = "Audit Load Error"
|
||||||
|
render_error = "Dashboard render error: {{error}}"
|
||||||
auth_method = "Auth Method"
|
auth_method = "Auth Method"
|
||||||
client_id = "Client ID: {{id}}"
|
client_id = "Client ID: {{id}}"
|
||||||
client_id_missing = "Client Id Missing"
|
client_id_missing = "Client Id Missing"
|
||||||
@@ -1051,10 +1054,14 @@ console_title = "Developer Console"
|
|||||||
env_badge = "Env: dev"
|
env_badge = "Env: dev"
|
||||||
scope_badge = "Scoped to /dev"
|
scope_badge = "Scoped to /dev"
|
||||||
|
|
||||||
|
[ui.dev.nav]
|
||||||
|
clients = "Connected Application"
|
||||||
|
logout = "Logout"
|
||||||
|
|
||||||
[ui.dev.clients]
|
[ui.dev.clients]
|
||||||
copy_client_id = "Copy client id"
|
copy_client_id = "Copy client id"
|
||||||
new = "New"
|
new = "Add Connected Application"
|
||||||
search_placeholder = "Search Placeholder"
|
search_placeholder = "Search by app name or ID..."
|
||||||
tenant_scoped = "Tenant-scoped"
|
tenant_scoped = "Tenant-scoped"
|
||||||
untitled = "Untitled"
|
untitled = "Untitled"
|
||||||
|
|
||||||
@@ -1130,8 +1137,8 @@ consents = "Consent & Users"
|
|||||||
settings = "Settings"
|
settings = "Settings"
|
||||||
|
|
||||||
[ui.dev.clients.general]
|
[ui.dev.clients.general]
|
||||||
create = "Create"
|
create = "Create Application"
|
||||||
display_new = "Display New"
|
display_new = "Add Connected Application"
|
||||||
save = "Settings Save"
|
save = "Settings Save"
|
||||||
title_create = "Create Client"
|
title_create = "Create Client"
|
||||||
title_edit = "Client Settings"
|
title_edit = "Client Settings"
|
||||||
@@ -1167,10 +1174,11 @@ title = "Scopes"
|
|||||||
description = "Description"
|
description = "Description"
|
||||||
mandatory = "Mandatory"
|
mandatory = "Mandatory"
|
||||||
name = "Scope Name"
|
name = "Scope Name"
|
||||||
|
delete = "Delete"
|
||||||
|
|
||||||
[ui.dev.clients.general.security]
|
[ui.dev.clients.general.security]
|
||||||
confidential = "Confidential"
|
private = "Private"
|
||||||
public = "Public"
|
pkce = "PKCE"
|
||||||
title = "Security Settings"
|
title = "Security Settings"
|
||||||
|
|
||||||
[ui.dev.clients.help]
|
[ui.dev.clients.help]
|
||||||
@@ -1179,7 +1187,7 @@ title = "Need help with OIDC configuration?"
|
|||||||
view_guides = "View guides"
|
view_guides = "View guides"
|
||||||
|
|
||||||
[ui.dev.clients.list]
|
[ui.dev.clients.list]
|
||||||
title = "Title"
|
title = "Connected Applications"
|
||||||
|
|
||||||
[ui.dev.clients.owner]
|
[ui.dev.clients.owner]
|
||||||
avatar_alt = "ops user"
|
avatar_alt = "ops user"
|
||||||
@@ -1203,8 +1211,8 @@ status = "Status"
|
|||||||
type = "Type"
|
type = "Type"
|
||||||
|
|
||||||
[ui.dev.clients.type]
|
[ui.dev.clients.type]
|
||||||
confidential = "Confidential"
|
private = "Private"
|
||||||
public = "Public"
|
pkce = "PKCE"
|
||||||
|
|
||||||
[ui.dev.dashboard]
|
[ui.dev.dashboard]
|
||||||
ready_badge = "devfront ready"
|
ready_badge = "devfront ready"
|
||||||
|
|||||||
@@ -243,14 +243,15 @@ saving = "저장 중..."
|
|||||||
unknown_error = "unknown error"
|
unknown_error = "unknown error"
|
||||||
|
|
||||||
[msg.dev]
|
[msg.dev]
|
||||||
|
logout_confirm = "로그아웃 하시겠습니까?"
|
||||||
|
|
||||||
[msg.dev.clients]
|
[msg.dev.clients]
|
||||||
copy_client_id = "클라이언트 ID가 복사되었습니다."
|
copy_client_id = "Client ID가 복사되었습니다."
|
||||||
load_error = "Error loading clients: {{error}}"
|
load_error = "Error loading clients: {{error}}"
|
||||||
loading = "Loading clients..."
|
loading = "Loading apps..."
|
||||||
showing = "Showing {{shown}} of {{total}} clients"
|
showing = "Showing {{shown}} of {{total}} apps"
|
||||||
status_update_error = "Failed to update client status"
|
status_update_error = "Failed to update client status"
|
||||||
status_updated = "클라이언트가 {{status}}되었습니다."
|
status_updated = "앱이 {{status}}되었습니다."
|
||||||
|
|
||||||
[msg.dev.clients.consents]
|
[msg.dev.clients.consents]
|
||||||
empty = "No consents found."
|
empty = "No consents found."
|
||||||
@@ -285,6 +286,7 @@ note = "엔드포인트는 읽기 전용으로 유지하고, 비밀키 재발행
|
|||||||
load_error = "Error loading client: {{error}}"
|
load_error = "Error loading client: {{error}}"
|
||||||
loading = "Loading client..."
|
loading = "Loading client..."
|
||||||
saved = "설정이 저장되었습니다."
|
saved = "설정이 저장되었습니다."
|
||||||
|
save_error = "저장 실패: {{error}}"
|
||||||
|
|
||||||
[msg.dev.clients.general.identity]
|
[msg.dev.clients.general.identity]
|
||||||
logo_help = "인증 화면에 표시될 PNG/SVG URL입니다."
|
logo_help = "인증 화면에 표시될 PNG/SVG URL입니다."
|
||||||
@@ -295,19 +297,19 @@ help = "인증 후 리다이렉트될 URI를 입력하세요. 생성 후 Connect
|
|||||||
|
|
||||||
[msg.dev.clients.general.scopes]
|
[msg.dev.clients.general.scopes]
|
||||||
empty = "등록된 스코프가 없습니다."
|
empty = "등록된 스코프가 없습니다."
|
||||||
subtitle = "이 클라이언트가 요청할 수 있는 권한 범위를 정의합니다."
|
subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다."
|
||||||
|
|
||||||
[msg.dev.clients.general.security]
|
[msg.dev.clients.general.security]
|
||||||
confidential_help = "서버 사이드 앱(예: Node.js, Java)처럼 비밀키를 안전하게 보관 가능한 경우."
|
private_help = "Private 앱 (서버 사이드 앱): Node.js, Java 등 비밀키를 안전하게 보관 가능한 경우 사용합니다."
|
||||||
public_help = "SPA/모바일 앱처럼 비밀키 보관이 어려운 경우. PKCE를 기본 사용합니다."
|
pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다."
|
||||||
subtitle = "클라이언트 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다."
|
subtitle = "앱 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다."
|
||||||
|
|
||||||
[msg.dev.clients.help]
|
[msg.dev.clients.help]
|
||||||
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."
|
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."
|
||||||
subtitle = "Developer guides for Confidential/Public clients, redirect URIs, and auth methods."
|
subtitle = "Developer guides for Confidential/Public clients, redirect URIs, and auth methods."
|
||||||
|
|
||||||
[msg.dev.clients.registry]
|
[msg.dev.clients.registry]
|
||||||
description = "OIDC 클라이언트, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다."
|
description = "OIDC 앱, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다."
|
||||||
|
|
||||||
[msg.dev.clients.scopes]
|
[msg.dev.clients.scopes]
|
||||||
email = "이메일 주소 접근"
|
email = "이메일 주소 접근"
|
||||||
@@ -329,7 +331,7 @@ hydra_health = "Hydra Admin 상태 체크 준비"
|
|||||||
|
|
||||||
[msg.dev.sidebar]
|
[msg.dev.sidebar]
|
||||||
notice = "개발자 전용 콘솔입니다."
|
notice = "개발자 전용 콘솔입니다."
|
||||||
notice_detail = "클라이언트 애플리케이션 등록 및 관리를 수행할 수 있습니다."
|
notice_detail = "연동 앱 등록 및 관리를 수행할 수 있습니다."
|
||||||
|
|
||||||
[msg.info]
|
[msg.info]
|
||||||
saved_success = "저장이 완료되었습니다."
|
saved_success = "저장이 완료되었습니다."
|
||||||
@@ -352,6 +354,7 @@ approved_device = "승인 기기: {{device}}"
|
|||||||
approved_ip = "승인 IP: {{ip}}"
|
approved_ip = "승인 IP: {{ip}}"
|
||||||
audit_empty = "최근 접속 이력이 없습니다."
|
audit_empty = "최근 접속 이력이 없습니다."
|
||||||
audit_load_error = "접속이력을 불러오지 못했습니다."
|
audit_load_error = "접속이력을 불러오지 못했습니다."
|
||||||
|
render_error = "대시보드 렌더링 오류: {{error}}"
|
||||||
auth_method = "인증수단: {{method}}"
|
auth_method = "인증수단: {{method}}"
|
||||||
client_id = "Client ID: {{id}}"
|
client_id = "Client ID: {{id}}"
|
||||||
client_id_missing = "Client ID 없음"
|
client_id_missing = "Client ID 없음"
|
||||||
@@ -1051,10 +1054,14 @@ console_title = "Developer Console"
|
|||||||
env_badge = "Env: dev"
|
env_badge = "Env: dev"
|
||||||
scope_badge = "Scoped to /dev"
|
scope_badge = "Scoped to /dev"
|
||||||
|
|
||||||
|
[ui.dev.nav]
|
||||||
|
clients = "연동 앱"
|
||||||
|
logout = "로그아웃"
|
||||||
|
|
||||||
[ui.dev.clients]
|
[ui.dev.clients]
|
||||||
copy_client_id = "Copy client id"
|
copy_client_id = "Copy client id"
|
||||||
new = "새 클라이언트"
|
new = "연동 앱 추가"
|
||||||
search_placeholder = "클라이언트 이름/ID로 검색..."
|
search_placeholder = "연동 앱 이름/ID로 검색..."
|
||||||
tenant_scoped = "Tenant-scoped"
|
tenant_scoped = "Tenant-scoped"
|
||||||
untitled = "Untitled"
|
untitled = "Untitled"
|
||||||
|
|
||||||
@@ -1097,13 +1104,13 @@ user = "User"
|
|||||||
[ui.dev.clients.details]
|
[ui.dev.clients.details]
|
||||||
|
|
||||||
[ui.dev.clients.details.breadcrumb]
|
[ui.dev.clients.details.breadcrumb]
|
||||||
current = "클라이언트 상세"
|
current = "연동 앱 상세"
|
||||||
section = "Relying Parties"
|
section = "Relying Parties"
|
||||||
|
|
||||||
[ui.dev.clients.details.credentials]
|
[ui.dev.clients.details.credentials]
|
||||||
client_id = "Client ID"
|
client_id = "Client ID"
|
||||||
client_secret = "Client Secret"
|
client_secret = "Client Secret"
|
||||||
title = "클라이언트 자격 증명"
|
title = "앱 자격 증명"
|
||||||
|
|
||||||
[ui.dev.clients.details.endpoints]
|
[ui.dev.clients.details.endpoints]
|
||||||
read_only = "읽기 전용"
|
read_only = "읽기 전용"
|
||||||
@@ -1130,8 +1137,8 @@ consents = "Consent & Users"
|
|||||||
settings = "Settings"
|
settings = "Settings"
|
||||||
|
|
||||||
[ui.dev.clients.general]
|
[ui.dev.clients.general]
|
||||||
create = "클라이언트 생성"
|
create = "앱 생성"
|
||||||
display_new = "새 클라이언트"
|
display_new = "연동 앱 추가"
|
||||||
save = "설정 저장"
|
save = "설정 저장"
|
||||||
title_create = "Create Client"
|
title_create = "Create Client"
|
||||||
title_edit = "Client Settings"
|
title_edit = "Client Settings"
|
||||||
@@ -1167,10 +1174,11 @@ title = "Scopes"
|
|||||||
description = "Description"
|
description = "Description"
|
||||||
mandatory = "Mandatory"
|
mandatory = "Mandatory"
|
||||||
name = "Scope Name"
|
name = "Scope Name"
|
||||||
|
delete = "Delete"
|
||||||
|
|
||||||
[ui.dev.clients.general.security]
|
[ui.dev.clients.general.security]
|
||||||
confidential = "Confidential"
|
private = "Private"
|
||||||
public = "Public"
|
pkce = "PKCE"
|
||||||
title = "보안 설정"
|
title = "보안 설정"
|
||||||
|
|
||||||
[ui.dev.clients.help]
|
[ui.dev.clients.help]
|
||||||
@@ -1179,7 +1187,7 @@ title = "Need help with OIDC configuration?"
|
|||||||
view_guides = "View guides"
|
view_guides = "View guides"
|
||||||
|
|
||||||
[ui.dev.clients.list]
|
[ui.dev.clients.list]
|
||||||
title = "클라이언트 목록"
|
title = "연동 앱 목록"
|
||||||
|
|
||||||
[ui.dev.clients.owner]
|
[ui.dev.clients.owner]
|
||||||
avatar_alt = "ops user"
|
avatar_alt = "ops user"
|
||||||
@@ -1203,8 +1211,8 @@ status = "상태"
|
|||||||
type = "유형"
|
type = "유형"
|
||||||
|
|
||||||
[ui.dev.clients.type]
|
[ui.dev.clients.type]
|
||||||
confidential = "기밀(Confidential)"
|
private = "Private"
|
||||||
public = "Public"
|
pkce = "PKCE"
|
||||||
|
|
||||||
[ui.dev.dashboard]
|
[ui.dev.dashboard]
|
||||||
ready_badge = "devfront ready"
|
ready_badge = "devfront ready"
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ saving = ""
|
|||||||
unknown_error = ""
|
unknown_error = ""
|
||||||
|
|
||||||
[msg.dev]
|
[msg.dev]
|
||||||
|
logout_confirm = ""
|
||||||
|
|
||||||
[msg.dev.clients]
|
[msg.dev.clients]
|
||||||
copy_client_id = ""
|
copy_client_id = ""
|
||||||
@@ -247,6 +248,7 @@ note = ""
|
|||||||
load_error = ""
|
load_error = ""
|
||||||
loading = ""
|
loading = ""
|
||||||
saved = ""
|
saved = ""
|
||||||
|
save_error = ""
|
||||||
|
|
||||||
[msg.dev.clients.general.identity]
|
[msg.dev.clients.general.identity]
|
||||||
logo_help = ""
|
logo_help = ""
|
||||||
@@ -260,8 +262,8 @@ empty = ""
|
|||||||
subtitle = ""
|
subtitle = ""
|
||||||
|
|
||||||
[msg.dev.clients.general.security]
|
[msg.dev.clients.general.security]
|
||||||
confidential_help = ""
|
private_help = ""
|
||||||
public_help = ""
|
pkce_help = ""
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
|
|
||||||
[msg.dev.clients.help]
|
[msg.dev.clients.help]
|
||||||
@@ -314,6 +316,7 @@ approved_device = ""
|
|||||||
approved_ip = ""
|
approved_ip = ""
|
||||||
audit_empty = ""
|
audit_empty = ""
|
||||||
audit_load_error = ""
|
audit_load_error = ""
|
||||||
|
render_error = ""
|
||||||
auth_method = ""
|
auth_method = ""
|
||||||
client_id = ""
|
client_id = ""
|
||||||
client_id_missing = ""
|
client_id_missing = ""
|
||||||
@@ -939,6 +942,10 @@ console_title = ""
|
|||||||
env_badge = ""
|
env_badge = ""
|
||||||
scope_badge = ""
|
scope_badge = ""
|
||||||
|
|
||||||
|
[ui.dev.nav]
|
||||||
|
clients = ""
|
||||||
|
logout = ""
|
||||||
|
|
||||||
[ui.dev.clients]
|
[ui.dev.clients]
|
||||||
copy_client_id = ""
|
copy_client_id = ""
|
||||||
new = ""
|
new = ""
|
||||||
@@ -1055,10 +1062,11 @@ title = ""
|
|||||||
description = ""
|
description = ""
|
||||||
mandatory = ""
|
mandatory = ""
|
||||||
name = ""
|
name = ""
|
||||||
|
delete = ""
|
||||||
|
|
||||||
[ui.dev.clients.general.security]
|
[ui.dev.clients.general.security]
|
||||||
confidential = ""
|
private = ""
|
||||||
public = ""
|
pkce = ""
|
||||||
title = ""
|
title = ""
|
||||||
|
|
||||||
[ui.dev.clients.help]
|
[ui.dev.clients.help]
|
||||||
@@ -1091,8 +1099,8 @@ status = ""
|
|||||||
type = ""
|
type = ""
|
||||||
|
|
||||||
[ui.dev.clients.type]
|
[ui.dev.clients.type]
|
||||||
confidential = ""
|
private = ""
|
||||||
public = ""
|
pkce = ""
|
||||||
|
|
||||||
[ui.dev.dashboard]
|
[ui.dev.dashboard]
|
||||||
ready_badge = ""
|
ready_badge = ""
|
||||||
|
|||||||
240
scripts/map_wasm_stack.py
Normal 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()
|
||||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 25 KiB |
BIN
userfront/assets/baron.ico
Normal file
|
After Width: | Height: | Size: 17 KiB |
@@ -38,6 +38,7 @@ approved_device = "Approved Device"
|
|||||||
approved_ip = "Approve IP: {ip}"
|
approved_ip = "Approve IP: {ip}"
|
||||||
audit_empty = "Audit Empty"
|
audit_empty = "Audit Empty"
|
||||||
audit_load_error = "Audit Load Error"
|
audit_load_error = "Audit Load Error"
|
||||||
|
render_error = "Dashboard render error: {error}"
|
||||||
auth_method = "Auth Method"
|
auth_method = "Auth Method"
|
||||||
client_id = "Client ID: {id}"
|
client_id = "Client ID: {id}"
|
||||||
client_id_missing = "Client Id Missing"
|
client_id_missing = "Client Id Missing"
|
||||||
@@ -557,4 +558,3 @@ verify = "Verify"
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = "Action"
|
action = "Action"
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ approved_device = "승인 기기: {device}"
|
|||||||
approved_ip = "승인 IP: {ip}"
|
approved_ip = "승인 IP: {ip}"
|
||||||
audit_empty = "최근 접속 이력이 없습니다."
|
audit_empty = "최근 접속 이력이 없습니다."
|
||||||
audit_load_error = "접속이력을 불러오지 못했습니다."
|
audit_load_error = "접속이력을 불러오지 못했습니다."
|
||||||
|
render_error = "대시보드 렌더링 오류: {error}"
|
||||||
auth_method = "인증수단: {method}"
|
auth_method = "인증수단: {method}"
|
||||||
client_id = "Client ID: {id}"
|
client_id = "Client ID: {id}"
|
||||||
client_id_missing = "Client ID 없음"
|
client_id_missing = "Client ID 없음"
|
||||||
@@ -557,4 +558,3 @@ verify = "본인인증"
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = "로그인하기"
|
action = "로그인하기"
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ approved_device = ""
|
|||||||
approved_ip = ""
|
approved_ip = ""
|
||||||
audit_empty = ""
|
audit_empty = ""
|
||||||
audit_load_error = ""
|
audit_load_error = ""
|
||||||
|
render_error = ""
|
||||||
auth_method = ""
|
auth_method = ""
|
||||||
client_id = ""
|
client_id = ""
|
||||||
client_id_missing = ""
|
client_id_missing = ""
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 954 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 21 KiB |
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:easy_localization/easy_localization.dart' hide tr;
|
import 'package:easy_localization/easy_localization.dart' hide tr;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
@@ -17,28 +19,54 @@ class LocaleGate extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _LocaleGateState extends State<LocaleGate> {
|
class _LocaleGateState extends State<LocaleGate> {
|
||||||
|
bool _syncScheduled = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
_applyLocale();
|
_scheduleLocaleSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(LocaleGate oldWidget) {
|
void didUpdateWidget(LocaleGate oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (oldWidget.localeCode != widget.localeCode) {
|
if (oldWidget.localeCode != widget.localeCode) {
|
||||||
_applyLocale();
|
_scheduleLocaleSync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _applyLocale() async {
|
void _scheduleLocaleSync() {
|
||||||
final normalized = normalizeLocaleCode(widget.localeCode);
|
if (_syncScheduled) {
|
||||||
LocaleStorage.write(normalized);
|
return;
|
||||||
webWindow.setTitle(tr('ui.userfront.app_title'));
|
}
|
||||||
if (context.locale.languageCode == normalized) {
|
_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;
|
return;
|
||||||
}
|
}
|
||||||
await context.setLocale(Locale(normalized));
|
|
||||||
webWindow.setTitle(tr('ui.userfront.app_title'));
|
webWindow.setTitle(tr('ui.userfront.app_title'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -183,10 +183,11 @@ class LocaleStorageEngine implements LocaleStorageBackend {
|
|||||||
|
|
||||||
final legacy = _readByKey(LocaleStoragePolicy.legacyKey);
|
final legacy = _readByKey(LocaleStoragePolicy.legacyKey);
|
||||||
if (LocaleStoragePolicy.shouldMigrateLegacy(
|
if (LocaleStoragePolicy.shouldMigrateLegacy(
|
||||||
current: current,
|
current: current,
|
||||||
legacy: legacy,
|
legacy: legacy,
|
||||||
)) {
|
) &&
|
||||||
_writeByKey(LocaleStoragePolicy.currentKey, legacy!);
|
legacy != null) {
|
||||||
|
_writeByKey(LocaleStoragePolicy.currentKey, legacy);
|
||||||
_removeEverywhere(LocaleStoragePolicy.legacyKey);
|
_removeEverywhere(LocaleStoragePolicy.legacyKey);
|
||||||
return legacy;
|
return legacy;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ String resolvePreferredLocaleCode() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
final deviceLocale = PlatformDispatcher.instance.locale;
|
final deviceLocale = PlatformDispatcher.instance.locale;
|
||||||
final languageTag =
|
final countryCode = deviceLocale.countryCode;
|
||||||
deviceLocale.countryCode == null || deviceLocale.countryCode!.isEmpty
|
final languageTag = countryCode == null || countryCode.isEmpty
|
||||||
? deviceLocale.languageCode
|
? deviceLocale.languageCode
|
||||||
: '${deviceLocale.languageCode}-${deviceLocale.countryCode}';
|
: '${deviceLocale.languageCode}-$countryCode';
|
||||||
return normalizeLocaleCode(languageTag);
|
return normalizeLocaleCode(languageTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,3 +101,17 @@ String buildSigninRedirectPath(String localeCode, Uri uri) {
|
|||||||
}
|
}
|
||||||
return result;
|
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';
|
||||||
|
}
|
||||||
|
|||||||
26
userfront/lib/core/services/null_check_recovery.dart
Normal 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;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import 'package:web/web.dart' as web;
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'dart:js_interop';
|
import 'dart:js_interop';
|
||||||
import 'auth_token_store.dart';
|
import 'auth_token_store.dart';
|
||||||
|
import '../i18n/locale_utils.dart';
|
||||||
|
|
||||||
void implSendLoginSuccess(String token) {
|
void implSendLoginSuccess(String token) {
|
||||||
var effectiveToken = token;
|
var effectiveToken = token;
|
||||||
@@ -87,8 +88,9 @@ void implSendLoginSuccess(String token) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// No opener and no redirect: fall back to local navigation
|
// No opener and no redirect: fall back to local navigation
|
||||||
debugPrint('No opener found. Redirecting to /.');
|
final fallbackTarget = buildLocalizedHomePath(Uri.base);
|
||||||
web.window.location.href = '/';
|
debugPrint('No opener found. Redirecting to $fallbackTarget.');
|
||||||
|
web.window.location.href = fallbackTarget;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool implIsPopup() {
|
bool implIsPopup() {
|
||||||
|
|||||||
@@ -13,7 +13,13 @@ class LanguageSelector extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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 = [
|
final items = [
|
||||||
DropdownMenuItem(value: 'ko', child: Text(tr('ui.common.language_ko'))),
|
DropdownMenuItem(value: 'ko', child: Text(tr('ui.common.language_ko'))),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
@@ -34,9 +40,16 @@ class LanguageSelector extends StatelessWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
LocaleStorage.write(value);
|
LocaleStorage.write(value);
|
||||||
await context.setLocale(Locale(value));
|
if (localization != null) {
|
||||||
|
await localization.setLocale(Locale(value));
|
||||||
|
}
|
||||||
if (!context.mounted) return;
|
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);
|
final target = buildLocalizedPath(value, uri);
|
||||||
context.go(target);
|
context.go(target);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../../core/services/auth_proxy_service.dart';
|
import '../../../../core/services/auth_proxy_service.dart';
|
||||||
|
import '../../../../core/i18n/locale_utils.dart';
|
||||||
|
|
||||||
class CreateUserScreen extends StatefulWidget {
|
class CreateUserScreen extends StatefulWidget {
|
||||||
const CreateUserScreen({super.key});
|
const CreateUserScreen({super.key});
|
||||||
@@ -67,7 +68,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
|
|||||||
|
|
||||||
// If cancelled or empty
|
// If cancelled or empty
|
||||||
if (inputPassword == null || inputPassword.isEmpty) {
|
if (inputPassword == null || inputPassword.isEmpty) {
|
||||||
if (mounted) context.go('/'); // Kick out
|
if (mounted) context.go(buildLocalizedHomePath(Uri.base)); // Kick out
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +92,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
|
|||||||
backgroundColor: Colors.red,
|
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'),
|
title: const Text('Create User'),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
onPressed: () => context.go('/'),
|
onPressed: () => context.go(buildLocalizedHomePath(Uri.base)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: Center(
|
body: Center(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import '../../../../core/services/auth_proxy_service.dart';
|
import '../../../../core/services/auth_proxy_service.dart';
|
||||||
|
import '../../../../core/i18n/locale_utils.dart';
|
||||||
|
|
||||||
class UserManagementScreen extends StatefulWidget {
|
class UserManagementScreen extends StatefulWidget {
|
||||||
const UserManagementScreen({super.key});
|
const UserManagementScreen({super.key});
|
||||||
@@ -89,7 +90,7 @@ class _UserManagementScreenState extends State<UserManagementScreen>
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (inputPassword == null || inputPassword.isEmpty) {
|
if (inputPassword == null || inputPassword.isEmpty) {
|
||||||
if (mounted) context.go('/');
|
if (mounted) context.go(buildLocalizedHomePath(Uri.base));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +114,7 @@ class _UserManagementScreenState extends State<UserManagementScreen>
|
|||||||
backgroundColor: Colors.red,
|
backgroundColor: Colors.red,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
context.go('/');
|
context.go(buildLocalizedHomePath(Uri.base));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -365,7 +366,7 @@ class _UserManagementScreenState extends State<UserManagementScreen>
|
|||||||
title: const Text('User Management'),
|
title: const Text('User Management'),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
onPressed: () => context.go('/'),
|
onPressed: () => context.go(buildLocalizedHomePath(Uri.base)),
|
||||||
),
|
),
|
||||||
bottom: TabBar(
|
bottom: TabBar(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
bool shouldPromoteCookieSession({
|
||||||
|
required String? currentToken,
|
||||||
|
required String? loginChallenge,
|
||||||
|
}) {
|
||||||
|
final hasToken = currentToken != null && currentToken.trim().isNotEmpty;
|
||||||
|
final hasChallenge =
|
||||||
|
loginChallenge != null && loginChallenge.trim().isNotEmpty;
|
||||||
|
|
||||||
|
// 토큰 기반 세션이 이미 확보된 일반 로그인 흐름에서는
|
||||||
|
// 뒤늦은 쿠키 세션 승격이 토큰을 덮어쓰지 않도록 차단합니다.
|
||||||
|
if (hasToken && !hasChallenge) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import '../../../core/i18n/locale_utils.dart';
|
||||||
|
|
||||||
|
bool isPublicAuthPath(String path, Uri uri) {
|
||||||
|
return path == '/signin' ||
|
||||||
|
path == '/signup' ||
|
||||||
|
path == '/login' ||
|
||||||
|
path == '/registration' ||
|
||||||
|
path == '/verify' ||
|
||||||
|
path == '/verification' ||
|
||||||
|
path.startsWith('/verify/') ||
|
||||||
|
path.startsWith('/l/') ||
|
||||||
|
path == '/approve' ||
|
||||||
|
path.startsWith('/ql/') ||
|
||||||
|
path == '/forgot-password' ||
|
||||||
|
path == '/recovery' ||
|
||||||
|
path == '/reset-password' ||
|
||||||
|
path == '/error' ||
|
||||||
|
path == '/settings' ||
|
||||||
|
path == '/consent' ||
|
||||||
|
path.startsWith('/consent/') ||
|
||||||
|
uri.path.contains('/consent');
|
||||||
|
}
|
||||||
|
|
||||||
|
String? extractLoginShortCode(Uri uri) {
|
||||||
|
final normalizedPath = stripLocalePath(uri);
|
||||||
|
final segments = normalizedPath
|
||||||
|
.split('/')
|
||||||
|
.where((segment) => segment.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
if (segments.length < 2 || segments.first != 'l') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return segments[1];
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../../../core/i18n/locale_utils.dart';
|
||||||
import '../../../../core/services/auth_proxy_service.dart';
|
import '../../../../core/services/auth_proxy_service.dart';
|
||||||
import '../../../../core/services/auth_token_store.dart';
|
import '../../../../core/services/auth_token_store.dart';
|
||||||
|
|
||||||
@@ -17,11 +18,15 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
|
|||||||
bool _success = false;
|
bool _success = false;
|
||||||
bool _isCheckingSession = false;
|
bool _isCheckingSession = false;
|
||||||
bool _redirectingToLogin = false;
|
bool _redirectingToLogin = false;
|
||||||
|
bool _autoApproveTriggered = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_bootstrapCookieSession().then((_) => _redirectIfNotLoggedIn());
|
_bootstrapCookieSession().then((_) {
|
||||||
|
_redirectIfNotLoggedIn();
|
||||||
|
_maybeAutoApprove();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _bootstrapCookieSession() async {
|
Future<bool> _bootstrapCookieSession() async {
|
||||||
@@ -47,18 +52,44 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
|
|||||||
|
|
||||||
void _redirectIfNotLoggedIn() {
|
void _redirectIfNotLoggedIn() {
|
||||||
if (_redirectingToLogin || !mounted) return;
|
if (_redirectingToLogin || !mounted) return;
|
||||||
final hasStoredToken = AuthTokenStore.getToken() != null;
|
final hasStoredToken = AuthTokenStore.getToken()?.isNotEmpty ?? false;
|
||||||
final usesCookie = AuthTokenStore.usesCookie();
|
final usesCookie = AuthTokenStore.usesCookie();
|
||||||
final isLoggedIn = hasStoredToken || usesCookie;
|
final isLoggedIn = hasStoredToken || usesCookie;
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
_redirectingToLogin = true;
|
_redirectingToLogin = true;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
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 {
|
Future<void> _handleApprove() async {
|
||||||
if (widget.pendingRef == null) return;
|
if (widget.pendingRef == null) return;
|
||||||
|
|
||||||
@@ -70,7 +101,8 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
|
|||||||
}
|
}
|
||||||
if (storedToken == null && !hasCookie) {
|
if (storedToken == null && !hasCookie) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
context.go('/signin?notice=qr_login_required');
|
final target = buildLocalizedSigninPath(Uri.base);
|
||||||
|
context.go('$target?notice=qr_login_required');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -94,7 +126,7 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
|
|||||||
|
|
||||||
// Automatically go to dashboard after a short delay
|
// Automatically go to dashboard after a short delay
|
||||||
Future.delayed(const Duration(seconds: 1), () {
|
Future.delayed(const Duration(seconds: 1), () {
|
||||||
if (mounted) context.go('/');
|
if (mounted) context.go(buildLocalizedHomePath(Uri.base));
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _message = "Error: $e");
|
setState(() => _message = "Error: $e");
|
||||||
@@ -105,13 +137,16 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final hasStoredToken = AuthTokenStore.getToken() != null;
|
final hasStoredToken = AuthTokenStore.getToken()?.isNotEmpty ?? false;
|
||||||
final usesCookie = AuthTokenStore.usesCookie();
|
final usesCookie = AuthTokenStore.usesCookie();
|
||||||
final isLoggedIn = hasStoredToken || usesCookie || _isCheckingSession;
|
final isLoggedIn = hasStoredToken || usesCookie || _isCheckingSession;
|
||||||
|
|
||||||
if (!isLoggedIn && !_redirectingToLogin) {
|
if (!isLoggedIn && !_redirectingToLogin) {
|
||||||
_redirectIfNotLoggedIn();
|
_redirectIfNotLoggedIn();
|
||||||
}
|
}
|
||||||
|
if (isLoggedIn && !_success && !_isLoading) {
|
||||||
|
_maybeAutoApprove();
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text("QR Login Approval")),
|
appBar: AppBar(title: const Text("QR Login Approval")),
|
||||||
@@ -148,29 +183,44 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
if (!_success)
|
if (_isLoading)
|
||||||
FilledButton.icon(
|
const Padding(
|
||||||
onPressed: _isLoading || !isLoggedIn ? null : _handleApprove,
|
padding: EdgeInsets.only(bottom: 16),
|
||||||
icon: const Icon(Icons.check_circle),
|
child: CircularProgressIndicator(),
|
||||||
label: const Text("Approve Login"),
|
),
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
minimumSize: const Size.fromHeight(60),
|
if (!_success && !_isLoading)
|
||||||
backgroundColor: Colors.blue,
|
Text(
|
||||||
),
|
"Approving login request automatically...",
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: Colors.grey.shade700),
|
||||||
),
|
),
|
||||||
|
|
||||||
if (!isLoggedIn && !_success)
|
if (!isLoggedIn && !_success)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 16),
|
padding: const EdgeInsets.only(top: 16),
|
||||||
child: TextButton(
|
child: TextButton(
|
||||||
onPressed: () => context.go('/signin'),
|
onPressed: () =>
|
||||||
|
context.go(buildLocalizedSigninPath(Uri.base)),
|
||||||
child: const Text("Login on this device first"),
|
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)
|
if (_success)
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => context.go('/'),
|
onPressed: () => context.go(buildLocalizedHomePath(Uri.base)),
|
||||||
child: const Text("Go to My Dashboard"),
|
child: const Text("Go to My Dashboard"),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:userfront/core/i18n/locale_utils.dart';
|
||||||
import 'package:userfront/core/services/auth_proxy_service.dart';
|
import 'package:userfront/core/services/auth_proxy_service.dart';
|
||||||
import 'package:userfront/core/services/web_window.dart';
|
import 'package:userfront/core/services/web_window.dart';
|
||||||
|
|
||||||
@@ -153,7 +154,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
|||||||
if (redirectTo != null) {
|
if (redirectTo != null) {
|
||||||
webWindow.redirectTo(redirectTo);
|
webWindow.redirectTo(redirectTo);
|
||||||
} else {
|
} else {
|
||||||
if (mounted) context.go('/');
|
if (mounted) context.go(buildLocalizedHomePath(Uri.base));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _isSubmitting = false);
|
setState(() => _isSubmitting = false);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../core/constants/error_whitelist.dart';
|
import '../../../core/constants/error_whitelist.dart';
|
||||||
|
import '../../../core/i18n/locale_utils.dart';
|
||||||
import '../../../core/services/auth_proxy_service.dart';
|
import '../../../core/services/auth_proxy_service.dart';
|
||||||
import 'package:userfront/i18n.dart';
|
import 'package:userfront/i18n.dart';
|
||||||
|
|
||||||
@@ -130,7 +131,8 @@ class ErrorScreen extends StatelessWidget {
|
|||||||
child: Text(tr('ui.userfront.error.go_login')),
|
child: Text(tr('ui.userfront.error.go_login')),
|
||||||
),
|
),
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onPressed: () => context.go('/'),
|
onPressed: () =>
|
||||||
|
context.go(buildLocalizedHomePath(Uri.base)),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: const Color(0xFF111827),
|
foregroundColor: const Color(0xFF111827),
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ import '../../../core/widgets/language_selector.dart';
|
|||||||
import '../../../core/services/web_auth_integration.dart';
|
import '../../../core/services/web_auth_integration.dart';
|
||||||
import '../../../core/services/auth_proxy_service.dart';
|
import '../../../core/services/auth_proxy_service.dart';
|
||||||
import '../../../core/services/auth_token_store.dart';
|
import '../../../core/services/auth_token_store.dart';
|
||||||
|
import '../../../core/i18n/locale_utils.dart';
|
||||||
import '../../../core/services/oidc_redirect_guard.dart';
|
import '../../../core/services/oidc_redirect_guard.dart';
|
||||||
import '../../../core/notifiers/auth_notifier.dart';
|
import '../../../core/notifiers/auth_notifier.dart';
|
||||||
import '../domain/login_challenge_resolver.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 '../../profile/domain/notifiers/profile_notifier.dart';
|
||||||
import '../../../core/services/web_window.dart';
|
import '../../../core/services/web_window.dart';
|
||||||
|
|
||||||
@@ -65,6 +68,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
bool _verificationOnly = false;
|
bool _verificationOnly = false;
|
||||||
bool _verificationApproved = false;
|
bool _verificationApproved = false;
|
||||||
bool _dismissedOverlays = false;
|
bool _dismissedOverlays = false;
|
||||||
|
bool _localNavigationCompleted = false;
|
||||||
String _verificationMessage = '';
|
String _verificationMessage = '';
|
||||||
String _verificationTitle = tr('ui.userfront.login.verification.title');
|
String _verificationTitle = tr('ui.userfront.login.verification.title');
|
||||||
String _verificationPageTitle = tr(
|
String _verificationPageTitle = tr(
|
||||||
@@ -108,8 +112,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
final loginIdParam = uri.queryParameters['loginId'];
|
final loginIdParam = uri.queryParameters['loginId'];
|
||||||
final codeParam = uri.queryParameters['code'];
|
final codeParam = uri.queryParameters['code'];
|
||||||
final pendingRefParam = uri.queryParameters['pendingRef'];
|
final pendingRefParam = uri.queryParameters['pendingRef'];
|
||||||
final hasShortCodePath =
|
final shortCodeFromPath = extractLoginShortCode(uri);
|
||||||
uri.pathSegments.length >= 2 && uri.pathSegments.first == 'l';
|
final hasShortCodePath = shortCodeFromPath != null;
|
||||||
final hasTokenParam = uri.queryParameters.containsKey('t');
|
final hasTokenParam = uri.queryParameters.containsKey('t');
|
||||||
final hasVerificationToken =
|
final hasVerificationToken =
|
||||||
widget.verificationToken != null || hasTokenParam;
|
widget.verificationToken != null || hasTokenParam;
|
||||||
@@ -119,13 +123,16 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
final notice = uri.queryParameters['notice'];
|
final notice = uri.queryParameters['notice'];
|
||||||
|
|
||||||
if (hasShortCodePath) {
|
if (hasShortCodePath) {
|
||||||
final shortCode = uri.pathSegments[1];
|
_verifyShortCode(shortCodeFromPath);
|
||||||
_verifyShortCode(shortCode);
|
|
||||||
}
|
}
|
||||||
if (hasLoginCode) {
|
if (hasLoginCode) {
|
||||||
_verifyLoginCode(loginIdParam, codeParam, pendingRef: pendingRefParam);
|
_verifyLoginCode(loginIdParam, codeParam, pendingRef: pendingRefParam);
|
||||||
} else if (hasVerificationToken) {
|
} 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') {
|
if (!_noticeHandled && notice == 'qr_login_required') {
|
||||||
@@ -142,8 +149,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _tryCookieSession({bool silent = true}) async {
|
Future<void> _tryCookieSession({bool silent = true}) async {
|
||||||
if (AuthTokenStore.getToken() != null &&
|
final loginChallenge = _loginChallenge;
|
||||||
(_loginChallenge == null || _loginChallenge!.isEmpty)) {
|
final token = AuthTokenStore.getToken();
|
||||||
|
if (!shouldPromoteCookieSession(
|
||||||
|
currentToken: token,
|
||||||
|
loginChallenge: loginChallenge,
|
||||||
|
)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final pendingProvider = AuthTokenStore.getPendingProvider();
|
final pendingProvider = AuthTokenStore.getPendingProvider();
|
||||||
@@ -151,6 +162,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await AuthProxyService.checkCookieSession();
|
await AuthProxyService.checkCookieSession();
|
||||||
|
if (!shouldPromoteCookieSession(
|
||||||
|
currentToken: AuthTokenStore.getToken(),
|
||||||
|
loginChallenge: loginChallenge,
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
AuthTokenStore.setCookieMode(provider: provider);
|
AuthTokenStore.setCookieMode(provider: provider);
|
||||||
AuthTokenStore.clearPendingProvider();
|
AuthTokenStore.clearPendingProvider();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -171,7 +188,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
|
|
||||||
Future<void> _onCookieLoginSuccess(String provider) async {
|
Future<void> _onCookieLoginSuccess(String provider) async {
|
||||||
debugPrint("[Auth] Cookie-based login success. Provider: $provider");
|
debugPrint("[Auth] Cookie-based login success. Provider: $provider");
|
||||||
AuthNotifier.instance.notify();
|
|
||||||
if (_hasLoginChallenge) {
|
if (_hasLoginChallenge) {
|
||||||
final accepted = await _acceptOidcLoginAndRedirect();
|
final accepted = await _acceptOidcLoginAndRedirect();
|
||||||
if (accepted) {
|
if (accepted) {
|
||||||
@@ -185,8 +201,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
|
|
||||||
final token = AuthTokenStore.getToken();
|
final token = AuthTokenStore.getToken();
|
||||||
if (token != null && token.isNotEmpty) {
|
if (token != null && token.isNotEmpty) {
|
||||||
|
final redirectUrl = _redirectUrl;
|
||||||
if (WebAuthIntegration.isPopup() ||
|
if (WebAuthIntegration.isPopup() ||
|
||||||
(_redirectUrl != null && _redirectUrl!.isNotEmpty)) {
|
(redirectUrl != null && redirectUrl.isNotEmpty)) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
"[Auth] Cookie session with external integration. Notifying...",
|
"[Auth] Cookie session with external integration. Notifying...",
|
||||||
);
|
);
|
||||||
@@ -196,14 +213,23 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
context.go('/');
|
_goLocalizedHomeOnce();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _goLocalizedHomeOnce() {
|
||||||
|
if (!mounted || _localNavigationCompleted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_localNavigationCompleted = true;
|
||||||
|
context.go(buildLocalizedHomePath(Uri.base));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _attemptOidcAutoAccept() async {
|
Future<void> _attemptOidcAutoAccept() async {
|
||||||
if (_oidcAutoAcceptTried) return;
|
if (_oidcAutoAcceptTried) return;
|
||||||
_oidcAutoAcceptTried = true;
|
_oidcAutoAcceptTried = true;
|
||||||
if (_loginChallenge == null || _loginChallenge!.isEmpty) {
|
final loginChallenge = _loginChallenge;
|
||||||
|
if (loginChallenge == null || loginChallenge.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,12 +253,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _acceptOidcLoginAndRedirect({String? token}) async {
|
Future<bool> _acceptOidcLoginAndRedirect({String? token}) async {
|
||||||
if (_loginChallenge == null || _loginChallenge!.isEmpty) {
|
final loginChallenge = _loginChallenge;
|
||||||
|
if (loginChallenge == null || loginChallenge.isEmpty) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final res = await AuthProxyService.acceptOidcLogin(
|
final res = await AuthProxyService.acceptOidcLogin(
|
||||||
_loginChallenge!,
|
loginChallenge,
|
||||||
token: token,
|
token: token,
|
||||||
);
|
);
|
||||||
final redirectTo = res['redirectTo'] as String?;
|
final redirectTo = res['redirectTo'] as String?;
|
||||||
@@ -274,8 +301,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get _hasLoginChallenge =>
|
bool get _hasLoginChallenge {
|
||||||
_loginChallenge != null && _loginChallenge!.isNotEmpty;
|
final loginChallenge = _loginChallenge;
|
||||||
|
return loginChallenge != null && loginChallenge.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
LoginChallengeResolution _resolveLoginChallenge(Uri uri) {
|
LoginChallengeResolution _resolveLoginChallenge(Uri uri) {
|
||||||
return resolveLoginChallenge(
|
return resolveLoginChallenge(
|
||||||
@@ -486,7 +515,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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') {
|
if (res['error'] == 'slow_down') {
|
||||||
final interval = res['interval'];
|
final interval = res['interval'];
|
||||||
if (interval is int && interval > 0) {
|
if (interval is int && interval > 0) {
|
||||||
@@ -656,9 +689,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final hasLocalSession =
|
final hasLocalSession =
|
||||||
AuthTokenStore.getToken() != null ||
|
(AuthTokenStore.getToken()?.isNotEmpty ?? false) ||
|
||||||
AuthTokenStore.usesCookie();
|
AuthTokenStore.usesCookie();
|
||||||
final target = hasLocalSession ? '/' : '/signin';
|
final target = hasLocalSession
|
||||||
|
? buildLocalizedHomePath(Uri.base)
|
||||||
|
: buildLocalizedSigninPath(Uri.base);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_verificationOnly = false;
|
_verificationOnly = false;
|
||||||
@@ -691,7 +726,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
final jwt = res['token'] ?? res['sessionJwt'] ?? res['sessionToken'];
|
final jwt = res['token'] ?? res['sessionJwt'] ?? res['sessionToken'];
|
||||||
final status = res['status']?.toString();
|
final status = res['status']?.toString();
|
||||||
final hasLocalSession = await _hasValidLocalSession();
|
final hasLocalSession = await _hasValidLocalSession();
|
||||||
final actionPath = hasLocalSession ? '/' : '/signin';
|
final actionPath = hasLocalSession
|
||||||
|
? buildLocalizedHomePath(Uri.base)
|
||||||
|
: buildLocalizedSigninPath(Uri.base);
|
||||||
|
|
||||||
if (status == 'approved' || (jwt == null && _verificationOnly)) {
|
if (status == 'approved' || (jwt == null && _verificationOnly)) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -754,7 +791,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
"[Auth] Code verification successful for loginId: $sanitizedLoginId",
|
"[Auth] Code verification successful for loginId: $sanitizedLoginId",
|
||||||
);
|
);
|
||||||
final hasLocalSession = await _hasValidLocalSession();
|
final hasLocalSession = await _hasValidLocalSession();
|
||||||
final actionPath = hasLocalSession ? '/' : '/signin';
|
final actionPath = hasLocalSession
|
||||||
|
? buildLocalizedHomePath(Uri.base)
|
||||||
|
: buildLocalizedSigninPath(Uri.base);
|
||||||
|
|
||||||
if (jwt == null && status == 'approved') {
|
if (jwt == null && status == 'approved') {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -814,7 +853,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
final status = res['status']?.toString();
|
final status = res['status']?.toString();
|
||||||
debugPrint("[Auth] Short code verification successful");
|
debugPrint("[Auth] Short code verification successful");
|
||||||
final hasLocalSession = await _hasValidLocalSession();
|
final hasLocalSession = await _hasValidLocalSession();
|
||||||
final actionPath = hasLocalSession ? '/' : '/signin';
|
final actionPath = hasLocalSession
|
||||||
|
? buildLocalizedHomePath(Uri.base)
|
||||||
|
: buildLocalizedSigninPath(Uri.base);
|
||||||
|
|
||||||
if (jwt == null && status == 'approved') {
|
if (jwt == null && status == 'approved') {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -1147,14 +1188,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// [Priority 2] OIDC Challenge Handling
|
// [Priority 2] OIDC Challenge Handling
|
||||||
if (_loginChallenge != null && _loginChallenge!.isNotEmpty) {
|
final loginChallenge = _loginChallenge;
|
||||||
|
if (loginChallenge != null && loginChallenge.isNotEmpty) {
|
||||||
try {
|
try {
|
||||||
// Save token first, it's needed for acceptance
|
// Save token first, it's needed for acceptance
|
||||||
final providerName = provider ?? AuthTokenStore.getProvider();
|
final providerName = provider ?? AuthTokenStore.getProvider();
|
||||||
AuthTokenStore.setToken(token, provider: providerName);
|
AuthTokenStore.setToken(token, provider: providerName);
|
||||||
|
|
||||||
final res = await AuthProxyService.acceptOidcLogin(
|
final res = await AuthProxyService.acceptOidcLogin(
|
||||||
_loginChallenge!,
|
loginChallenge,
|
||||||
token: token,
|
token: token,
|
||||||
);
|
);
|
||||||
final nextRedirectTo = res['redirectTo'] as String?;
|
final nextRedirectTo = res['redirectTo'] as String?;
|
||||||
@@ -1196,9 +1238,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
AuthNotifier.instance.notify();
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
context.go('/');
|
_goLocalizedHomeOnce();
|
||||||
}
|
}
|
||||||
} catch (globalErr) {
|
} catch (globalErr) {
|
||||||
// ignore
|
// ignore
|
||||||
@@ -1237,7 +1278,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
title: Text(_verificationPageTitle),
|
title: Text(_verificationPageTitle),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
onPressed: () => context.go('/'),
|
onPressed: () => context.go(buildLocalizedHomePath(Uri.base)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: _buildVerificationResultView(),
|
body: _buildVerificationResultView(),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:userfront/core/i18n/locale_utils.dart';
|
||||||
import 'package:userfront/i18n.dart';
|
import 'package:userfront/i18n.dart';
|
||||||
|
|
||||||
class LoginSuccessScreen extends StatelessWidget {
|
class LoginSuccessScreen extends StatelessWidget {
|
||||||
@@ -54,7 +55,7 @@ class LoginSuccessScreen extends StatelessWidget {
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.go('/');
|
context.go(buildLocalizedHomePath(Uri.base));
|
||||||
},
|
},
|
||||||
child: Text(
|
child: Text(
|
||||||
tr('ui.userfront.login_success.later'),
|
tr('ui.userfront.login_success.later'),
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
enum QrCameraBootstrapStatus {
|
||||||
|
ready,
|
||||||
|
detectorUnsupported,
|
||||||
|
permissionError,
|
||||||
|
cameraError,
|
||||||
|
}
|
||||||
|
|
||||||
|
class QrCameraBootstrapResult {
|
||||||
|
const QrCameraBootstrapResult(this.status, {this.errorDetail = ''});
|
||||||
|
|
||||||
|
final QrCameraBootstrapStatus status;
|
||||||
|
final String errorDetail;
|
||||||
|
|
||||||
|
bool get isReady => status == QrCameraBootstrapStatus.ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef QrOpenCameraAndPlay = Future<void> Function();
|
||||||
|
typedef QrStopCamera = Future<void> Function();
|
||||||
|
|
||||||
|
bool isQrPermissionError(Object error) {
|
||||||
|
final raw = error.toString();
|
||||||
|
return raw.contains('NotAllowedError') ||
|
||||||
|
raw.contains('PermissionDeniedError') ||
|
||||||
|
raw.contains('SecurityError');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<QrCameraBootstrapResult> bootstrapQrCamera({
|
||||||
|
required bool hasBarcodeDetector,
|
||||||
|
required QrOpenCameraAndPlay openCameraAndPlay,
|
||||||
|
required QrStopCamera stopCamera,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await openCameraAndPlay();
|
||||||
|
if (!hasBarcodeDetector) {
|
||||||
|
await stopCamera();
|
||||||
|
return const QrCameraBootstrapResult(
|
||||||
|
QrCameraBootstrapStatus.detectorUnsupported,
|
||||||
|
errorDetail: 'BarcodeDetector is not supported in this browser.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const QrCameraBootstrapResult(QrCameraBootstrapStatus.ready);
|
||||||
|
} catch (e) {
|
||||||
|
if (isQrPermissionError(e)) {
|
||||||
|
return QrCameraBootstrapResult(
|
||||||
|
QrCameraBootstrapStatus.permissionError,
|
||||||
|
errorDetail: e.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return QrCameraBootstrapResult(
|
||||||
|
QrCameraBootstrapStatus.cameraError,
|
||||||
|
errorDetail: e.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
userfront/lib/features/auth/presentation/qr_scan_route.dart
Normal 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)}';
|
||||||
|
}
|
||||||
@@ -1,30 +1,2 @@
|
|||||||
import 'package:flutter/material.dart';
|
export 'qr_scan_screen_stub.dart'
|
||||||
import 'package:go_router/go_router.dart';
|
if (dart.library.js_interop) 'qr_scan_screen_web.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.',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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: '승인 화면으로 이동'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
238
userfront/lib/features/auth/presentation/qr_scan_screen_web.dart
Normal 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: '승인 화면으로 이동'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/i18n/locale_utils.dart';
|
||||||
import '../../../core/services/auth_proxy_service.dart';
|
import '../../../core/services/auth_proxy_service.dart';
|
||||||
import 'package:userfront/i18n.dart';
|
import 'package:userfront/i18n.dart';
|
||||||
|
|
||||||
@@ -89,7 +90,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
context.go('/signin');
|
context.go(buildLocalizedSigninPath(Uri.base));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:userfront/i18n.dart';
|
import 'package:userfront/i18n.dart';
|
||||||
|
import '../../../core/i18n/locale_utils.dart';
|
||||||
import '../../../core/services/auth_proxy_service.dart';
|
import '../../../core/services/auth_proxy_service.dart';
|
||||||
|
|
||||||
class SignupScreen extends StatefulWidget {
|
class SignupScreen extends StatefulWidget {
|
||||||
@@ -345,7 +346,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
content: Text(tr('msg.userfront.signup.success.body')),
|
content: Text(tr('msg.userfront.signup.success.body')),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => context.go('/signin'),
|
onPressed: () => context.go(buildLocalizedSigninPath(Uri.base)),
|
||||||
child: Text(tr('ui.userfront.signup.success.action')),
|
child: Text(tr('ui.userfront.signup.success.action')),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -133,7 +133,8 @@ class AuthTimelineNotifier extends Notifier<AuthTimelineState> {
|
|||||||
if (state.isLoading || state.isLoadingMore) {
|
if (state.isLoading || state.isLoadingMore) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (state.nextCursor == null || state.nextCursor!.isEmpty) {
|
final nextCursor = state.nextCursor;
|
||||||
|
if (nextCursor == null || nextCursor.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state = state.copyWith(isLoadingMore: true, error: null);
|
state = state.copyWith(isLoadingMore: true, error: null);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@@ -7,6 +8,7 @@ import 'package:url_launcher/url_launcher.dart';
|
|||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
import '../domain/providers/linked_rps_provider.dart';
|
import '../domain/providers/linked_rps_provider.dart';
|
||||||
import '../../../../core/notifiers/auth_notifier.dart';
|
import '../../../../core/notifiers/auth_notifier.dart';
|
||||||
|
import '../../../../core/services/auth_proxy_service.dart';
|
||||||
import '../../../../core/services/auth_token_store.dart';
|
import '../../../../core/services/auth_token_store.dart';
|
||||||
import '../../../../core/services/http_client.dart';
|
import '../../../../core/services/http_client.dart';
|
||||||
import '../../../../core/i18n/locale_utils.dart';
|
import '../../../../core/i18n/locale_utils.dart';
|
||||||
@@ -38,6 +40,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
bool _auditLoadingMore = false;
|
bool _auditLoadingMore = false;
|
||||||
bool _isRevoking = false;
|
bool _isRevoking = false;
|
||||||
bool _redirectingToSignin = false;
|
bool _redirectingToSignin = false;
|
||||||
|
bool _authBootstrapInProgress = false;
|
||||||
|
|
||||||
bool _showAllActivities = false;
|
bool _showAllActivities = false;
|
||||||
final Set<String> _revokedClientIds = {};
|
final Set<String> _revokedClientIds = {};
|
||||||
@@ -47,11 +50,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
_pageScrollController.addListener(_onPageScroll);
|
_pageScrollController.addListener(_onPageScroll);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!_isLoggedIn()) {
|
if (!mounted) {
|
||||||
_redirectToSignin();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_loadAuditLogs(reset: true);
|
unawaited(_bootstrapAuthAndLoad());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +256,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
if (closeOnTap) {
|
if (closeOnTap) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
}
|
}
|
||||||
context.go('/');
|
context.go(buildLocalizedHomePath(Uri.base));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
@@ -302,8 +304,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
|
|
||||||
Future<void> _refreshAll() async {
|
Future<void> _refreshAll() async {
|
||||||
if (!_isLoggedIn()) {
|
if (!_isLoggedIn()) {
|
||||||
_redirectToSignin();
|
final recovered = await _recoverSessionFromCookie();
|
||||||
return;
|
if (!recovered) {
|
||||||
|
_redirectToSignin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await ref.read(profileProvider.notifier).loadProfile();
|
await ref.read(profileProvider.notifier).loadProfile();
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -372,7 +377,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
if (_auditLoading || _auditLoadingMore) {
|
if (_auditLoading || _auditLoadingMore) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!reset && (_auditNextCursor == null || _auditNextCursor!.isEmpty)) {
|
final nextCursor = _auditNextCursor;
|
||||||
|
if (!reset && (nextCursor == null || nextCursor.isEmpty)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,109 +712,133 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (!_isLoggedIn()) {
|
try {
|
||||||
_redirectToSignin();
|
if (!_isLoggedIn()) {
|
||||||
return const SizedBox.shrink();
|
_redirectToSignin();
|
||||||
}
|
return const SizedBox.shrink();
|
||||||
final isWide = MediaQuery.of(context).size.width >= sideMenuBreakpoint;
|
}
|
||||||
final profileState = ref.watch(profileProvider);
|
final isWide = MediaQuery.of(context).size.width >= sideMenuBreakpoint;
|
||||||
final profile = profileState.value;
|
final profileState = ref.watch(profileProvider);
|
||||||
final timelineState = ref.watch(authTimelineProvider);
|
final profile = profileState.value;
|
||||||
final userName =
|
final timelineState = ref.watch(authTimelineProvider);
|
||||||
profile?.name ??
|
final userName =
|
||||||
profile?.email ??
|
profile?.name ??
|
||||||
profile?.phone ??
|
profile?.email ??
|
||||||
tr('ui.userfront.profile.user_fallback', fallback: 'User');
|
profile?.phone ??
|
||||||
final department = profile?.department.isNotEmpty == true
|
tr('ui.userfront.profile.user_fallback', fallback: 'User');
|
||||||
? profile!.department
|
final departmentValue = profile?.department ?? '';
|
||||||
: tr('ui.userfront.profile.department_empty');
|
final department = departmentValue.isNotEmpty
|
||||||
final sessionIssuedAt = _getJwtIssuedAt();
|
? departmentValue
|
||||||
|
: tr('ui.userfront.profile.department_empty');
|
||||||
|
final sessionIssuedAt = _getJwtIssuedAt();
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: _subtle,
|
backgroundColor: _subtle,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(
|
title: Text(
|
||||||
tr('ui.userfront.app_title'),
|
tr('ui.userfront.app_title'),
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
style: const 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'),
|
|
||||||
),
|
),
|
||||||
IconButton(
|
elevation: 0,
|
||||||
icon: const Icon(Icons.qr_code_scanner),
|
backgroundColor: _surface,
|
||||||
tooltip: tr('ui.userfront.nav.qr_scan'),
|
foregroundColor: Colors.black,
|
||||||
onPressed: _onScanQR,
|
actions: [
|
||||||
),
|
IconButton(
|
||||||
IconButton(
|
icon: const Icon(Icons.person_outline),
|
||||||
icon: const Icon(Icons.logout),
|
tooltip: tr('ui.userfront.nav.profile'),
|
||||||
tooltip: tr('ui.userfront.nav.logout'),
|
onPressed: () => context.push('/profile'),
|
||||||
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(
|
IconButton(
|
||||||
child: RefreshIndicator(
|
icon: const Icon(Icons.qr_code_scanner),
|
||||||
onRefresh: _refreshAll,
|
tooltip: tr('ui.userfront.nav.qr_scan'),
|
||||||
child: LayoutBuilder(
|
onPressed: _onScanQR,
|
||||||
builder: (context, constraints) {
|
),
|
||||||
final timelineWide = constraints.maxWidth >= 900;
|
IconButton(
|
||||||
final isMobile = constraints.maxWidth < 600;
|
icon: const Icon(Icons.logout),
|
||||||
return SingleChildScrollView(
|
tooltip: tr('ui.userfront.nav.logout'),
|
||||||
controller: _pageScrollController,
|
onPressed: _logout,
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
),
|
||||||
child: Padding(
|
],
|
||||||
padding: const EdgeInsets.all(24),
|
),
|
||||||
child: Column(
|
drawer: isWide
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
? null
|
||||||
children: [
|
: Drawer(child: _buildSideMenu(context, closeOnTap: true)),
|
||||||
if (!isMobile) ...[
|
body: Row(
|
||||||
_buildHeaderCard(
|
children: [
|
||||||
userName,
|
if (isWide)
|
||||||
department,
|
SizedBox(
|
||||||
sessionIssuedAt,
|
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),
|
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(
|
Widget _buildHeaderCard(
|
||||||
@@ -973,8 +1003,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
normalizedStatus == 'active' || normalizedStatus == '';
|
normalizedStatus == 'active' || normalizedStatus == '';
|
||||||
final isRevoked = !isActiveInApi;
|
final isRevoked = !isActiveInApi;
|
||||||
|
|
||||||
final lastAuthLabel = rp.lastAuthenticatedAt != null
|
final lastAuthAt = rp.lastAuthenticatedAt;
|
||||||
? _formatDateTime(rp.lastAuthenticatedAt!)
|
final lastAuthLabel = lastAuthAt != null
|
||||||
|
? _formatDateTime(lastAuthAt)
|
||||||
: tr('ui.userfront.dashboard.activity.linked');
|
: tr('ui.userfront.dashboard.activity.linked');
|
||||||
|
|
||||||
final statusCode = isRevoked ? 'revoked' : 'active';
|
final statusCode = isRevoked ? 'revoked' : 'active';
|
||||||
@@ -1004,8 +1035,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
if (!aActive && bActive) return 1;
|
if (!aActive && bActive) return 1;
|
||||||
|
|
||||||
// 둘 다 활성이거나 둘 다 비활성인 경우 최근 인증순 내림차순
|
// 둘 다 활성이거나 둘 다 비활성인 경우 최근 인증순 내림차순
|
||||||
if (a.lastAuthDateTime != null && b.lastAuthDateTime != null) {
|
final aLastAuth = a.lastAuthDateTime;
|
||||||
return b.lastAuthDateTime!.compareTo(a.lastAuthDateTime!);
|
final bLastAuth = b.lastAuthDateTime;
|
||||||
|
if (aLastAuth != null && bLastAuth != null) {
|
||||||
|
return bLastAuth.compareTo(aLastAuth);
|
||||||
}
|
}
|
||||||
if (a.lastAuthDateTime != null) return -1;
|
if (a.lastAuthDateTime != null) return -1;
|
||||||
if (b.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 =
|
final double cardWidth =
|
||||||
(maxWidth - (spacing * (crossAxisCount - 1))) / crossAxisCount;
|
(maxWidth - (spacing * (crossAxisCount - 1))) / crossAxisCount;
|
||||||
|
|
||||||
@@ -1145,16 +1178,16 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: statusColor.withValues(alpha: 31),
|
color: statusColor,
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
item.status == 'active'
|
item.status == 'active'
|
||||||
? tr('ui.common.status.active')
|
? tr('ui.userfront.dashboard.activity.linked')
|
||||||
: tr('ui.userfront.dashboard.status.revoked'),
|
: tr('ui.userfront.dashboard.status.revoked'),
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: statusColor,
|
color: Colors.white,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1244,8 +1277,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
if (item.url != null && item.url!.isNotEmpty) {
|
final itemUrl = item.url;
|
||||||
final uri = Uri.parse(item.url!);
|
if (itemUrl != null && itemUrl.isNotEmpty) {
|
||||||
|
final uri = Uri.parse(itemUrl);
|
||||||
final canOpen = await canLaunchUrl(uri);
|
final canOpen = await canLaunchUrl(uri);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (canOpen) {
|
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(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(top: 8),
|
padding: const EdgeInsets.only(top: 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -1581,7 +1616,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool _isLoggedIn() {
|
bool _isLoggedIn() {
|
||||||
return AuthTokenStore.getToken() != null || AuthTokenStore.usesCookie();
|
final token = AuthTokenStore.getToken();
|
||||||
|
return (token != null && token.isNotEmpty) || AuthTokenStore.usesCookie();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _redirectToSignin() {
|
void _redirectToSignin() {
|
||||||
@@ -1593,13 +1629,60 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final uri = GoRouterState.of(context).uri;
|
Uri uri;
|
||||||
|
try {
|
||||||
|
uri = GoRouterState.of(context).uri;
|
||||||
|
} catch (_) {
|
||||||
|
uri = Uri.base;
|
||||||
|
}
|
||||||
final localeCode =
|
final localeCode =
|
||||||
extractLocaleFromPath(uri) ?? resolvePreferredLocaleCode();
|
extractLocaleFromPath(uri) ?? resolvePreferredLocaleCode();
|
||||||
context.go('/$localeCode/signin');
|
context.go('/$localeCode/signin');
|
||||||
_redirectingToSignin = false;
|
_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 {
|
class _ActivityItem {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:userfront/i18n.dart';
|
import 'package:userfront/i18n.dart';
|
||||||
import '../../../../core/notifiers/auth_notifier.dart';
|
import '../../../../core/notifiers/auth_notifier.dart';
|
||||||
|
import '../../../../core/i18n/locale_utils.dart';
|
||||||
import '../../../../core/services/auth_token_store.dart';
|
import '../../../../core/services/auth_token_store.dart';
|
||||||
import '../../../../core/ui/layout_breakpoints.dart';
|
import '../../../../core/ui/layout_breakpoints.dart';
|
||||||
import '../../../../core/widgets/language_selector.dart';
|
import '../../../../core/widgets/language_selector.dart';
|
||||||
@@ -509,7 +510,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.home_outlined),
|
leading: const Icon(Icons.home_outlined),
|
||||||
title: Text(tr('ui.userfront.nav.dashboard')),
|
title: Text(tr('ui.userfront.nav.dashboard')),
|
||||||
onTap: () => context.go('/'),
|
onTap: () => context.go(buildLocalizedHomePath(Uri.base)),
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.person_outline),
|
leading: const Icon(Icons.person_outline),
|
||||||
@@ -1092,7 +1093,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.home_outlined),
|
icon: const Icon(Icons.home_outlined),
|
||||||
tooltip: tr('ui.userfront.nav.dashboard'),
|
tooltip: tr('ui.userfront.nav.dashboard'),
|
||||||
onPressed: () => context.go('/'),
|
onPressed: () => context.go(buildLocalizedHomePath(Uri.base)),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.qr_code_scanner),
|
icon: const Icon(Icons.qr_code_scanner),
|
||||||
|
|||||||
@@ -14,12 +14,15 @@ import 'features/auth/presentation/qr_scan_screen.dart';
|
|||||||
import 'features/auth/presentation/forgot_password_screen.dart';
|
import 'features/auth/presentation/forgot_password_screen.dart';
|
||||||
import 'features/auth/presentation/reset_password_screen.dart';
|
import 'features/auth/presentation/reset_password_screen.dart';
|
||||||
import 'features/auth/presentation/error_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/dashboard/presentation/dashboard_screen.dart';
|
||||||
import 'features/admin/presentation/user_management_screen.dart';
|
import 'features/admin/presentation/user_management_screen.dart';
|
||||||
import 'features/profile/presentation/pages/profile_page.dart';
|
import 'features/profile/presentation/pages/profile_page.dart';
|
||||||
import 'core/services/auth_proxy_service.dart';
|
import 'core/services/auth_proxy_service.dart';
|
||||||
import 'core/services/auth_token_store.dart';
|
import 'core/services/auth_token_store.dart';
|
||||||
import 'core/services/logger_service.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/notifiers/auth_notifier.dart';
|
||||||
import 'core/i18n/locale_gate.dart';
|
import 'core/i18n/locale_gate.dart';
|
||||||
import 'core/i18n/locale_registry.dart';
|
import 'core/i18n/locale_registry.dart';
|
||||||
@@ -31,6 +34,29 @@ import 'i18n.dart';
|
|||||||
|
|
||||||
final _log = Logger('Main');
|
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 {
|
Future<void> _loadBundledFonts() async {
|
||||||
const family = 'NotoSansKR';
|
const family = 'NotoSansKR';
|
||||||
final loader = FontLoader(family);
|
final loader = FontLoader(family);
|
||||||
@@ -57,11 +83,16 @@ void main() async {
|
|||||||
AuthProxyService.logError(
|
AuthProxyService.logError(
|
||||||
"FLUTTER_ERROR: ${details.exception}\n${details.stack}",
|
"FLUTTER_ERROR: ${details.exception}\n${details.stack}",
|
||||||
);
|
);
|
||||||
|
_attemptRecoveryFromNullCheck(
|
||||||
|
exception: details.exception,
|
||||||
|
stackTrace: details.stack,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
PlatformDispatcher.instance.onError = (error, stack) {
|
PlatformDispatcher.instance.onError = (error, stack) {
|
||||||
_log.severe("PLATFORM_ERROR", error, stack);
|
_log.severe("PLATFORM_ERROR", error, stack);
|
||||||
AuthProxyService.logError("PLATFORM_ERROR: $error\n$stack");
|
AuthProxyService.logError("PLATFORM_ERROR: $error\n$stack");
|
||||||
|
_attemptRecoveryFromNullCheck(exception: error, stackTrace: stack);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -107,6 +138,15 @@ final _router = GoRouter(
|
|||||||
debugLogDiagnostics: !kReleaseMode,
|
debugLogDiagnostics: !kReleaseMode,
|
||||||
refreshListenable: AuthNotifier.instance,
|
refreshListenable: AuthNotifier.instance,
|
||||||
routes: [
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/',
|
||||||
|
redirect: (context, state) {
|
||||||
|
return buildLocalizedHomePath(
|
||||||
|
state.uri,
|
||||||
|
preferredLocaleCode: resolvePreferredLocaleCode(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
ShellRoute(
|
ShellRoute(
|
||||||
builder: (context, state, child) {
|
builder: (context, state, child) {
|
||||||
final localeCode =
|
final localeCode =
|
||||||
@@ -116,10 +156,25 @@ final _router = GoRouter(
|
|||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/:locale',
|
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: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '', // Matches /:locale
|
path: 'dashboard',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
return const DashboardScreen();
|
return const DashboardScreen();
|
||||||
},
|
},
|
||||||
@@ -274,34 +329,23 @@ final _router = GoRouter(
|
|||||||
(token != null && token.isNotEmpty) || AuthTokenStore.usesCookie();
|
(token != null && token.isNotEmpty) || AuthTokenStore.usesCookie();
|
||||||
final path = stripLocalePath(uri);
|
final path = stripLocalePath(uri);
|
||||||
|
|
||||||
// Precise public path detection
|
final isPublicPath = isPublicAuthPath(path, uri);
|
||||||
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');
|
|
||||||
|
|
||||||
if (isPublicPath) {
|
if (isPublicPath) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
|
if (path == '/') {
|
||||||
|
return '/$requestedLocale/signin';
|
||||||
|
}
|
||||||
return buildSigninRedirectPath(requestedLocale, uri);
|
return buildSigninRedirectPath(requestedLocale, uri);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path == '/') {
|
||||||
|
return '/$requestedLocale/dashboard';
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -311,11 +355,21 @@ class BaronSSOApp extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return MaterialApp.router(
|
||||||
title: tr('ui.userfront.app_title'),
|
title: tr('ui.userfront.app_title'),
|
||||||
localizationsDelegates: context.localizationDelegates,
|
localizationsDelegates: delegates,
|
||||||
supportedLocales: context.supportedLocales,
|
supportedLocales: supportedLocales,
|
||||||
locale: context.locale,
|
locale: locale,
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(
|
colorScheme: ColorScheme.fromSeed(
|
||||||
seedColor: const Color(0xFF1A1F2C), // Dark Navy/Black base
|
seedColor: const Color(0xFF1A1F2C), // Dark Navy/Black base
|
||||||
|
|||||||
@@ -45,10 +45,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.0"
|
||||||
cli_config:
|
cli_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -268,6 +268,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.5"
|
version: "1.0.5"
|
||||||
|
js:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: js
|
||||||
|
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.2"
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -320,18 +328,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.18"
|
version: "0.12.17"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.13.0"
|
version: "0.11.1"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -348,6 +356,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
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:
|
node_preamble:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -637,26 +653,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test
|
name: test
|
||||||
sha256: "54c516bbb7cee2754d327ad4fca637f78abfc3cbcc5ace83b3eda117e42cd71a"
|
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.29.0"
|
version: "1.26.3"
|
||||||
test_api:
|
test_api:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.9"
|
version: "0.7.7"
|
||||||
test_core:
|
test_core:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_core
|
name: test_core
|
||||||
sha256: "394f07d21f0f2255ec9e3989f21e54d3c7dc0e6e9dbce160e5a9c1a6be0e2943"
|
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.15"
|
version: "0.6.12"
|
||||||
toml:
|
toml:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ dependencies:
|
|||||||
logging: ^1.2.0
|
logging: ^1.2.0
|
||||||
logger: ^2.0.0
|
logger: ^2.0.0
|
||||||
qr_flutter: ^4.1.0
|
qr_flutter: ^4.1.0
|
||||||
|
mobile_scanner: ^7.1.4
|
||||||
easy_localization: ^3.0.7
|
easy_localization: ^3.0.7
|
||||||
toml: ^0.15.0
|
toml: ^0.15.0
|
||||||
web: ^1.1.0
|
web: ^1.1.0
|
||||||
|
|||||||
40
userfront/test/cookie_session_policy_test.dart
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
50
userfront/test/dashboard_screen_smoke_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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',
|
'/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',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
33
userfront/test/login_link_route_policy_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
94
userfront/test/login_navigation_race_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
63
userfront/test/null_check_recovery_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
67
userfront/test/qr_camera_bootstrap_policy_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
27
userfront/test/qr_scan_route_test.dart
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:userfront/features/auth/presentation/qr_scan_route.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('buildQrApprovePath', () {
|
||||||
|
test('스캔 값을 trim/encode 해서 approve 경로를 만든다', () {
|
||||||
|
final result = buildQrApprovePath(
|
||||||
|
' https://sss.hmac.kr/ql/abc-123?x=1&y=2 ',
|
||||||
|
localeCode: 'ko',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
result,
|
||||||
|
'/ko/approve?ref=https%3A%2F%2Fsss.hmac.kr%2Fql%2Fabc-123%3Fx%3D1%26y%3D2',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('현재 URI에서 locale을 추출한다', () {
|
||||||
|
final result = buildQrApprovePath(
|
||||||
|
'abc123',
|
||||||
|
currentUri: Uri.parse('https://sss.hmac.kr/en/dashboard'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, '/en/approve?ref=abc123');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
16
userfront/test/qr_scan_screen_test.dart
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:userfront/features/auth/presentation/qr_scan_screen.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('QR 스캔 화면은 비활성 문구 대신 입력/이동 UI를 노출한다', (tester) async {
|
||||||
|
await tester.pumpWidget(const MaterialApp(home: QRScanScreen()));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.text('QR Scanner is temporarily disabled for WASM build stability.'),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
expect(find.byKey(const ValueKey('qr_scan_manual_input')), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('qr_scan_submit_button')), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -11,8 +11,28 @@ Widget _buildTestApp(String initialLocation) {
|
|||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/:locale',
|
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: [
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: 'dashboard',
|
||||||
|
builder: (context, state) =>
|
||||||
|
const Scaffold(body: Text('dashboard-page')),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'signin',
|
path: 'signin',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
@@ -57,8 +77,9 @@ Widget _buildTestApp(String initialLocation) {
|
|||||||
return buildLocalizedPath(resolvePreferredLocaleCode(), state.uri);
|
return buildLocalizedPath(resolvePreferredLocaleCode(), state.uri);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final token = AuthTokenStore.getToken();
|
||||||
final isLoggedIn =
|
final isLoggedIn =
|
||||||
AuthTokenStore.getToken() != null || AuthTokenStore.usesCookie();
|
(token != null && token.isNotEmpty) || AuthTokenStore.usesCookie();
|
||||||
final path = stripLocalePath(state.uri);
|
final path = stripLocalePath(state.uri);
|
||||||
final isPublicPath = path == '/signin' || path == '/login';
|
final isPublicPath = path == '/signin' || path == '/login';
|
||||||
if (isPublicPath) {
|
if (isPublicPath) {
|
||||||
@@ -85,6 +106,25 @@ void main() {
|
|||||||
LocaleRegistry.resetForTest();
|
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 {
|
testWidgets('/login: login_challenge와 redirect_uri를 전달', (tester) async {
|
||||||
final encodedRedirectUri = Uri.encodeComponent(
|
final encodedRedirectUri = Uri.encodeComponent(
|
||||||
'https://rp.example.com/callback?x=1',
|
'https://rp.example.com/callback?x=1',
|
||||||
@@ -153,6 +193,15 @@ void main() {
|
|||||||
expect(find.textContaining('signin|'), findsNothing);
|
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 {
|
testWidgets('로그인 후 같은 브라우저 새 창/팝업에서도 세션이 유지된다', (tester) async {
|
||||||
await tester.pumpWidget(_buildTestApp('/en/signin'));
|
await tester.pumpWidget(_buildTestApp('/en/signin'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|||||||
BIN
userfront/web/favicon.ico
Normal file
|
After Width: | Height: | Size: 17 KiB |