forked from baron/baron-sso
Merge pull request 'feature/afdf-session' (#725) from feature/afdf-session into dev
Reviewed-on: baron/baron-sso#725
This commit is contained in:
@@ -123,6 +123,7 @@ function createEmptyAppointment(): AppointmentDraft {
|
|||||||
tenantId: "",
|
tenantId: "",
|
||||||
tenantName: "",
|
tenantName: "",
|
||||||
tenantSlug: "",
|
tenantSlug: "",
|
||||||
|
isPrimary: false,
|
||||||
isOwner: false,
|
isOwner: false,
|
||||||
jobTitle: "",
|
jobTitle: "",
|
||||||
position: "",
|
position: "",
|
||||||
@@ -554,6 +555,15 @@ function UserDetailPage() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setPrimaryAppointment = (targetIndex: number) => {
|
||||||
|
setAdditionalAppointments((current) =>
|
||||||
|
current.map((appointment, index) => ({
|
||||||
|
...appointment,
|
||||||
|
isPrimary: index === targetIndex,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const handleUserTypeChange = (value: string) => {
|
const handleUserTypeChange = (value: string) => {
|
||||||
const nextType = value as UserType;
|
const nextType = value as UserType;
|
||||||
setUserType(nextType);
|
setUserType(nextType);
|
||||||
@@ -645,6 +655,9 @@ function UserDetailPage() {
|
|||||||
Array.isArray(rawAppointments)
|
Array.isArray(rawAppointments)
|
||||||
? (rawAppointments as UserAppointment[]).map((appointment) => ({
|
? (rawAppointments as UserAppointment[]).map((appointment) => ({
|
||||||
...appointment,
|
...appointment,
|
||||||
|
isPrimary:
|
||||||
|
appointment.isPrimary === true ||
|
||||||
|
appointment.tenantId === primaryFromMetadata?.id,
|
||||||
draftId: createDraftId(),
|
draftId: createDraftId(),
|
||||||
}))
|
}))
|
||||||
: isUserHanmacFamily
|
: isUserHanmacFamily
|
||||||
@@ -654,6 +667,7 @@ function UserDetailPage() {
|
|||||||
tenantId: tenant.id,
|
tenantId: tenant.id,
|
||||||
tenantName: tenant.name,
|
tenantName: tenant.name,
|
||||||
tenantSlug: tenant.slug,
|
tenantSlug: tenant.slug,
|
||||||
|
isPrimary: tenant.id === fallbackAppointment?.id,
|
||||||
isOwner:
|
isOwner:
|
||||||
metadata.primaryTenantIsOwner === true &&
|
metadata.primaryTenantIsOwner === true &&
|
||||||
tenant.id === fallbackAppointment?.id,
|
tenant.id === fallbackAppointment?.id,
|
||||||
@@ -667,6 +681,7 @@ function UserDetailPage() {
|
|||||||
tenantId: fallbackAppointment.id,
|
tenantId: fallbackAppointment.id,
|
||||||
tenantName: fallbackAppointment.name,
|
tenantName: fallbackAppointment.name,
|
||||||
tenantSlug: fallbackAppointment.slug,
|
tenantSlug: fallbackAppointment.slug,
|
||||||
|
isPrimary: true,
|
||||||
isOwner: metadata.primaryTenantIsOwner === true,
|
isOwner: metadata.primaryTenantIsOwner === true,
|
||||||
jobTitle: user.jobTitle,
|
jobTitle: user.jobTitle,
|
||||||
position: user.position,
|
position: user.position,
|
||||||
@@ -781,7 +796,15 @@ function UserDetailPage() {
|
|||||||
payload.metadata = {
|
payload.metadata = {
|
||||||
...metadata,
|
...metadata,
|
||||||
additionalAppointments: appointments,
|
additionalAppointments: appointments,
|
||||||
|
primaryTenantId: primary?.tenantId,
|
||||||
|
primaryTenantName: primary?.tenantName,
|
||||||
|
primaryTenantSlug: primary?.tenantSlug,
|
||||||
|
primaryTenantIsOwner: primary?.isOwner ?? false,
|
||||||
};
|
};
|
||||||
|
payload.tenantSlug = primary?.tenantSlug;
|
||||||
|
payload.primaryTenantId = primary?.tenantId;
|
||||||
|
payload.primaryTenantName = primary?.tenantName;
|
||||||
|
payload.primaryTenantIsOwner = primary?.isOwner ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
mutation.mutate(payload);
|
mutation.mutate(payload);
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "../../components/ui/dialog";
|
} from "../../components/ui/dialog";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "../../components/ui/select";
|
||||||
import { Switch } from "../../components/ui/switch";
|
import { Switch } from "../../components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -547,7 +554,7 @@ function UserListPage() {
|
|||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="min-w-[220px] cursor-pointer hover:bg-muted/50 transition-colors"
|
className="min-w-[220px] whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
onClick={() => requestSort("id")}
|
onClick={() => requestSort("id")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@@ -556,7 +563,7 @@ function UserListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="min-w-[200px] cursor-pointer hover:bg-muted/50 transition-colors"
|
className="min-w-[200px] whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
onClick={() => requestSort("name_email")}
|
onClick={() => requestSort("name_email")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@@ -568,7 +575,7 @@ function UserListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
className="whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
onClick={() => requestSort("status")}
|
onClick={() => requestSort("status")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@@ -577,7 +584,7 @@ function UserListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
className="whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
onClick={() => requestSort("tenant_dept")}
|
onClick={() => requestSort("tenant_dept")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@@ -594,7 +601,7 @@ function UserListPage() {
|
|||||||
visibleColumns[field.key] !== false && (
|
visibleColumns[field.key] !== false && (
|
||||||
<TableHead
|
<TableHead
|
||||||
key={field.key}
|
key={field.key}
|
||||||
className="uppercase cursor-pointer hover:bg-muted/50 transition-colors"
|
className="whitespace-nowrap uppercase cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
onClick={() => requestSort(field.key)}
|
onClick={() => requestSort(field.key)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@@ -605,7 +612,7 @@ function UserListPage() {
|
|||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
<TableHead
|
<TableHead
|
||||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
className="whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
onClick={() => requestSort("createdAt")}
|
onClick={() => requestSort("createdAt")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@@ -693,7 +700,7 @@ function UserListPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>{" "}
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
describe("shouldAttemptSlidingSessionRenew", () => {
|
describe("shouldAttemptSlidingSessionRenew", () => {
|
||||||
const nowMs = 1_700_000_000_000;
|
const nowMs = 1_700_000_000_000;
|
||||||
|
|
||||||
it("returns false when remaining time is above the 5 minute threshold", () => {
|
it("returns false when remaining time is above the 10 minute threshold", () => {
|
||||||
expect(
|
expect(
|
||||||
shouldAttemptSlidingSessionRenew({
|
shouldAttemptSlidingSessionRenew({
|
||||||
expiresAtSec: Math.floor(
|
expiresAtSec: Math.floor(
|
||||||
@@ -24,7 +24,7 @@ describe("shouldAttemptSlidingSessionRenew", () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns true when remaining time is within the 5 minute threshold", () => {
|
it("returns true when remaining time is within the 10 minute threshold", () => {
|
||||||
expect(
|
expect(
|
||||||
shouldAttemptSlidingSessionRenew({
|
shouldAttemptSlidingSessionRenew({
|
||||||
expiresAtSec: Math.floor(
|
expiresAtSec: Math.floor(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const SESSION_RENEW_THRESHOLD_MS = 5 * 60 * 1000;
|
export const SESSION_RENEW_THRESHOLD_MS = 10 * 60 * 1000;
|
||||||
export const SESSION_RENEW_THROTTLE_MS = 30 * 1000;
|
export const SESSION_RENEW_THROTTLE_MS = 30 * 1000;
|
||||||
|
|
||||||
type SlidingSessionRenewDecisionParams = {
|
type SlidingSessionRenewDecisionParams = {
|
||||||
|
|||||||
@@ -131,12 +131,17 @@ empty = ""
|
|||||||
import_error = ""
|
import_error = ""
|
||||||
import_success = ""
|
import_success = ""
|
||||||
loading = ""
|
loading = ""
|
||||||
|
no_results = ""
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
|
|
||||||
[msg.admin.groups.members]
|
[msg.admin.groups.members]
|
||||||
|
add_modal_desc = ""
|
||||||
add_success = ""
|
add_success = ""
|
||||||
|
all_added = ""
|
||||||
count = ""
|
count = ""
|
||||||
empty = ""
|
empty = ""
|
||||||
|
move_modal_desc = ""
|
||||||
|
move_success = ""
|
||||||
remove_confirm = ""
|
remove_confirm = ""
|
||||||
remove_success = ""
|
remove_success = ""
|
||||||
title = ""
|
title = ""
|
||||||
@@ -234,6 +239,9 @@ subtitle = ""
|
|||||||
desc = ""
|
desc = ""
|
||||||
empty = ""
|
empty = ""
|
||||||
limit_notice = ""
|
limit_notice = ""
|
||||||
|
remove_confirm = ""
|
||||||
|
remove_error = ""
|
||||||
|
remove_success = ""
|
||||||
|
|
||||||
[msg.admin.tenants.registry]
|
[msg.admin.tenants.registry]
|
||||||
count = ""
|
count = ""
|
||||||
@@ -250,6 +258,7 @@ empty = ""
|
|||||||
subtitle = ""
|
subtitle = ""
|
||||||
|
|
||||||
[msg.admin.users]
|
[msg.admin.users]
|
||||||
|
confirm_remove_org = ""
|
||||||
export_error = ""
|
export_error = ""
|
||||||
status_error = ""
|
status_error = ""
|
||||||
|
|
||||||
@@ -827,8 +836,11 @@ unit_level_placeholder = ""
|
|||||||
title = ""
|
title = ""
|
||||||
|
|
||||||
[ui.admin.groups.members]
|
[ui.admin.groups.members]
|
||||||
|
add_modal_title = ""
|
||||||
|
move_modal_title = ""
|
||||||
|
|
||||||
[ui.admin.groups.members.table]
|
[ui.admin.groups.members.table]
|
||||||
|
actions = ""
|
||||||
email = ""
|
email = ""
|
||||||
name = ""
|
name = ""
|
||||||
remove = ""
|
remove = ""
|
||||||
@@ -899,6 +911,8 @@ export_with_ids = ""
|
|||||||
export_without_ids = ""
|
export_without_ids = ""
|
||||||
import = ""
|
import = ""
|
||||||
title = ""
|
title = ""
|
||||||
|
view.hierarchy = ""
|
||||||
|
view.list = ""
|
||||||
view_org_chart = ""
|
view_org_chart = ""
|
||||||
|
|
||||||
[ui.admin.tenants.domain_conflict]
|
[ui.admin.tenants.domain_conflict]
|
||||||
@@ -985,8 +999,12 @@ search_placeholder = ""
|
|||||||
select_placeholder = ""
|
select_placeholder = ""
|
||||||
|
|
||||||
[ui.admin.tenants.members]
|
[ui.admin.tenants.members]
|
||||||
|
add_existing = ""
|
||||||
|
create_new = ""
|
||||||
descendants = ""
|
descendants = ""
|
||||||
direct = ""
|
direct = ""
|
||||||
|
remove = ""
|
||||||
|
view_profile = ""
|
||||||
|
|
||||||
[msg.admin.apikeys.registry]
|
[msg.admin.apikeys.registry]
|
||||||
count = ""
|
count = ""
|
||||||
@@ -1013,6 +1031,7 @@ total = ""
|
|||||||
total_label = ""
|
total_label = ""
|
||||||
|
|
||||||
[ui.admin.tenants.members.table]
|
[ui.admin.tenants.members.table]
|
||||||
|
actions = ""
|
||||||
email = ""
|
email = ""
|
||||||
name = ""
|
name = ""
|
||||||
role = ""
|
role = ""
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
"gorm.io/driver/postgres"
|
"gorm.io/driver/postgres"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"log"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
|
|||||||
@@ -1620,54 +1620,55 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Normal update (Move): replace primary company code and remove the old one from existingCodes
|
// Normal update (Move): replace primary company code and remove the old one from existingCodes
|
||||||
currentPrimary := extractTraitString(traits, "companyCode")
|
currentPrimary := extractTraitString(traits, "companyCode")
|
||||||
if currentPrimary != "" && currentPrimary != code {
|
if currentPrimary != "" && currentPrimary != code {
|
||||||
// Remove old primary from existingCodes
|
// Remove old primary from existingCodes
|
||||||
var newCodes []string
|
var newCodes []string
|
||||||
for _, existing := range existingCodes {
|
for _, existing := range existingCodes {
|
||||||
if existing != currentPrimary {
|
if existing != currentPrimary {
|
||||||
newCodes = append(newCodes, existing)
|
newCodes = append(newCodes, existing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
existingCodes = newCodes
|
existingCodes = newCodes
|
||||||
|
|
||||||
// [Keto Sync] Remove membership for the old tenant
|
// [Keto Sync] Remove membership for the old tenant
|
||||||
if h.TenantService != nil && h.KetoOutboxRepo != nil {
|
if h.TenantService != nil && h.KetoOutboxRepo != nil {
|
||||||
go func(removedSlug string) {
|
go func(removedSlug string) {
|
||||||
bgCtx := context.Background()
|
bgCtx := context.Background()
|
||||||
if t, err := h.TenantService.GetTenantBySlug(bgCtx, removedSlug); err == nil && t != nil {
|
if t, err := h.TenantService.GetTenantBySlug(bgCtx, removedSlug); err == nil && t != nil {
|
||||||
_ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{
|
_ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{
|
||||||
Namespace: "Tenant",
|
Namespace: "Tenant",
|
||||||
Object: t.ID,
|
Object: t.ID,
|
||||||
Relation: "members",
|
Relation: "members",
|
||||||
Subject: "User:" + userID,
|
Subject: "User:" + userID,
|
||||||
Action: domain.KetoOutboxActionDelete,
|
Action: domain.KetoOutboxActionDelete,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}(currentPrimary)
|
}(currentPrimary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
traits["companyCode"] = code
|
traits["companyCode"] = code
|
||||||
// Resolve TenantID for Kratos Trait
|
// Resolve TenantID for Kratos Trait
|
||||||
if h.TenantService != nil && code != "" {
|
if h.TenantService != nil && code != "" {
|
||||||
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil {
|
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil {
|
||||||
traits["tenant_id"] = tenant.ID
|
traits["tenant_id"] = tenant.ID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
found := false
|
found := false
|
||||||
for _, existing := range existingCodes {
|
for _, existing := range existingCodes {
|
||||||
if existing == code {
|
if existing == code {
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found && code != "" {
|
if !found && code != "" {
|
||||||
existingCodes = append(existingCodes, code)
|
existingCodes = append(existingCodes, code)
|
||||||
}
|
}
|
||||||
} }
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Deduplicate and save back companyCodes
|
// Deduplicate and save back companyCodes
|
||||||
var codesToSave []string
|
var codesToSave []string
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import type { AxiosError } from "axios";
|
import type { AxiosError } from "axios";
|
||||||
import {
|
import { BookOpenText, Filter, Plus, Search, X } from "lucide-react";
|
||||||
BookOpenText,
|
|
||||||
Filter,
|
|
||||||
Plus,
|
|
||||||
Search,
|
|
||||||
ServerCog,
|
|
||||||
ShieldHalf,
|
|
||||||
X,
|
|
||||||
} 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 { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
@@ -50,6 +42,7 @@ import { t } from "../../lib/i18n";
|
|||||||
import { resolveProfileRole } from "../../lib/role";
|
import { resolveProfileRole } from "../../lib/role";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
import { fetchMe } from "../auth/authApi";
|
import { fetchMe } from "../auth/authApi";
|
||||||
|
import { ClientLogo } from "./components/ClientLogo";
|
||||||
|
|
||||||
function ClientsPage() {
|
function ClientsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -498,13 +491,7 @@ function ClientsPage() {
|
|||||||
to={`/clients/${client.id}`}
|
to={`/clients/${client.id}`}
|
||||||
className="flex items-center gap-3 transition-colors hover:text-primary"
|
className="flex items-center gap-3 transition-colors hover:text-primary"
|
||||||
>
|
>
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
<ClientLogo client={client} />
|
||||||
{client.type === "private" ? (
|
|
||||||
<ServerCog className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<ShieldHalf className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">
|
<p className="font-semibold">
|
||||||
{client.name ||
|
{client.name ||
|
||||||
|
|||||||
59
devfront/src/features/clients/components/ClientLogo.tsx
Normal file
59
devfront/src/features/clients/components/ClientLogo.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { ServerCog, ShieldHalf } from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
} from "../../../components/ui/avatar";
|
||||||
|
import type { ClientSummary, ClientType } from "../../../lib/devApi";
|
||||||
|
import { t } from "../../../lib/i18n";
|
||||||
|
|
||||||
|
type ClientLogoProps = {
|
||||||
|
client: Pick<ClientSummary, "name" | "type" | "metadata">;
|
||||||
|
};
|
||||||
|
|
||||||
|
function readLogoUrl(metadata?: Record<string, unknown>): string | undefined {
|
||||||
|
const logoUrl = metadata?.logo_url;
|
||||||
|
if (typeof logoUrl !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedLogoUrl = logoUrl.trim();
|
||||||
|
return trimmedLogoUrl.length > 0 ? trimmedLogoUrl : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TypeFallbackIcon({ type }: { type: ClientType }) {
|
||||||
|
if (type === "private") {
|
||||||
|
return <ServerCog className="h-4 w-4" aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ShieldHalf className="h-4 w-4" aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientLogo({ client }: ClientLogoProps) {
|
||||||
|
const [didImageFail, setDidImageFail] = useState(false);
|
||||||
|
const logoUrl = useMemo(
|
||||||
|
() => readLogoUrl(client.metadata),
|
||||||
|
[client.metadata],
|
||||||
|
);
|
||||||
|
const showImage = Boolean(logoUrl) && !didImageFail;
|
||||||
|
const clientName = client.name || t("ui.dev.clients.untitled", "Untitled");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Avatar className="h-9 w-9 rounded-lg border border-border/60 bg-primary/10 text-primary">
|
||||||
|
{showImage ? (
|
||||||
|
<AvatarImage
|
||||||
|
src={logoUrl}
|
||||||
|
alt={t("ui.dev.clients.logo_alt", "{{name}} 로고", {
|
||||||
|
name: clientName,
|
||||||
|
})}
|
||||||
|
className="object-contain p-1"
|
||||||
|
onError={() => setDidImageFail(true)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<AvatarFallback className="rounded-lg bg-primary/10 text-primary">
|
||||||
|
<TypeFallbackIcon type={client.type} />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export const SESSION_RENEW_THRESHOLD_MS = 5 * 60 * 1000;
|
export const SESSION_RENEW_THRESHOLD_MS = 10 * 60 * 1000;
|
||||||
export const SESSION_RENEW_THROTTLE_MS = 30 * 1000;
|
export const SESSION_RENEW_THROTTLE_MS = 30 * 1000;
|
||||||
|
|
||||||
type SlidingSessionRenewDecisionParams = {
|
type SlidingSessionRenewDecisionParams = {
|
||||||
|
|||||||
@@ -24,6 +24,29 @@ devfront의 RP 일반 설정에서 다음 항목을 입력합니다.
|
|||||||
3. Redirect URI 목록에는 RP callback URL을 등록합니다.
|
3. Redirect URI 목록에는 RP callback URL을 등록합니다.
|
||||||
4. 저장 후 userfront 연동 앱 카드에서 “연동앱 클릭 시 별도 로그인 없이 로그인할 수 있습니다.” 안내가 보이는지 확인합니다.
|
4. 저장 후 userfront 연동 앱 카드에서 “연동앱 클릭 시 별도 로그인 없이 로그인할 수 있습니다.” 안내가 보이는지 확인합니다.
|
||||||
|
|
||||||
|
### 설정 규칙
|
||||||
|
|
||||||
|
- `자동 로그인 시작 URL`은 반드시 `http://` 또는 `https://`로 시작하는 완전한 절대 URL이어야 합니다.
|
||||||
|
- Baron은 이 값을 브라우저 진입 URL로만 사용합니다. Baron이 `/oauth2/auth?...`를 대신 생성하지 않습니다.
|
||||||
|
- 따라서 이 URL은 RP 내부의 "로그인 시작 엔드포인트"여야 합니다.
|
||||||
|
- 루트(`/`)가 아니라 실제로 OIDC 시작을 트리거하는 경로를 넣어야 합니다.
|
||||||
|
|
||||||
|
허용 예시:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:3333/login
|
||||||
|
http://localhost:3333/login?auto=1
|
||||||
|
https://rp.example.com/login?auto=1&returnTo=%2Fdashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
비권장 예시:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http:localhost:3333/login
|
||||||
|
localhost:3333/login
|
||||||
|
/login?auto=1
|
||||||
|
```
|
||||||
|
|
||||||
예시:
|
예시:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -32,6 +55,24 @@ auto_login_url: https://org.example.com/login?auto=1
|
|||||||
redirect_uri: https://org.example.com/auth/callback
|
redirect_uri: https://org.example.com/auth/callback
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 로컬 RP 예시
|
||||||
|
|
||||||
|
로컬에서 `client_id=f5cdd938-a3ae-4e47-ab83-4c13e59949f5` RP가 `http://localhost:3333`에서 실행 중이라면, 메인 페이지가 `/login` 링크를 노출하고 `/login` 호출 시 Baron OIDC authorize endpoint로 `302`를 반환하는지 먼저 확인합니다.
|
||||||
|
|
||||||
|
이 경우 devfront 설정 탭의 권장 입력값은 다음과 같습니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
자동 로그인 지원: ON
|
||||||
|
자동 로그인 시작 URL: http://localhost:3333/login
|
||||||
|
Redirect URI: http://localhost:3333/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
주의:
|
||||||
|
|
||||||
|
- `http://localhost:3333/`는 홈 URL일 뿐, 로그인 시작 URL이 아닐 수 있습니다.
|
||||||
|
- `http://localhost:3333/login?auto=1`도 저장은 가능하지만, RP가 `/login`만으로 이미 즉시 OIDC를 시작한다면 `?auto=1`은 필수가 아닙니다.
|
||||||
|
- 현재 확인된 로컬 데모 RP는 `/login`만 호출해도 Baron OIDC로 바로 리다이렉트합니다.
|
||||||
|
|
||||||
## RP 구현 요구사항
|
## RP 구현 요구사항
|
||||||
|
|
||||||
RP는 `auto_login_url`에서 다음 동작을 구현해야 합니다.
|
RP는 `auto_login_url`에서 다음 동작을 구현해야 합니다.
|
||||||
@@ -74,6 +115,81 @@ userfront는 backend의 linked RP 응답을 기준으로 진입 URL을 선택합
|
|||||||
|
|
||||||
이 기준 때문에 `auto_login_supported=false`인 RP는 accidental auto-login을 수행하지 않습니다.
|
이 기준 때문에 `auto_login_supported=false`인 RP는 accidental auto-login을 수행하지 않습니다.
|
||||||
|
|
||||||
|
## 시퀀스 다이어그램
|
||||||
|
|
||||||
|
### 1. 설정 저장 흐름
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Admin as 운영자
|
||||||
|
participant DevFront as DevFront 설정 화면
|
||||||
|
participant Backend as Baron Backend
|
||||||
|
participant Hydra as Hydra Client Metadata
|
||||||
|
|
||||||
|
Admin->>DevFront: RP 설정 화면 진입
|
||||||
|
Admin->>DevFront: 자동 로그인 지원 ON
|
||||||
|
Admin->>DevFront: 자동 로그인 시작 URL 입력
|
||||||
|
Admin->>DevFront: 저장
|
||||||
|
DevFront->>Backend: RP 일반 설정 수정 요청
|
||||||
|
Backend->>Backend: auto_login_url 검증
|
||||||
|
Note over Backend: scheme=http/https<br/>host 존재 필요
|
||||||
|
Backend->>Hydra: client metadata update
|
||||||
|
Hydra-->>Backend: 저장 완료
|
||||||
|
Backend-->>DevFront: 저장 성공
|
||||||
|
DevFront-->>Admin: 저장 완료 표시
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. userfront에서 자동 로그인 시작
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User as 사용자
|
||||||
|
participant UserFront as UserFront
|
||||||
|
participant Backend as Baron Backend
|
||||||
|
participant RP as RP 로그인 시작 URL
|
||||||
|
participant Baron as Baron OIDC/Hydra
|
||||||
|
|
||||||
|
User->>UserFront: 연동 앱 카드 클릭
|
||||||
|
UserFront->>Backend: linked RP 목록/상태 조회
|
||||||
|
Backend-->>UserFront: auto_login_supported, auto_login_url 반환
|
||||||
|
|
||||||
|
alt auto_login_supported = true
|
||||||
|
UserFront->>RP: GET auto_login_url
|
||||||
|
Note over RP: RP가 state, nonce,<br/>PKCE verifier/challenge 생성
|
||||||
|
RP-->>UserFront: 302 Baron authorize endpoint
|
||||||
|
UserFront->>Baron: GET /oidc/oauth2/auth
|
||||||
|
else auto_login_supported = false
|
||||||
|
UserFront->>UserFront: 일반 url 또는 init_url 사용
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 로컬 3333 RP 예시 흐름
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User as 사용자 브라우저
|
||||||
|
participant UserFront as UserFront
|
||||||
|
participant RP as localhost:3333 RP
|
||||||
|
participant Baron as Baron OIDC
|
||||||
|
|
||||||
|
User->>UserFront: RP 카드 클릭
|
||||||
|
UserFront->>RP: GET http://localhost:3333/login
|
||||||
|
RP-->>User: 302 /oidc/oauth2/auth?...&client_id=f5cdd938-a3ae-4e47-ab83-4c13e59949f5
|
||||||
|
User->>Baron: GET authorize endpoint
|
||||||
|
Baron-->>User: 로그인/동의 또는 기존 세션 진행
|
||||||
|
Baron-->>RP: callback redirect
|
||||||
|
RP-->>User: RP 세션 생성 후 앱 화면 진입
|
||||||
|
```
|
||||||
|
|
||||||
|
## 로직 요약
|
||||||
|
|
||||||
|
1. DevFront는 `auto_login_supported`, `auto_login_url`을 RP metadata로 저장합니다.
|
||||||
|
2. Backend는 `auto_login_supported=true`일 때만 `auto_login_url`을 linked RP 응답에 포함시켜 userfront가 사용할 수 있게 합니다.
|
||||||
|
3. UserFront는 카드 클릭 시 이 URL로 직접 이동합니다.
|
||||||
|
4. RP는 그 진입점에서 OIDC 요청을 "직접" 생성해야 합니다.
|
||||||
|
5. Callback 검증에 필요한 `state`, `nonce`, PKCE 상태는 RP 저장소가 소유해야 합니다.
|
||||||
|
6. 그래서 Baron이 RP 대신 authorize URL을 만들어 주는 구조로 바꾸면 안 됩니다.
|
||||||
|
|
||||||
## 검증 체크리스트
|
## 검증 체크리스트
|
||||||
|
|
||||||
RP 등록자는 다음을 확인해야 합니다.
|
RP 등록자는 다음을 확인해야 합니다.
|
||||||
@@ -101,6 +217,7 @@ npm run test -- tests/orgfront-auto-login.spec.ts --project=chromium
|
|||||||
| RP 로그인 화면에 머무름 | RP가 `auto=1` 쿼리를 읽어 자동으로 `signinRedirect` 또는 동일한 OIDC 시작 함수를 호출하는지 확인합니다. |
|
| RP 로그인 화면에 머무름 | RP가 `auto=1` 쿼리를 읽어 자동으로 `signinRedirect` 또는 동일한 OIDC 시작 함수를 호출하는지 확인합니다. |
|
||||||
| callback에서 state 오류 발생 | userfront나 backend가 만든 `/oauth2/auth?...` URL을 직접 쓰지 말고 RP 자체 로그인 시작 URL에서 OIDC 요청을 생성해야 합니다. |
|
| callback에서 state 오류 발생 | userfront나 backend가 만든 `/oauth2/auth?...` URL을 직접 쓰지 말고 RP 자체 로그인 시작 URL에서 OIDC 요청을 생성해야 합니다. |
|
||||||
| 등록 저장이 실패함 | `auto_login_supported=true`일 때 `auto_login_url`이 비어 있거나 `http/https` URL이 아닌지 확인합니다. |
|
| 등록 저장이 실패함 | `auto_login_supported=true`일 때 `auto_login_url`이 비어 있거나 `http/https` URL이 아닌지 확인합니다. |
|
||||||
|
| `http:localhost:3333/login` 같은 값이 브라우저에서는 열림 | 브라우저 보정에 기대지 말고 `http://localhost:3333/login`처럼 완전한 절대 URL로 저장합니다. |
|
||||||
|
|
||||||
## 구현 예시
|
## 구현 예시
|
||||||
|
|
||||||
|
|||||||
@@ -131,12 +131,17 @@ empty = "No organization units have been registered yet."
|
|||||||
import_error = "Import Error"
|
import_error = "Import Error"
|
||||||
import_success = "Import Success"
|
import_success = "Import Success"
|
||||||
loading = "Loading..."
|
loading = "Loading..."
|
||||||
|
no_results = "No groups found."
|
||||||
subtitle = "Manage departments and teams under the current tenant."
|
subtitle = "Manage departments and teams under the current tenant."
|
||||||
|
|
||||||
[msg.admin.groups.members]
|
[msg.admin.groups.members]
|
||||||
|
add_modal_desc = "Search and select members to add from users in this tenant."
|
||||||
add_success = "Member added successfully."
|
add_success = "Member added successfully."
|
||||||
|
all_added = "All tenant members are already in this group."
|
||||||
count = "{{count}} members loaded."
|
count = "{{count}} members loaded."
|
||||||
empty = "No members are assigned to this organization unit."
|
empty = "No members are assigned to this organization unit."
|
||||||
|
move_modal_desc = "Select the target group to move the selected member."
|
||||||
|
move_success = "Member moved successfully."
|
||||||
remove_confirm = "Are you sure you want to remove this member?"
|
remove_confirm = "Are you sure you want to remove this member?"
|
||||||
remove_success = "Member removed successfully."
|
remove_success = "Member removed successfully."
|
||||||
title = "Member Management"
|
title = "Member Management"
|
||||||
@@ -229,6 +234,9 @@ subtitle = "Set the basic tenant profile information."
|
|||||||
desc = "View the list of users belonging to this organization."
|
desc = "View the list of users belonging to this organization."
|
||||||
empty = "No members found."
|
empty = "No members found."
|
||||||
limit_notice = "Showing members from the first 10 descendant organizations due to size limits."
|
limit_notice = "Showing members from the first 10 descendant organizations due to size limits."
|
||||||
|
remove_confirm = "Are you sure you want to exclude '{{name}}' from this organization?"
|
||||||
|
remove_error = "An error occurred while excluding from organization."
|
||||||
|
remove_success = "Successfully excluded from organization."
|
||||||
|
|
||||||
[msg.admin.tenants.owners]
|
[msg.admin.tenants.owners]
|
||||||
add_success = "Owner added successfully."
|
add_success = "Owner added successfully."
|
||||||
@@ -255,6 +263,7 @@ empty = "No child tenants are connected."
|
|||||||
subtitle = "Review and manage child tenants linked under this tenant."
|
subtitle = "Review and manage child tenants linked under this tenant."
|
||||||
|
|
||||||
[msg.admin.users]
|
[msg.admin.users]
|
||||||
|
confirm_remove_org = "Do you want to remove this user from the organization?"
|
||||||
export_error = "Failed to export users."
|
export_error = "Failed to export users."
|
||||||
status_error = "Failed to update user status."
|
status_error = "Failed to update user status."
|
||||||
|
|
||||||
@@ -1026,8 +1035,11 @@ unit_level_placeholder = "Unit Level Placeholder"
|
|||||||
title = "User Groups"
|
title = "User Groups"
|
||||||
|
|
||||||
[ui.admin.groups.members]
|
[ui.admin.groups.members]
|
||||||
|
add_modal_title = "Add Member to Group"
|
||||||
|
move_modal_title = "Move Department"
|
||||||
|
|
||||||
[ui.admin.groups.members.table]
|
[ui.admin.groups.members.table]
|
||||||
|
actions = "ACTIONS"
|
||||||
email = "Email"
|
email = "Email"
|
||||||
name = "Name"
|
name = "Name"
|
||||||
remove = "Remove"
|
remove = "Remove"
|
||||||
@@ -1100,6 +1112,10 @@ seed_badge = "Seed"
|
|||||||
title = "Tenant Registry"
|
title = "Tenant Registry"
|
||||||
view_org_chart = "View Full Org Chart"
|
view_org_chart = "View Full Org Chart"
|
||||||
|
|
||||||
|
[ui.admin.tenants.view]
|
||||||
|
hierarchy = "Hierarchy"
|
||||||
|
list = "List"
|
||||||
|
|
||||||
[ui.admin.tenants.domain_conflict]
|
[ui.admin.tenants.domain_conflict]
|
||||||
description = ""
|
description = ""
|
||||||
title = "Domain conflict"
|
title = "Domain conflict"
|
||||||
@@ -1217,13 +1233,17 @@ self_delete_blocked = "You cannot delete your own account."
|
|||||||
title = "API Key Registry"
|
title = "API Key Registry"
|
||||||
|
|
||||||
[ui.admin.tenants.members]
|
[ui.admin.tenants.members]
|
||||||
|
add_existing = "Assign Existing Member"
|
||||||
|
create_new = "Create New Member"
|
||||||
delete_selected = "Delete Selected"
|
delete_selected = "Delete Selected"
|
||||||
|
remove = "Exclude from Organization"
|
||||||
view_org_chart = "View Full Org Chart"
|
view_org_chart = "View Full Org Chart"
|
||||||
direct_label = "Direct"
|
direct_label = "Direct"
|
||||||
list_title = "Member Management"
|
list_title = "Member Management"
|
||||||
title = "Tenant Members ({{count}})"
|
title = "Tenant Members ({{count}})"
|
||||||
total = "Total"
|
total = "Total"
|
||||||
total_label = "Total"
|
total_label = "Total"
|
||||||
|
view_profile = "View Profile"
|
||||||
|
|
||||||
[ui.admin.tenants.import_preview]
|
[ui.admin.tenants.import_preview]
|
||||||
candidates = "Candidates"
|
candidates = "Candidates"
|
||||||
@@ -1235,6 +1255,7 @@ no_candidates = "No matching tenants found."
|
|||||||
title = "Import Preview"
|
title = "Import Preview"
|
||||||
|
|
||||||
[ui.admin.tenants.members.table]
|
[ui.admin.tenants.members.table]
|
||||||
|
actions = "ACTIONS"
|
||||||
email = "EMAIL"
|
email = "EMAIL"
|
||||||
name = "NAME"
|
name = "NAME"
|
||||||
role = "ROLE"
|
role = "ROLE"
|
||||||
|
|||||||
@@ -612,12 +612,17 @@ empty = "테넌트에 등록된 조직 단위가 없습니다."
|
|||||||
import_error = "가져오기 실패"
|
import_error = "가져오기 실패"
|
||||||
import_success = "조직도가 임포트되었습니다."
|
import_success = "조직도가 임포트되었습니다."
|
||||||
loading = "로딩 중..."
|
loading = "로딩 중..."
|
||||||
|
no_results = "그룹이 없습니다."
|
||||||
subtitle = "이 테넌트에 정의된 사용자 그룹 목록입니다."
|
subtitle = "이 테넌트에 정의된 사용자 그룹 목록입니다."
|
||||||
|
|
||||||
[msg.admin.groups.members]
|
[msg.admin.groups.members]
|
||||||
|
add_modal_desc = "이 테넌트에 속한 사용자 중 추가할 멤버를 검색하여 선택하세요."
|
||||||
add_success = "구성원이 추가되었습니다."
|
add_success = "구성원이 추가되었습니다."
|
||||||
|
all_added = "모든 테넌트 멤버가 이미 이 그룹에 속해 있습니다."
|
||||||
count = "{{count}} 명"
|
count = "{{count}} 명"
|
||||||
empty = "멤버가 없습니다."
|
empty = "멤버가 없습니다."
|
||||||
|
move_modal_desc = "선택한 멤버를 이동할 대상 그룹을 선택하세요."
|
||||||
|
move_success = "멤버가 이동되었습니다."
|
||||||
remove_confirm = "제거하시겠습니까?"
|
remove_confirm = "제거하시겠습니까?"
|
||||||
remove_success = "구성원이 제외되었습니다."
|
remove_success = "구성원이 제외되었습니다."
|
||||||
title = "[{{name}}] 멤버 관리"
|
title = "[{{name}}] 멤버 관리"
|
||||||
@@ -710,6 +715,9 @@ subtitle = "필수 정보만 입력해도 생성 가능합니다. Slug는 없으
|
|||||||
desc = "조직에 소속된 사용자 목록을 확인합니다."
|
desc = "조직에 소속된 사용자 목록을 확인합니다."
|
||||||
empty = "소속된 사용자가 없습니다."
|
empty = "소속된 사용자가 없습니다."
|
||||||
limit_notice = "하위 조직이 많아 상위 10개 조직의 멤버만 표시됩니다."
|
limit_notice = "하위 조직이 많아 상위 10개 조직의 멤버만 표시됩니다."
|
||||||
|
remove_confirm = "'{{name}}'님을 이 조직에서 제외하시겠습니까?"
|
||||||
|
remove_error = "조직에서 제외하는 중 오류가 발생했습니다."
|
||||||
|
remove_success = "조직에서 제외되었습니다."
|
||||||
|
|
||||||
[msg.admin.tenants.owners]
|
[msg.admin.tenants.owners]
|
||||||
add_success = "소유자가 추가되었습니다."
|
add_success = "소유자가 추가되었습니다."
|
||||||
@@ -736,6 +744,7 @@ empty = "하위 테넌트가 없습니다."
|
|||||||
subtitle = "현재 테넌트 하위에 생성된 조직입니다."
|
subtitle = "현재 테넌트 하위에 생성된 조직입니다."
|
||||||
|
|
||||||
[msg.admin.users]
|
[msg.admin.users]
|
||||||
|
confirm_remove_org = "이 조직에서 사용자를 제외하시겠습니까?"
|
||||||
export_error = "사용자 내보내기에 실패했습니다."
|
export_error = "사용자 내보내기에 실패했습니다."
|
||||||
status_error = "사용자 상태 변경에 실패했습니다."
|
status_error = "사용자 상태 변경에 실패했습니다."
|
||||||
|
|
||||||
@@ -1505,8 +1514,11 @@ unit_level_placeholder = "예: 본부, 팀"
|
|||||||
title = "User Groups"
|
title = "User Groups"
|
||||||
|
|
||||||
[ui.admin.groups.members]
|
[ui.admin.groups.members]
|
||||||
|
add_modal_title = "그룹에 멤버 추가"
|
||||||
|
move_modal_title = "부서 이동"
|
||||||
|
|
||||||
[ui.admin.groups.members.table]
|
[ui.admin.groups.members.table]
|
||||||
|
actions = "ACTIONS"
|
||||||
email = "이메일"
|
email = "이메일"
|
||||||
name = "이름"
|
name = "이름"
|
||||||
remove = "제거"
|
remove = "제거"
|
||||||
@@ -1575,6 +1587,10 @@ seed_badge = "초기 설정"
|
|||||||
title = "테넌트 목록"
|
title = "테넌트 목록"
|
||||||
view_org_chart = "전체 조직도 보기"
|
view_org_chart = "전체 조직도 보기"
|
||||||
|
|
||||||
|
[ui.admin.tenants.view]
|
||||||
|
hierarchy = "계층 구조"
|
||||||
|
list = "평면 목록"
|
||||||
|
|
||||||
[ui.admin.tenants.admins]
|
[ui.admin.tenants.admins]
|
||||||
add_button = "관리자 추가"
|
add_button = "관리자 추가"
|
||||||
already_admin = "이미 관리자"
|
already_admin = "이미 관리자"
|
||||||
@@ -1679,13 +1695,17 @@ status_error = "사용자 상태 변경에 실패했습니다: {{error}}"
|
|||||||
title = "API Key Registry"
|
title = "API Key Registry"
|
||||||
|
|
||||||
[ui.admin.tenants.members]
|
[ui.admin.tenants.members]
|
||||||
|
add_existing = "기존 멤버 배정"
|
||||||
|
create_new = "신규 멤버 생성"
|
||||||
delete_selected = "선택 삭제"
|
delete_selected = "선택 삭제"
|
||||||
|
remove = "조직에서 제외"
|
||||||
view_org_chart = "전체 조직도 보기"
|
view_org_chart = "전체 조직도 보기"
|
||||||
direct_label = "직속"
|
direct_label = "직속"
|
||||||
list_title = "구성원 관리"
|
list_title = "구성원 관리"
|
||||||
title = "테넌트 구성원 ({{count}})"
|
title = "테넌트 구성원 ({{count}})"
|
||||||
total = "전체"
|
total = "전체"
|
||||||
total_label = "전체"
|
total_label = "전체"
|
||||||
|
view_profile = "상세 정보"
|
||||||
|
|
||||||
[ui.admin.tenants.import_preview]
|
[ui.admin.tenants.import_preview]
|
||||||
candidates = "후보"
|
candidates = "후보"
|
||||||
@@ -1697,6 +1717,7 @@ no_candidates = "매칭 가능한 테넌트가 없습니다."
|
|||||||
title = "임포트 미리보기"
|
title = "임포트 미리보기"
|
||||||
|
|
||||||
[ui.admin.tenants.members.table]
|
[ui.admin.tenants.members.table]
|
||||||
|
actions = "ACTIONS"
|
||||||
email = "EMAIL"
|
email = "EMAIL"
|
||||||
name = "NAME"
|
name = "NAME"
|
||||||
role = "ROLE"
|
role = "ROLE"
|
||||||
|
|||||||
@@ -481,12 +481,17 @@ empty = ""
|
|||||||
import_error = ""
|
import_error = ""
|
||||||
import_success = ""
|
import_success = ""
|
||||||
loading = ""
|
loading = ""
|
||||||
|
no_results = ""
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
|
|
||||||
[msg.admin.groups.members]
|
[msg.admin.groups.members]
|
||||||
|
add_modal_desc = ""
|
||||||
add_success = ""
|
add_success = ""
|
||||||
|
all_added = ""
|
||||||
count = ""
|
count = ""
|
||||||
empty = ""
|
empty = ""
|
||||||
|
move_modal_desc = ""
|
||||||
|
move_success = ""
|
||||||
remove_confirm = ""
|
remove_confirm = ""
|
||||||
remove_success = ""
|
remove_success = ""
|
||||||
title = ""
|
title = ""
|
||||||
@@ -579,6 +584,9 @@ subtitle = ""
|
|||||||
desc = ""
|
desc = ""
|
||||||
empty = ""
|
empty = ""
|
||||||
limit_notice = ""
|
limit_notice = ""
|
||||||
|
remove_confirm = ""
|
||||||
|
remove_error = ""
|
||||||
|
remove_success = ""
|
||||||
|
|
||||||
[msg.admin.tenants.owners]
|
[msg.admin.tenants.owners]
|
||||||
add_success = ""
|
add_success = ""
|
||||||
@@ -605,6 +613,7 @@ empty = ""
|
|||||||
subtitle = ""
|
subtitle = ""
|
||||||
|
|
||||||
[msg.admin.users]
|
[msg.admin.users]
|
||||||
|
confirm_remove_org = ""
|
||||||
export_error = ""
|
export_error = ""
|
||||||
status_error = ""
|
status_error = ""
|
||||||
|
|
||||||
@@ -1374,8 +1383,11 @@ unit_level_placeholder = ""
|
|||||||
title = ""
|
title = ""
|
||||||
|
|
||||||
[ui.admin.groups.members]
|
[ui.admin.groups.members]
|
||||||
|
add_modal_title = ""
|
||||||
|
move_modal_title = ""
|
||||||
|
|
||||||
[ui.admin.groups.members.table]
|
[ui.admin.groups.members.table]
|
||||||
|
actions = ""
|
||||||
email = ""
|
email = ""
|
||||||
name = ""
|
name = ""
|
||||||
remove = ""
|
remove = ""
|
||||||
@@ -1444,6 +1456,10 @@ seed_badge = ""
|
|||||||
title = ""
|
title = ""
|
||||||
view_org_chart = ""
|
view_org_chart = ""
|
||||||
|
|
||||||
|
[ui.admin.tenants.view]
|
||||||
|
hierarchy = ""
|
||||||
|
list = ""
|
||||||
|
|
||||||
[ui.admin.tenants.admins]
|
[ui.admin.tenants.admins]
|
||||||
add_button = ""
|
add_button = ""
|
||||||
already_admin = ""
|
already_admin = ""
|
||||||
@@ -1521,13 +1537,17 @@ search_placeholder = ""
|
|||||||
select_placeholder = ""
|
select_placeholder = ""
|
||||||
|
|
||||||
[ui.admin.tenants.members]
|
[ui.admin.tenants.members]
|
||||||
|
add_existing = ""
|
||||||
|
create_new = ""
|
||||||
descendants = ""
|
descendants = ""
|
||||||
direct = ""
|
direct = ""
|
||||||
direct_label = ""
|
direct_label = ""
|
||||||
list_title = ""
|
list_title = ""
|
||||||
|
remove = ""
|
||||||
title = ""
|
title = ""
|
||||||
total = ""
|
total = ""
|
||||||
total_label = ""
|
total_label = ""
|
||||||
|
view_profile = ""
|
||||||
|
|
||||||
[msg.admin.apikeys.registry]
|
[msg.admin.apikeys.registry]
|
||||||
count = ""
|
count = ""
|
||||||
@@ -1554,15 +1574,20 @@ status_error = ""
|
|||||||
title = ""
|
title = ""
|
||||||
|
|
||||||
[ui.admin.tenants.members]
|
[ui.admin.tenants.members]
|
||||||
|
add_existing = ""
|
||||||
|
create_new = ""
|
||||||
delete_selected = ""
|
delete_selected = ""
|
||||||
|
remove = ""
|
||||||
view_org_chart = ""
|
view_org_chart = ""
|
||||||
direct_label = ""
|
direct_label = ""
|
||||||
list_title = ""
|
list_title = ""
|
||||||
title = ""
|
title = ""
|
||||||
total = ""
|
total = ""
|
||||||
total_label = ""
|
total_label = ""
|
||||||
|
view_profile = ""
|
||||||
|
|
||||||
[ui.admin.tenants.members.table]
|
[ui.admin.tenants.members.table]
|
||||||
|
actions = ""
|
||||||
email = ""
|
email = ""
|
||||||
name = ""
|
name = ""
|
||||||
role = ""
|
role = ""
|
||||||
|
|||||||
@@ -2,10 +2,36 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
job_name="${1:-adminfront-tests}"
|
job_name="${1:-adminfront-tests}"
|
||||||
|
repo_root="$(pwd)"
|
||||||
|
tmp_dir=""
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [ -n "${tmp_dir:-}" ] && [ -d "$tmp_dir" ]; then
|
||||||
|
rm -rf "$tmp_dir"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
mkdir -p reports
|
mkdir -p reports
|
||||||
rm -rf adminfront/node_modules
|
rm -rf adminfront/node_modules
|
||||||
|
|
||||||
|
tmp_dir="$(mktemp -d /tmp/baron-sso-adminfront-tests.XXXXXX)"
|
||||||
|
playwright_browsers_path="$tmp_dir/ms-playwright"
|
||||||
|
|
||||||
|
if command -v rsync >/dev/null 2>&1; then
|
||||||
|
rsync -rlptD --delete \
|
||||||
|
--exclude 'node_modules' \
|
||||||
|
--exclude 'playwright-report' \
|
||||||
|
--exclude 'test-results' \
|
||||||
|
"$repo_root/adminfront/" "$tmp_dir/adminfront/"
|
||||||
|
else
|
||||||
|
cp -R "$repo_root/adminfront" "$tmp_dir/adminfront"
|
||||||
|
rm -rf "$tmp_dir/adminfront/node_modules" \
|
||||||
|
"$tmp_dir/adminfront/playwright-report" \
|
||||||
|
"$tmp_dir/adminfront/test-results"
|
||||||
|
fi
|
||||||
|
|
||||||
is_port_available() {
|
is_port_available() {
|
||||||
local port="$1"
|
local port="$1"
|
||||||
node -e '
|
node -e '
|
||||||
@@ -43,7 +69,7 @@ fi
|
|||||||
|
|
||||||
set +e
|
set +e
|
||||||
(
|
(
|
||||||
cd adminfront
|
cd "$tmp_dir/adminfront"
|
||||||
npm ci --ignore-scripts
|
npm ci --ignore-scripts
|
||||||
) 2>&1 | tee reports/adminfront-install.log
|
) 2>&1 | tee reports/adminfront-install.log
|
||||||
install_exit_code=${PIPESTATUS[0]}
|
install_exit_code=${PIPESTATUS[0]}
|
||||||
@@ -71,8 +97,8 @@ fi
|
|||||||
|
|
||||||
set +e
|
set +e
|
||||||
(
|
(
|
||||||
cd adminfront
|
cd "$tmp_dir/adminfront"
|
||||||
"${playwright_install_cmd[@]}"
|
PLAYWRIGHT_BROWSERS_PATH="$playwright_browsers_path" "${playwright_install_cmd[@]}"
|
||||||
) 2>&1 | tee reports/adminfront-provision.log
|
) 2>&1 | tee reports/adminfront-provision.log
|
||||||
provision_exit_code=${PIPESTATUS[0]}
|
provision_exit_code=${PIPESTATUS[0]}
|
||||||
set -e
|
set -e
|
||||||
@@ -106,13 +132,16 @@ if ! is_port_available "$port"; then
|
|||||||
fi
|
fi
|
||||||
echo "==> adminfront using PORT=$port"
|
echo "==> adminfront using PORT=$port"
|
||||||
(
|
(
|
||||||
cd adminfront
|
cd "$tmp_dir/adminfront"
|
||||||
PORT="$port" PLAYWRIGHT_WORKERS="${PLAYWRIGHT_WORKERS:-1}" \
|
PORT="$port" PLAYWRIGHT_WORKERS="${PLAYWRIGHT_WORKERS:-1}" PLAYWRIGHT_BROWSERS_PATH="$playwright_browsers_path" \
|
||||||
node ./node_modules/playwright/cli.js test
|
node ./node_modules/playwright/cli.js test
|
||||||
) 2>&1 | tee reports/adminfront-test.log
|
) 2>&1 | tee reports/adminfront-test.log
|
||||||
test_exit_code=${PIPESTATUS[0]}
|
test_exit_code=${PIPESTATUS[0]}
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
[ -d "$tmp_dir/adminfront/playwright-report" ] && rm -rf reports/adminfront-playwright-report && cp -R "$tmp_dir/adminfront/playwright-report" reports/adminfront-playwright-report || true
|
||||||
|
[ -d "$tmp_dir/adminfront/test-results" ] && rm -rf reports/adminfront-test-results && cp -R "$tmp_dir/adminfront/test-results" reports/adminfront-test-results || true
|
||||||
|
|
||||||
if [ "$test_exit_code" -ne 0 ]; then
|
if [ "$test_exit_code" -ne 0 ]; then
|
||||||
{
|
{
|
||||||
echo "# Adminfront Test Failure Report"
|
echo "# Adminfront Test Failure Report"
|
||||||
|
|||||||
Reference in New Issue
Block a user