forked from baron/baron-sso
Merge pull request 'feature/df-rp-settings' (#1104) from feature/df-rp-settings into dev
Reviewed-on: baron/baron-sso#1104
This commit is contained in:
16
Makefile
16
Makefile
@@ -340,7 +340,11 @@ code-check-userfront-lint:
|
|||||||
code-check-front-lint:
|
code-check-front-lint:
|
||||||
@echo "==> adminfront biome lint/format check"
|
@echo "==> adminfront biome lint/format check"
|
||||||
rm -rf adminfront/playwright-report adminfront/test-results
|
rm -rf adminfront/playwright-report adminfront/test-results
|
||||||
cd adminfront && CI=true npx pnpm install --frozen-lockfile --ignore-scripts
|
@if [ -d adminfront/node_modules ]; then \
|
||||||
|
echo "adminfront/node_modules already present; skipping pnpm install."; \
|
||||||
|
else \
|
||||||
|
cd adminfront && CI=true npx pnpm install --frozen-lockfile --ignore-scripts; \
|
||||||
|
fi
|
||||||
cd adminfront && npx biome lint .
|
cd adminfront && npx biome lint .
|
||||||
cd adminfront && npx biome format .
|
cd adminfront && npx biome format .
|
||||||
@echo "==> devfront biome lint/format check"
|
@echo "==> devfront biome lint/format check"
|
||||||
@@ -354,9 +358,13 @@ code-check-front-lint:
|
|||||||
cd devfront && npx biome format .
|
cd devfront && npx biome format .
|
||||||
@echo "==> orgfront biome lint/format check"
|
@echo "==> orgfront biome lint/format check"
|
||||||
rm -rf orgfront/playwright-report orgfront/test-results
|
rm -rf orgfront/playwright-report orgfront/test-results
|
||||||
cd orgfront && npm ci --ignore-scripts
|
@if [ -d orgfront/node_modules ]; then \
|
||||||
cd orgfront && npx biome lint .
|
echo "orgfront/node_modules already present; skipping npm install."; \
|
||||||
cd orgfront && npx biome format .
|
else \
|
||||||
|
cd orgfront && npm ci --ignore-scripts; \
|
||||||
|
fi
|
||||||
|
cd orgfront && ./node_modules/@biomejs/biome/bin/biome lint .
|
||||||
|
cd orgfront && ./node_modules/@biomejs/biome/bin/biome format .
|
||||||
|
|
||||||
code-check-backend-tests:
|
code-check-backend-tests:
|
||||||
@echo "==> backend tests"
|
@echo "==> backend tests"
|
||||||
|
|||||||
@@ -405,5 +405,4 @@ func TestSeedTenantsCreatesMissingSeedRowsAndRepairsExistingSeedSlug(t *testing.
|
|||||||
if rootCount != 1 {
|
if rootCount != 1 {
|
||||||
t.Fatalf("existing-root row count = %d, want 1", rootCount)
|
t.Fatalf("existing-root row count = %d, want 1", rootCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -486,8 +486,8 @@ func runHeadlessPasswordLoginWithAssertionRequest(
|
|||||||
})
|
})
|
||||||
|
|
||||||
h := &AuthHandler{
|
h := &AuthHandler{
|
||||||
IdpProvider: mockIdp,
|
IdpProvider: mockIdp,
|
||||||
KratosAdmin: mockKratos,
|
KratosAdmin: mockKratos,
|
||||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient),
|
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient),
|
||||||
Hydra: &service.HydraAdminService{
|
Hydra: &service.HydraAdminService{
|
||||||
AdminURL: "http://hydra.test",
|
AdminURL: "http://hydra.test",
|
||||||
@@ -585,8 +585,8 @@ func runHeadlessPasswordLoginWithAssertionAndLoggerRequest(
|
|||||||
})
|
})
|
||||||
|
|
||||||
h := &AuthHandler{
|
h := &AuthHandler{
|
||||||
IdpProvider: mockIdp,
|
IdpProvider: mockIdp,
|
||||||
KratosAdmin: mockKratos,
|
KratosAdmin: mockKratos,
|
||||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient),
|
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient),
|
||||||
Hydra: &service.HydraAdminService{
|
Hydra: &service.HydraAdminService{
|
||||||
AdminURL: "http://hydra.test",
|
AdminURL: "http://hydra.test",
|
||||||
@@ -904,8 +904,8 @@ func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) {
|
|||||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "employee001").Return("kratos-identity-id", nil)
|
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "employee001").Return("kratos-identity-id", nil)
|
||||||
|
|
||||||
h := &AuthHandler{
|
h := &AuthHandler{
|
||||||
IdpProvider: mockIdp,
|
IdpProvider: mockIdp,
|
||||||
KratosAdmin: mockKratos,
|
KratosAdmin: mockKratos,
|
||||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient),
|
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient),
|
||||||
Hydra: &service.HydraAdminService{
|
Hydra: &service.HydraAdminService{
|
||||||
AdminURL: "http://hydra.test",
|
AdminURL: "http://hydra.test",
|
||||||
@@ -1002,8 +1002,8 @@ func TestHeadlessPasswordLogin_OIDCSubjectConflictBlocksMixedRP(t *testing.T) {
|
|||||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "employee002").Return("kratos-target-b", nil)
|
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "employee002").Return("kratos-target-b", nil)
|
||||||
|
|
||||||
h := &AuthHandler{
|
h := &AuthHandler{
|
||||||
IdpProvider: mockIdp,
|
IdpProvider: mockIdp,
|
||||||
KratosAdmin: mockKratos,
|
KratosAdmin: mockKratos,
|
||||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient),
|
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient),
|
||||||
Hydra: &service.HydraAdminService{
|
Hydra: &service.HydraAdminService{
|
||||||
AdminURL: "http://hydra.test",
|
AdminURL: "http://hydra.test",
|
||||||
@@ -1080,8 +1080,8 @@ func TestHeadlessPasswordLogin_OIDCSubjectSameAllowsMixedRP(t *testing.T) {
|
|||||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "employee001").Return("kratos-userfront-a", nil)
|
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "employee001").Return("kratos-userfront-a", nil)
|
||||||
|
|
||||||
h := &AuthHandler{
|
h := &AuthHandler{
|
||||||
IdpProvider: mockIdp,
|
IdpProvider: mockIdp,
|
||||||
KratosAdmin: mockKratos,
|
KratosAdmin: mockKratos,
|
||||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient),
|
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient),
|
||||||
Hydra: &service.HydraAdminService{
|
Hydra: &service.HydraAdminService{
|
||||||
AdminURL: "http://hydra.test",
|
AdminURL: "http://hydra.test",
|
||||||
@@ -1281,8 +1281,8 @@ func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured(
|
|||||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "employee001").Return("kratos-identity-id", nil)
|
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "employee001").Return("kratos-identity-id", nil)
|
||||||
|
|
||||||
h := &AuthHandler{
|
h := &AuthHandler{
|
||||||
IdpProvider: mockIdp,
|
IdpProvider: mockIdp,
|
||||||
KratosAdmin: mockKratos,
|
KratosAdmin: mockKratos,
|
||||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient),
|
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient),
|
||||||
Hydra: &service.HydraAdminService{
|
Hydra: &service.HydraAdminService{
|
||||||
AdminURL: "http://hydra.test",
|
AdminURL: "http://hydra.test",
|
||||||
@@ -1544,8 +1544,8 @@ func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
h := &AuthHandler{
|
h := &AuthHandler{
|
||||||
IdpProvider: mockIdp,
|
IdpProvider: mockIdp,
|
||||||
KratosAdmin: mockKratos,
|
KratosAdmin: mockKratos,
|
||||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient),
|
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, jwksClient),
|
||||||
Hydra: &service.HydraAdminService{
|
Hydra: &service.HydraAdminService{
|
||||||
AdminURL: "http://hydra.test",
|
AdminURL: "http://hydra.test",
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ func normalizeDeveloperAccessPagesForHandler(pages []string) []string {
|
|||||||
}
|
}
|
||||||
if page == domain.DeveloperAccessPageAll {
|
if page == domain.DeveloperAccessPageAll {
|
||||||
normalized = []string{domain.DeveloperAccessPageAll}
|
normalized = []string{domain.DeveloperAccessPageAll}
|
||||||
seen = map[string]struct{}{domain.DeveloperAccessPageAll: struct{}{}}
|
seen = map[string]struct{}{domain.DeveloperAccessPageAll: {}}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, allowed := range domain.DeveloperAccessPageOrder {
|
for _, allowed := range domain.DeveloperAccessPageOrder {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func normalizeDeveloperAccessPages(pages []string) []string {
|
|||||||
}
|
}
|
||||||
if page == domain.DeveloperAccessPageAll {
|
if page == domain.DeveloperAccessPageAll {
|
||||||
normalized = []string{domain.DeveloperAccessPageAll}
|
normalized = []string{domain.DeveloperAccessPageAll}
|
||||||
seen = map[string]struct{}{domain.DeveloperAccessPageAll: struct{}{}}
|
seen = map[string]struct{}{domain.DeveloperAccessPageAll: {}}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if page != domain.DeveloperAccessPageOverview &&
|
if page != domain.DeveloperAccessPageOverview &&
|
||||||
|
|||||||
204
devfront/src/features/clients/ClientConsentsPage.test.tsx
Normal file
204
devfront/src/features/clients/ClientConsentsPage.test.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import ClientConsentsPage from "./ClientConsentsPage";
|
||||||
|
|
||||||
|
const fetchClientMock = vi.fn();
|
||||||
|
const fetchConsentsMock = vi.fn();
|
||||||
|
const fetchRPUserMetadataMock = vi.fn();
|
||||||
|
const updateRPUserMetadataMock = vi.fn();
|
||||||
|
const revokeConsentMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../lib/devApi", () => ({
|
||||||
|
fetchClient: (...args: unknown[]) => fetchClientMock(...args),
|
||||||
|
fetchConsents: (...args: unknown[]) => fetchConsentsMock(...args),
|
||||||
|
fetchRPUserMetadata: (...args: unknown[]) => fetchRPUserMetadataMock(...args),
|
||||||
|
updateRPUserMetadata: (...args: unknown[]) =>
|
||||||
|
updateRPUserMetadataMock(...args),
|
||||||
|
revokeConsent: (...args: unknown[]) => revokeConsentMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../lib/i18n", () => ({
|
||||||
|
t: (key: string, fallback?: string, vars?: Record<string, unknown>) => {
|
||||||
|
let text = fallback ?? key;
|
||||||
|
for (const [name, value] of Object.entries(vars ?? {})) {
|
||||||
|
text = text.replaceAll(`{{${name}}}`, String(value));
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const roots: Root[] = [];
|
||||||
|
|
||||||
|
const clientDetail = {
|
||||||
|
client: {
|
||||||
|
id: "client-a",
|
||||||
|
name: "Claims App",
|
||||||
|
type: "private" as const,
|
||||||
|
status: "active" as const,
|
||||||
|
redirectUris: ["https://rp.example.com/callback"],
|
||||||
|
scopes: ["openid", "profile"],
|
||||||
|
tokenEndpointAuthMethod: "client_secret_basic",
|
||||||
|
metadata: {
|
||||||
|
id_token_claims: [
|
||||||
|
{
|
||||||
|
namespace: "rp_claims",
|
||||||
|
key: "license",
|
||||||
|
value: "12345678",
|
||||||
|
valueType: "text",
|
||||||
|
readPermission: "admin_only",
|
||||||
|
writePermission: "admin_only",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
endpoints: {
|
||||||
|
discovery: "https://issuer/.well-known/openid-configuration",
|
||||||
|
issuer: "https://issuer",
|
||||||
|
authorization: "https://issuer/oauth2/auth",
|
||||||
|
token: "https://issuer/oauth2/token",
|
||||||
|
userinfo: "https://issuer/userinfo",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildMetadata() {
|
||||||
|
return {
|
||||||
|
license: "abcd",
|
||||||
|
license_permissions: {
|
||||||
|
readPermission: "user_and_admin",
|
||||||
|
writePermission: "admin_only",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush() {
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderPage() {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
roots.push(root);
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter initialEntries={["/clients/client-a/consents"]}>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/clients/:id/consents"
|
||||||
|
element={<ClientConsentsPage />}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
return { container };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ClientConsentsPage RP custom claims", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchClientMock.mockResolvedValue(clientDetail);
|
||||||
|
fetchConsentsMock.mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
subject: "user-1",
|
||||||
|
userName: "Consent User",
|
||||||
|
clientId: "client-a",
|
||||||
|
clientName: "Claims App",
|
||||||
|
grantedScopes: ["openid", "profile"],
|
||||||
|
authenticatedAt: "2026-06-11T09:00:00Z",
|
||||||
|
createdAt: "2026-06-10T09:00:00Z",
|
||||||
|
status: "active",
|
||||||
|
tenantId: "tenant-1",
|
||||||
|
tenantName: "Hanmac",
|
||||||
|
rpMetadata: buildMetadata(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
fetchRPUserMetadataMock.mockResolvedValue({
|
||||||
|
clientId: "client-a",
|
||||||
|
userId: "user-1",
|
||||||
|
metadata: buildMetadata(),
|
||||||
|
});
|
||||||
|
updateRPUserMetadataMock.mockResolvedValue({
|
||||||
|
clientId: "client-a",
|
||||||
|
userId: "user-1",
|
||||||
|
metadata: buildMetadata(),
|
||||||
|
});
|
||||||
|
revokeConsentMock.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const root of roots.splice(0)) {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
vi.clearAllMocks();
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the RP custom claim permission selectors while keeping claim data editable", async () => {
|
||||||
|
const { container } = await renderPage();
|
||||||
|
|
||||||
|
const editButton = Array.from(container.querySelectorAll("button")).find(
|
||||||
|
(button) =>
|
||||||
|
button.textContent?.includes("사용자 Claim 설정") ||
|
||||||
|
button.textContent?.includes("User Claim Settings"),
|
||||||
|
);
|
||||||
|
expect(editButton).toBeDefined();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
container.querySelectorAll('select[aria-label="읽기 권한"]'),
|
||||||
|
).toHaveLength(0);
|
||||||
|
expect(
|
||||||
|
container.querySelectorAll('select[aria-label="쓰기 권한"]'),
|
||||||
|
).toHaveLength(0);
|
||||||
|
expect(container.textContent).toContain("license");
|
||||||
|
expect(container.textContent).toContain("abcd");
|
||||||
|
|
||||||
|
const saveButton = Array.from(container.querySelectorAll("button")).find(
|
||||||
|
(button) =>
|
||||||
|
button.textContent?.includes("Claim 저장") ||
|
||||||
|
button.textContent?.includes("Save"),
|
||||||
|
);
|
||||||
|
expect(saveButton).toBeDefined();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(updateRPUserMetadataMock).toHaveBeenCalledWith(
|
||||||
|
"client-a",
|
||||||
|
"user-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
license: "abcd",
|
||||||
|
license_permissions: {
|
||||||
|
readPermission: "user_and_admin",
|
||||||
|
writePermission: "admin_only",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1023,7 +1023,7 @@ function ClientConsentsPage() {
|
|||||||
metadataDraftRows.map((row) => (
|
metadataDraftRows.map((row) => (
|
||||||
<div
|
<div
|
||||||
key={row.id}
|
key={row.id}
|
||||||
className="grid gap-3 md:grid-cols-[minmax(180px,0.8fr)_minmax(220px,1fr)_150px_150px_auto]"
|
className="grid gap-3 md:items-center md:grid-cols-[180px_minmax(220px,320px)_88px]"
|
||||||
>
|
>
|
||||||
<div className="flex h-10 items-center rounded-md border bg-muted/30 px-3 font-mono text-xs">
|
<div className="flex h-10 items-center rounded-md border bg-muted/30 px-3 font-mono text-xs">
|
||||||
{row.key}
|
{row.key}
|
||||||
@@ -1036,7 +1036,7 @@ function ClientConsentsPage() {
|
|||||||
value: event.target.value,
|
value: event.target.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="h-10 rounded-md border border-input bg-background px-3 font-mono text-xs shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
className="h-10 w-full max-w-[180px] rounded-md border border-input bg-background px-3 font-mono text-xs shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
aria-label={`${row.key} boolean`}
|
aria-label={`${row.key} boolean`}
|
||||||
>
|
>
|
||||||
<option value="true">true</option>
|
<option value="true">true</option>
|
||||||
@@ -1051,12 +1051,12 @@ function ClientConsentsPage() {
|
|||||||
value: event.target.value,
|
value: event.target.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="min-h-10 font-mono text-xs"
|
|
||||||
placeholder={
|
placeholder={
|
||||||
row.valueType === "array"
|
row.valueType === "array"
|
||||||
? `["value"]`
|
? `["value"]`
|
||||||
: `{"key": "value"}`
|
: `{"key": "value"}`
|
||||||
}
|
}
|
||||||
|
className="min-h-10 w-full max-w-[320px] font-mono text-xs"
|
||||||
aria-label={`${row.key} ${row.valueType}`}
|
aria-label={`${row.key} ${row.valueType}`}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -1071,7 +1071,7 @@ function ClientConsentsPage() {
|
|||||||
value: event.target.value,
|
value: event.target.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="font-mono text-xs"
|
className="w-full max-w-[320px] font-mono text-xs"
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"ui.dev.clients.consents.rp_claims.value_placeholder",
|
"ui.dev.clients.consents.rp_claims.value_placeholder",
|
||||||
"claim value",
|
"claim value",
|
||||||
@@ -1099,63 +1099,9 @@ function ClientConsentsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<select
|
|
||||||
value={row.readPermission}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateMetadataDraftRow(row.id, {
|
|
||||||
readPermission: event.target
|
|
||||||
.value as CustomClaimPermission,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
||||||
aria-label={t(
|
|
||||||
"ui.dev.clients.consents.rp_claims.read_permission",
|
|
||||||
"읽기 권한",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<option value="admin_only">
|
|
||||||
{t(
|
|
||||||
"ui.common.custom_claim_permission.admin_only",
|
|
||||||
"관리자만 가능",
|
|
||||||
)}
|
|
||||||
</option>
|
|
||||||
<option value="user_and_admin">
|
|
||||||
{t(
|
|
||||||
"ui.common.custom_claim_permission.user_and_admin",
|
|
||||||
"사용자 및 관리자 가능",
|
|
||||||
)}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={row.writePermission}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateMetadataDraftRow(row.id, {
|
|
||||||
writePermission: event.target
|
|
||||||
.value as CustomClaimPermission,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
||||||
aria-label={t(
|
|
||||||
"ui.dev.clients.consents.rp_claims.write_permission",
|
|
||||||
"쓰기 권한",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<option value="admin_only">
|
|
||||||
{t(
|
|
||||||
"ui.common.custom_claim_permission.admin_only",
|
|
||||||
"관리자만 가능",
|
|
||||||
)}
|
|
||||||
</option>
|
|
||||||
<option value="user_and_admin">
|
|
||||||
{t(
|
|
||||||
"ui.common.custom_claim_permission.user_and_admin",
|
|
||||||
"사용자 및 관리자 가능",
|
|
||||||
)}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<Badge
|
<Badge
|
||||||
variant="muted"
|
variant="muted"
|
||||||
className="h-10 justify-center rounded-md px-3 font-mono text-xs"
|
className="h-10 w-fit justify-center rounded-md px-3 font-mono text-xs"
|
||||||
>
|
>
|
||||||
{row.valueType}
|
{row.valueType}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -68,7 +68,36 @@ vi.mock("../../lib/i18n", () => ({
|
|||||||
|
|
||||||
const roots: Root[] = [];
|
const roots: Root[] = [];
|
||||||
|
|
||||||
function makeClientDetail(claimKey: string): ClientDetailResponse {
|
function makeClientDetail(
|
||||||
|
claimKey: string,
|
||||||
|
options?: {
|
||||||
|
includeTenantScope?: boolean;
|
||||||
|
tenantAccessRestricted?: boolean;
|
||||||
|
tenantScopeMandatory?: boolean;
|
||||||
|
},
|
||||||
|
): ClientDetailResponse {
|
||||||
|
const includeTenantScope = options?.includeTenantScope ?? false;
|
||||||
|
const tenantAccessRestricted = options?.tenantAccessRestricted ?? false;
|
||||||
|
const tenantScopeMandatory = options?.tenantScopeMandatory ?? false;
|
||||||
|
const structuredScopes = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
name: "openid",
|
||||||
|
description: "",
|
||||||
|
mandatory: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (includeTenantScope) {
|
||||||
|
structuredScopes.push({
|
||||||
|
id: "2",
|
||||||
|
name: "tenant",
|
||||||
|
description: "Tenant access",
|
||||||
|
mandatory: tenantScopeMandatory,
|
||||||
|
locked: tenantAccessRestricted,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
client: {
|
client: {
|
||||||
id: "client-claims",
|
id: "client-claims",
|
||||||
@@ -76,18 +105,14 @@ function makeClientDetail(claimKey: string): ClientDetailResponse {
|
|||||||
type: "private",
|
type: "private",
|
||||||
status: "active",
|
status: "active",
|
||||||
redirectUris: ["https://rp.example.com/callback"],
|
redirectUris: ["https://rp.example.com/callback"],
|
||||||
scopes: ["openid", "profile"],
|
scopes: includeTenantScope
|
||||||
|
? ["openid", "tenant", "profile"]
|
||||||
|
: ["openid", "profile"],
|
||||||
tokenEndpointAuthMethod: "client_secret_basic",
|
tokenEndpointAuthMethod: "client_secret_basic",
|
||||||
metadata: {
|
metadata: {
|
||||||
description: "Claims app",
|
description: "Claims app",
|
||||||
structured_scopes: [
|
tenant_access_restricted: tenantAccessRestricted,
|
||||||
{
|
structured_scopes: structuredScopes,
|
||||||
id: "1",
|
|
||||||
name: "openid",
|
|
||||||
description: "",
|
|
||||||
mandatory: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
id_token_claims: [
|
id_token_claims: [
|
||||||
{
|
{
|
||||||
namespace: "rp_claims",
|
namespace: "rp_claims",
|
||||||
@@ -304,6 +329,75 @@ describe("ClientGeneralPage RP claims", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves tenant scope mandatory state when tenant access restriction is off", async () => {
|
||||||
|
fetchClientMock.mockResolvedValue(
|
||||||
|
makeClientDetail("old_claim", {
|
||||||
|
includeTenantScope: true,
|
||||||
|
tenantAccessRestricted: false,
|
||||||
|
tenantScopeMandatory: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
updateClientMock.mockResolvedValue(
|
||||||
|
makeClientDetail("old_claim", {
|
||||||
|
includeTenantScope: true,
|
||||||
|
tenantAccessRestricted: false,
|
||||||
|
tenantScopeMandatory: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { container } = await renderPage();
|
||||||
|
|
||||||
|
const tenantScopeRow = Array.from(container.querySelectorAll("tr")).find(
|
||||||
|
(row) =>
|
||||||
|
Array.from(row.querySelectorAll("input")).some(
|
||||||
|
(input) => (input as HTMLInputElement).value === "tenant",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tenantScopeRow).toBeDefined();
|
||||||
|
const mandatorySwitch =
|
||||||
|
tenantScopeRow?.querySelector<HTMLButtonElement>('[role="switch"]');
|
||||||
|
expect(mandatorySwitch).toBeDefined();
|
||||||
|
expect(mandatorySwitch?.getAttribute("aria-checked")).toBe("true");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
mandatorySwitch?.dispatchEvent(
|
||||||
|
new MouseEvent("click", { bubbles: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(mandatorySwitch?.getAttribute("aria-checked")).toBe("false");
|
||||||
|
|
||||||
|
const saveButton = Array.from(container.querySelectorAll("button")).find(
|
||||||
|
(button) =>
|
||||||
|
button.textContent?.includes("저장") ||
|
||||||
|
button.textContent?.includes("Save"),
|
||||||
|
);
|
||||||
|
expect(saveButton).toBeDefined();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(updateClientMock).toHaveBeenCalledWith(
|
||||||
|
"client-claims",
|
||||||
|
expect.objectContaining({
|
||||||
|
metadata: expect.objectContaining({
|
||||||
|
tenant_access_restricted: false,
|
||||||
|
structured_scopes: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "tenant",
|
||||||
|
mandatory: false,
|
||||||
|
locked: false,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps nullable and default value as separate RP claim settings", async () => {
|
it("keeps nullable and default value as separate RP claim settings", async () => {
|
||||||
const { container } = await renderPage();
|
const { container } = await renderPage();
|
||||||
|
|
||||||
|
|||||||
@@ -689,11 +689,18 @@ function ClientGeneralPage() {
|
|||||||
if (scope.name.trim() !== "tenant") {
|
if (scope.name.trim() !== "tenant") {
|
||||||
return scope;
|
return scope;
|
||||||
}
|
}
|
||||||
|
if (restricted) {
|
||||||
|
return {
|
||||||
|
...scope,
|
||||||
|
description: scope.description || tenantScopeDescription,
|
||||||
|
mandatory: true,
|
||||||
|
locked: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...scope,
|
...scope,
|
||||||
description: scope.description || tenantScopeDescription,
|
description: scope.description || tenantScopeDescription,
|
||||||
mandatory: restricted,
|
locked: false,
|
||||||
locked: restricted,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -354,10 +354,12 @@ test.describe("DevFront RP claim cache", () => {
|
|||||||
|
|
||||||
await valueTypeSelect.selectOption("number");
|
await valueTypeSelect.selectOption("number");
|
||||||
await expect(valueTypeSelect).toHaveValue("number");
|
await expect(valueTypeSelect).toHaveValue("number");
|
||||||
await page
|
const defaultValueInput = page
|
||||||
.getByPlaceholder(/기본값을 입력하세요|Enter the default value/i)
|
.getByPlaceholder(/기본값을 입력하세요|Enter the default value/i)
|
||||||
.first()
|
.first();
|
||||||
.fill("3.14");
|
await expect(defaultValueInput).toHaveAttribute("inputmode", "numeric");
|
||||||
|
await expect(defaultValueInput).toHaveAttribute("pattern", "-?[0-9]*");
|
||||||
|
await defaultValueInput.fill("3.14");
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText(/Claim 기본값이 타입과 맞지 않습니다|does not match/i),
|
page.getByText(/Claim 기본값이 타입과 맞지 않습니다|does not match/i),
|
||||||
|
|||||||
@@ -167,7 +167,9 @@ test.describe("DevFront clients lifecycle", () => {
|
|||||||
.first()
|
.first()
|
||||||
.click();
|
.click();
|
||||||
await page
|
await page
|
||||||
.getByLabel(/Read 사용자 허용|Read user allowed/i)
|
.getByLabel(
|
||||||
|
/Read 사용자 허용|Read user allowed|사용자 읽기 허용|Allow user read/i,
|
||||||
|
)
|
||||||
.first()
|
.first()
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
@@ -347,10 +349,18 @@ test.describe("DevFront clients lifecycle", () => {
|
|||||||
).toHaveValue("2");
|
).toHaveValue("2");
|
||||||
await expect(page.getByLabel(/Nullable|Null 허용/i).first()).toBeChecked();
|
await expect(page.getByLabel(/Nullable|Null 허용/i).first()).toBeChecked();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByLabel(/Read 사용자 허용|Read user allowed/i).first(),
|
page
|
||||||
|
.getByLabel(
|
||||||
|
/Read 사용자 허용|Read user allowed|사용자 읽기 허용|Allow user read/i,
|
||||||
|
)
|
||||||
|
.first(),
|
||||||
).toBeChecked();
|
).toBeChecked();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByLabel(/Write 사용자 허용|Write user allowed/i).first(),
|
page
|
||||||
|
.getByLabel(
|
||||||
|
/Write 사용자 허용|Write user allowed|사용자 쓰기 허용|Allow user write/i,
|
||||||
|
)
|
||||||
|
.first(),
|
||||||
).not.toBeChecked();
|
).not.toBeChecked();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from "./helpers/devfront-fixtures";
|
} from "./helpers/devfront-fixtures";
|
||||||
import { captureEvidence } from "./helpers/evidence";
|
import { captureEvidence } from "./helpers/evidence";
|
||||||
import { installDevFrontStaticRoutes } from "./helpers/static-devfront";
|
import { installDevFrontStaticRoutes } from "./helpers/static-devfront";
|
||||||
|
import { dateTimeInputToUnixSeconds } from "../src/features/clients/rpClaimDateTime";
|
||||||
|
|
||||||
test.describe("DevFront consents", () => {
|
test.describe("DevFront consents", () => {
|
||||||
test.afterEach(async ({ page }, testInfo) => {
|
test.afterEach(async ({ page }, testInfo) => {
|
||||||
@@ -107,17 +108,22 @@ test.describe("DevFront consents", () => {
|
|||||||
.getByLabel(/active_member.*boolean|boolean.*active_member/i)
|
.getByLabel(/active_member.*boolean|boolean.*active_member/i)
|
||||||
.selectOption("false");
|
.selectOption("false");
|
||||||
await page.getByLabel(/score.*number|number.*score/i).fill("42");
|
await page.getByLabel(/score.*number|number.*score/i).fill("42");
|
||||||
await page
|
|
||||||
.getByLabel(/쓰기 권한|Write permission/i)
|
|
||||||
.first()
|
|
||||||
.selectOption("user_and_admin");
|
|
||||||
await page.getByRole("button", { name: /Claim 저장|Save Claim/i }).click();
|
await page.getByRole("button", { name: /Claim 저장|Save Claim/i }).click();
|
||||||
|
const browserTimeZone = await page.evaluate(
|
||||||
|
() => Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
);
|
||||||
await expect
|
await expect
|
||||||
.poll(() => state.consents[0]?.rpMetadata?.contract_date)
|
.poll(() => state.consents[0]?.rpMetadata?.contract_date)
|
||||||
.toBe(1781017200);
|
.toBe(dateTimeInputToUnixSeconds("2026-06-10", "date", browserTimeZone));
|
||||||
await expect
|
await expect
|
||||||
.poll(() => state.consents[0]?.rpMetadata?.approved_at)
|
.poll(() => state.consents[0]?.rpMetadata?.approved_at)
|
||||||
.toBe(1780968600);
|
.toBe(
|
||||||
|
dateTimeInputToUnixSeconds(
|
||||||
|
"2026-06-09T10:30",
|
||||||
|
"datetime",
|
||||||
|
browserTimeZone,
|
||||||
|
),
|
||||||
|
);
|
||||||
await expect
|
await expect
|
||||||
.poll(() => state.consents[0]?.rpMetadata?.active_member)
|
.poll(() => state.consents[0]?.rpMetadata?.active_member)
|
||||||
.toBe(false);
|
.toBe(false);
|
||||||
@@ -131,7 +137,7 @@ test.describe("DevFront consents", () => {
|
|||||||
| undefined
|
| undefined
|
||||||
)?.writePermission,
|
)?.writePermission,
|
||||||
)
|
)
|
||||||
.toBe("user_and_admin");
|
.toBe("admin_only");
|
||||||
|
|
||||||
await page.getByRole("button", { name: /권한 철회|철회|Revoke/i }).click();
|
await page.getByRole("button", { name: /권한 철회|철회|Revoke/i }).click();
|
||||||
await expect(page.getByText(/Revoked|철회/i).first()).toBeVisible();
|
await expect(page.getByText(/Revoked|철회/i).first()).toBeVisible();
|
||||||
|
|||||||
@@ -298,6 +298,7 @@ echo "==> adminfront using PORT=$port"
|
|||||||
(
|
(
|
||||||
cd "$tmp_dir/adminfront"
|
cd "$tmp_dir/adminfront"
|
||||||
CI=true PORT="$port" PLAYWRIGHT_WORKERS="${PLAYWRIGHT_WORKERS:-1}" \
|
CI=true PORT="$port" PLAYWRIGHT_WORKERS="${PLAYWRIGHT_WORKERS:-1}" \
|
||||||
|
ADMINFRONT_DIST_DIR="$tmp_dir/adminfront/dist" \
|
||||||
pnpm exec playwright test --max-failures=1 "${playwright_project_args[@]}"
|
pnpm exec playwright test --max-failures=1 "${playwright_project_args[@]}"
|
||||||
) 2>&1 | tee reports/adminfront-test.log
|
) 2>&1 | tee reports/adminfront-test.log
|
||||||
test_exit_code=${PIPESTATUS[0]}
|
test_exit_code=${PIPESTATUS[0]}
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ error = "An error occurred while cancelling consent: {error}"
|
|||||||
|
|
||||||
[msg.userfront.consent.scope]
|
[msg.userfront.consent.scope]
|
||||||
email = "Email address (account identification and notifications)"
|
email = "Email address (account identification and notifications)"
|
||||||
|
offline_access = "Offline access (keep signed in)"
|
||||||
openid = "OpenID authentication information (signin session check)"
|
openid = "OpenID authentication information (signin session check)"
|
||||||
phone = "Phone number (identity verification and notifications)"
|
phone = "Phone number (identity verification and notifications)"
|
||||||
profile = "Basic profile information (name, user identifier)"
|
profile = "Basic profile information (name, user identifier)"
|
||||||
@@ -705,3 +706,4 @@ toggle_label = "Show active sessions only"
|
|||||||
|
|
||||||
[msg.userfront.audit.filter]
|
[msg.userfront.audit.filter]
|
||||||
description = "Toggle to view only active sessions."
|
description = "Toggle to view only active sessions."
|
||||||
|
|
||||||
|
|||||||
@@ -297,6 +297,7 @@ error = "취소 처리 중 오류가 발생했습니다: {error}"
|
|||||||
|
|
||||||
[msg.userfront.consent.scope]
|
[msg.userfront.consent.scope]
|
||||||
email = "이메일 주소 (계정 식별 및 알림 용도)"
|
email = "이메일 주소 (계정 식별 및 알림 용도)"
|
||||||
|
offline_access = "오프라인 접근 (로그인 유지)"
|
||||||
openid = "OpenID 인증 정보 (로그인 상태 확인)"
|
openid = "OpenID 인증 정보 (로그인 상태 확인)"
|
||||||
phone = "휴대폰 번호 (본인 인증 및 알림)"
|
phone = "휴대폰 번호 (본인 인증 및 알림)"
|
||||||
profile = "기본 프로필 정보 (이름, 사용자 식별자)"
|
profile = "기본 프로필 정보 (이름, 사용자 식별자)"
|
||||||
@@ -926,3 +927,4 @@ toggle_label = "활성 세션만 보기"
|
|||||||
|
|
||||||
[msg.userfront.audit.filter]
|
[msg.userfront.audit.filter]
|
||||||
description = "활성화된 세션만 보려면 토글을 켜주세요."
|
description = "활성화된 세션만 보려면 토글을 켜주세요."
|
||||||
|
|
||||||
|
|||||||
@@ -269,6 +269,7 @@ error = ""
|
|||||||
|
|
||||||
[msg.userfront.consent.scope]
|
[msg.userfront.consent.scope]
|
||||||
email = ""
|
email = ""
|
||||||
|
offline_access = ""
|
||||||
openid = ""
|
openid = ""
|
||||||
phone = ""
|
phone = ""
|
||||||
profile = ""
|
profile = ""
|
||||||
@@ -898,3 +899,4 @@ toggle_label = ""
|
|||||||
|
|
||||||
[msg.userfront.audit.filter]
|
[msg.userfront.audit.filter]
|
||||||
description = ""
|
description = ""
|
||||||
|
|
||||||
|
|||||||
@@ -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: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.19"
|
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:
|
||||||
@@ -653,26 +661,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test
|
name: test
|
||||||
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
|
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.30.0"
|
version: "1.26.3"
|
||||||
test_api:
|
test_api:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.10"
|
version: "0.7.7"
|
||||||
test_core:
|
test_core:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_core
|
name: test_core
|
||||||
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
|
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.16"
|
version: "0.6.12"
|
||||||
toml:
|
toml:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
Reference in New Issue
Block a user