린트 적용

This commit is contained in:
chan committed 2026-02-23 16:18:01 +09:00
1 parent 04938d7cd9
commit 68becb43bc
36 files changed
+1240 -1057

No files matched your search

@@ -100,7 +100,12 @@ const RoleSwitcher: FC = () => {
}}
>
{(
["super_admin", "tenant_admin", "rp_admin", "tenant_member"] as const
[
"super_admin",
"tenant_admin",
"rp_admin",
"tenant_member",
] as const
).map((role) => (
<button
key={role}
@@ -120,8 +125,12 @@ const RoleSwitcher: FC = () => {
alignItems: "center",
}}
>
<span>{roleLabels[role] ?? role.toUpperCase().replace("_", " ")}</span>
{currentRole === role && <span style={{ marginLeft: "8px" }}>✅</span>}
<span>
{roleLabels[role] ?? role.toUpperCase().replace("_", " ")}
</span>
{currentRole === role && (
<span style={{ marginLeft: "8px" }}>✅</span>
)}
</button>
))}
</div>
+2 -1
View File
@@ -26,7 +26,8 @@ const badgeVariants = cva(
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
extends
React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
+4 -2
View File
@@ -6,7 +6,9 @@ import { Button } from "./button";
describe("Button Component", () => {
it("renders correctly with children", () => {
render(<Button>Click me</Button>);
expect(screen.getByRole("button", { name: /click me/i })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /click me/i }),
).toBeInTheDocument();
});
it("applies variant classes correctly", () => {
@@ -23,7 +25,7 @@ describe("Button Component", () => {
const onClick = vi.fn();
const user = userEvent.setup();
render(<Button onClick={onClick}>Click me</Button>);
await user.click(screen.getByRole("button", { name: /click me/i }));
expect(onClick).toHaveBeenCalledTimes(1);
});
+2 -1
View File
@@ -34,7 +34,8 @@ const buttonVariants = cva(
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
+1 -2
View File
@@ -1,8 +1,7 @@
import * as React from "react";
import { cn } from "../../lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
+1 -2
View File
@@ -1,8 +1,7 @@
import * as React from "react";
import { cn } from "../../lib/utils";
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
@@ -1,6 +1,13 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Plus, Search, ShieldCheck, Trash2, UserPlus, Users } from "lucide-react";
import {
Plus,
Search,
ShieldCheck,
Trash2,
UserPlus,
Users,
} from "lucide-react";
import { useState } from "react";
import { useParams } from "react-router-dom";
import { toast } from "sonner";
@@ -64,11 +71,16 @@ export function TenantAdminsTab() {
mutationFn: (userId: string) => addTenantAdmin(tenantId, userId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenant-admins", tenantId] });
toast.success(t("msg.admin.tenants.admins.add_success", "관리자가 추가되었습니다."));
toast.success(
t("msg.admin.tenants.admins.add_success", "관리자가 추가되었습니다."),
);
setSearchTerm("");
},
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."));
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
});
@@ -76,10 +88,15 @@ export function TenantAdminsTab() {
mutationFn: (userId: string) => removeTenantAdmin(tenantId, userId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenant-admins", tenantId] });
toast.success(t("msg.admin.tenants.admins.remove_success", "권한이 회수되었습니다."));
toast.success(
t("msg.admin.tenants.admins.remove_success", "권한이 회수되었습니다."),
);
},
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."));
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
});
@@ -88,7 +105,15 @@ export function TenantAdminsTab() {
};
const handleRemoveAdmin = (userId: string, userName: string) => {
if (window.confirm(t("msg.admin.tenants.admins.remove_confirm", "관리자를 삭제하시겠습니까?", { name: userName }))) {
if (
window.confirm(
t(
"msg.admin.tenants.admins.remove_confirm",
"관리자를 삭제하시겠습니까?",
{ name: userName },
),
)
) {
removeMutation.mutate(userId);
}
};
@@ -106,14 +131,20 @@ export function TenantAdminsTab() {
{t("ui.admin.tenants.admins.title", "테넌트 관리자")}
</CardTitle>
<CardDescription className="text-muted-foreground">
{t("msg.admin.tenants.admins.subtitle", "이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다.")}
{t(
"msg.admin.tenants.admins.subtitle",
"이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다.",
)}
</CardDescription>
</div>
<Dialog open={isDialogOpen} onOpenChange={(open) => {
setIsAddDialogOpen(open);
if (!open) setSearchTerm("");
}}>
<Dialog
open={isDialogOpen}
onOpenChange={(open) => {
setIsAddDialogOpen(open);
if (!open) setSearchTerm("");
}}
>
<DialogTrigger asChild>
<Button className="bg-primary text-primary-foreground hover:bg-primary/90">
<UserPlus className="mr-2 h-4 w-4" />
@@ -126,7 +157,10 @@ export function TenantAdminsTab() {
{t("ui.admin.tenants.admins.dialog_title", "새 관리자 추가")}
</DialogTitle>
<DialogDescription>
{t("ui.admin.tenants.admins.dialog_description", "이름 또는 이메일로 사용자를 검색하세요.")}
{t(
"ui.admin.tenants.admins.dialog_description",
"이름 또는 이메일로 사용자를 검색하세요.",
)}
</DialogDescription>
</DialogHeader>
@@ -134,7 +168,10 @@ export function TenantAdminsTab() {
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t("ui.admin.tenants.admins.dialog_search_placeholder", "사용자 검색 (최소 2자)...")}
placeholder={t(
"ui.admin.tenants.admins.dialog_search_placeholder",
"사용자 검색 (최소 2자)...",
)}
className="pl-10 h-11"
autoFocus
value={searchTerm}
@@ -146,7 +183,12 @@ export function TenantAdminsTab() {
{searchTerm.length < 2 ? (
<div className="p-10 text-center text-muted-foreground flex flex-col items-center gap-2">
<Search className="h-8 w-8 opacity-20" />
<p className="text-sm">{t("ui.admin.tenants.admins.dialog_search_hint", "검색어를 입력해 주세요.")}</p>
<p className="text-sm">
{t(
"ui.admin.tenants.admins.dialog_search_hint",
"검색어를 입력해 주세요.",
)}
</p>
</div>
) : usersQuery.isLoading ? (
<div className="p-10 text-center">
@@ -154,21 +196,33 @@ export function TenantAdminsTab() {
</div>
) : searchResults.length === 0 ? (
<div className="p-10 text-center text-muted-foreground">
{t("ui.admin.tenants.admins.dialog_no_results", "검색 결과가 없습니다.")}
{t(
"ui.admin.tenants.admins.dialog_no_results",
"검색 결과가 없습니다.",
)}
</div>
) : (
<div className="divide-y divide-border">
{searchResults.map((user) => {
const isAlreadyAdmin = currentAdmins.some((a) => a.id === user.id);
const isAlreadyAdmin = currentAdmins.some(
(a) => a.id === user.id,
);
return (
<div key={user.id} className="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors">
<div
key={user.id}
className="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
{user.name.charAt(0)}
</div>
<div className="flex flex-col">
<span className="text-sm font-medium">{user.name}</span>
<span className="text-xs text-muted-foreground">{user.email}</span>
<span className="text-sm font-medium">
{user.name}
</span>
<span className="text-xs text-muted-foreground">
{user.email}
</span>
</div>
</div>
<Button
@@ -178,9 +232,20 @@ export function TenantAdminsTab() {
onClick={() => handleAddAdmin(user.id)}
>
{isAlreadyAdmin ? (
<Badge variant="secondary" className="font-normal">{t("ui.admin.tenants.admins.already_admin", "이미 관리자")}</Badge>
<Badge
variant="secondary"
className="font-normal"
>
{t(
"ui.admin.tenants.admins.already_admin",
"이미 관리자",
)}
</Badge>
) : (
<><Plus className="h-3 w-3 mr-1" /> {t("ui.common.add", "추가")}</>
<>
<Plus className="h-3 w-3 mr-1" />{" "}
{t("ui.common.add", "추가")}
</>
)}
</Button>
</div>
@@ -219,16 +284,27 @@ export function TenantAdminsTab() {
</TableRow>
) : currentAdmins.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="h-32 text-center text-muted-foreground">
<TableCell
colSpan={3}
className="h-32 text-center text-muted-foreground"
>
<div className="flex flex-col items-center gap-2">
<Users className="h-8 w-8 opacity-20" />
<p>{t("msg.admin.tenants.admins.empty", "등록된 관리자가 없습니다.")}</p>
<p>
{t(
"msg.admin.tenants.admins.empty",
"등록된 관리자가 없습니다.",
)}
</p>
</div>
</TableCell>
</TableRow>
) : (
currentAdmins.map((admin) => (
<TableRow key={admin.id} className="hover:bg-muted/30 transition-colors group">
<TableRow
key={admin.id}
className="hover:bg-muted/30 transition-colors group"
>
<TableCell className="font-medium">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-lg bg-secondary flex items-center justify-center text-secondary-foreground font-bold text-xs">
@@ -245,9 +321,14 @@ export function TenantAdminsTab() {
variant="ghost"
size="icon"
className="opacity-0 group-hover:opacity-100 text-destructive hover:text-destructive hover:bg-destructive/10 transition-all"
onClick={() => handleRemoveAdmin(admin.id, admin.name)}
onClick={() =>
handleRemoveAdmin(admin.id, admin.name)
}
disabled={removeMutation.isPending}
title={t("ui.admin.tenants.admins.remove_title", "관리자 권한 회수")}
title={t(
"ui.admin.tenants.admins.remove_title",
"관리자 권한 회수",
)}
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -117,10 +117,27 @@ function TenantCreatePage() {
value={type}
onChange={(e) => setType(e.target.value)}
>
<option value="COMPANY">{t("domain.tenant_type.company", "COMPANY (일반 기업)")}</option>
<option value="COMPANY_GROUP">{t("domain.tenant_type.company_group", "COMPANY_GROUP (그룹사/지주사)")}</option>
<option value="USER_GROUP">{t("domain.tenant_type.user_group", "USER_GROUP (내부 부서/팀)")}</option>
<option value="PERSONAL">{t("domain.tenant_type.personal", "PERSONAL (개인 워크스페이스)")}</option>
<option value="COMPANY">
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
</option>
<option value="COMPANY_GROUP">
{t(
"domain.tenant_type.company_group",
"COMPANY_GROUP (그룹사/지주사)",
)}
</option>
<option value="USER_GROUP">
{t(
"domain.tenant_type.user_group",
"USER_GROUP (내부 부서/팀)",
)}
</option>
<option value="PERSONAL">
{t(
"domain.tenant_type.personal",
"PERSONAL (개인 워크스페이스)",
)}
</option>
</select>
</div>
<div className="space-y-2">
@@ -25,21 +25,32 @@ function TenantDetailPage() {
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<Link to="/tenants" className="inline-flex items-center gap-2 hover:text-foreground transition-colors">
<Link
to="/tenants"
className="inline-flex items-center gap-2 hover:text-foreground transition-colors"
>
<ArrowLeft size={14} />
{t("ui.admin.tenants.detail.breadcrumb_list", "테넌트 목록")}
</Link>
<span>/</span>
<span className="text-foreground">{t("ui.admin.tenants.detail.title", "상세")}</span>
<span className="text-foreground">
{t("ui.admin.tenants.detail.title", "상세")}
</span>
</div>
<h2 className="text-3xl font-semibold">
{tenantQuery.data?.name ?? t("ui.admin.tenants.detail.loading", "불러오는 중...")}
{tenantQuery.data?.name ??
t("ui.admin.tenants.detail.loading", "불러오는 중...")}
</h2>
<p className="text-sm text-[var(--color-muted)]">
{t("ui.admin.tenants.detail.header_subtitle", "테넌트 정보를 수정하거나 연동 설정을 관리합니다.")}
{t(
"ui.admin.tenants.detail.header_subtitle",
"테넌트 정보를 수정하거나 연동 설정을 관리합니다.",
)}
</p>
</div>
<Badge variant="muted">{t("ui.common.admin_only", "관리자 전용")}</Badge>
<Badge variant="muted">
{t("ui.common.admin_only", "관리자 전용")}
</Badge>
</header>
{/* Tabs */}
@@ -43,9 +43,15 @@ import {
import { t } from "../../../lib/i18n";
import { toast } from "sonner";
type UserGroupNode = GroupSummary & { children: UserGroupNode[]; isExpanded?: boolean };
type UserGroupNode = GroupSummary & {
children: UserGroupNode[];
isExpanded?: boolean;
};
function buildGroupTree(groups: GroupSummary[], parentId: string | null = null): UserGroupNode[] {
function buildGroupTree(
groups: GroupSummary[],
parentId: string | null = null,
): UserGroupNode[] {
const nodes: UserGroupNode[] = [];
const childrenOf = new Map<string, UserGroupNode[]>();
@@ -56,7 +62,10 @@ function buildGroupTree(groups: GroupSummary[], parentId: string | null = null):
// Second pass: Populate children
groups.forEach((group) => {
const node: UserGroupNode = { ...group, children: childrenOf.get(group.id)! };
const node: UserGroupNode = {
...group,
children: childrenOf.get(group.id)!,
};
if (group.parentId === parentId) {
nodes.push(node);
} else {
@@ -73,7 +82,7 @@ function buildGroupTree(groups: GroupSummary[], parentId: string | null = null):
// Sort children for consistent rendering (optional, but good for UI)
nodes.sort((a, b) => a.name.localeCompare(b.name));
nodes.forEach(node => {
nodes.forEach((node) => {
node.children.sort((a, b) => a.name.localeCompare(b.name));
});
@@ -130,11 +139,16 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
<ChevronRight size={16} />
)}
</Button>
) : (level > 0 && (
) : (
level > 0 && (
<span className="inline-block w-6 text-center">
<ChevronRight size={16} className="text-muted-foreground inline-block align-middle" />
<ChevronRight
size={16}
className="text-muted-foreground inline-block align-middle"
/>
</span>
))}
)
)}
<Users size={14} className="text-muted-foreground" />
<span className="font-semibold">{node.name}</span>
<Badge variant="secondary" className="text-[10px] font-mono">
@@ -221,7 +235,12 @@ function TenantGroupsPage() {
parentId: newGroupParentId || undefined,
}),
onSuccess: () => {
toast.success(t("msg.admin.groups.list.create_success", "그룹이 성공적으로 생성되었습니다."));
toast.success(
t(
"msg.admin.groups.list.create_success",
"그룹이 성공적으로 생성되었습니다.",
),
);
groupsQuery.refetch();
setNewGroupName("");
setNewGroupNameDesc("");
@@ -229,21 +248,27 @@ function TenantGroupsPage() {
setNewGroupParentId(null);
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.admin.groups.list.create_error", "그룹 생성 실패"), { description: error.response?.data?.error || error.message });
}
toast.error(t("msg.admin.groups.list.create_error", "그룹 생성 실패"), {
description: error.response?.data?.error || error.message,
});
},
});
// 그룹 삭제
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteGroup(tenantId, id),
onSuccess: () => {
toast.success(t("msg.admin.groups.list.delete_success", "그룹이 삭제되었습니다."));
toast.success(
t("msg.admin.groups.list.delete_success", "그룹이 삭제되었습니다."),
);
groupsQuery.refetch();
setSelectedGroupId(null);
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.admin.groups.list.delete_error", "그룹 삭제 실패"), { description: error.response?.data?.error || error.message });
}
toast.error(t("msg.admin.groups.list.delete_error", "그룹 삭제 실패"), {
description: error.response?.data?.error || error.message,
});
},
});
// 멤버 추가
@@ -251,12 +276,16 @@ function TenantGroupsPage() {
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
addGroupMember(tenantId, groupId, userId),
onSuccess: () => {
toast.success(t("msg.admin.groups.members.add_success", "멤버가 추가되었습니다."));
toast.success(
t("msg.admin.groups.members.add_success", "멤버가 추가되었습니다."),
);
groupsQuery.refetch();
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.common.error", "오류 발생"), { description: error.response?.data?.error || error.message });
}
toast.error(t("msg.common.error", "오류 발생"), {
description: error.response?.data?.error || error.message,
});
},
});
// 멤버 제거
@@ -264,15 +293,21 @@ function TenantGroupsPage() {
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
removeGroupMember(tenantId, groupId, userId),
onSuccess: () => {
toast.success(t("msg.admin.groups.members.remove_success", "멤버가 제거되었습니다."));
toast.success(
t("msg.admin.groups.members.remove_success", "멤버가 제거되었습니다."),
);
groupsQuery.refetch();
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.common.error", "오류 발생"), { description: error.response?.data?.error || error.message });
}
toast.error(t("msg.common.error", "오류 발생"), {
description: error.response?.data?.error || error.message,
});
},
});
const groupTree = groupsQuery.data ? buildGroupTree(groupsQuery.data, tenantId) : [];
const groupTree = groupsQuery.data
? buildGroupTree(groupsQuery.data, tenantId)
: [];
const handleAddSubGroup = (parentId: string) => {
setNewGroupParentId(parentId);
@@ -304,7 +339,10 @@ function TenantGroupsPage() {
{t("ui.admin.groups.create.title", "새 그룹 생성")}
</CardTitle>
<CardDescription>
{t("ui.admin.groups.create.description", "새로운 사용자 그룹을 생성하고 계층 구조를 설정합니다.")}
{t(
"ui.admin.groups.create.description",
"새로운 사용자 그룹을 생성하고 계층 구조를 설정합니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -425,8 +463,14 @@ function TenantGroupsPage() {
)}
{!groupsQuery.isLoading && groupTree.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="text-center py-8 text-muted-foreground">
{t("msg.admin.groups.list.empty", "아직 등록된 그룹이 없습니다.")}
<TableCell
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
{t(
"msg.admin.groups.list.empty",
"아직 등록된 그룹이 없습니다.",
)}
</TableCell>
</TableRow>
)}
@@ -438,9 +482,16 @@ function TenantGroupsPage() {
onSelect={setSelectedGroupId}
selectedGroupId={selectedGroupId}
onDelete={(id) => {
if (window.confirm(t("msg.admin.groups.list.delete_confirm", "그룹을 삭제하시겠습니까?"))) {
deleteMutation.mutate(id);
}
if (
window.confirm(
t(
"msg.admin.groups.list.delete_confirm",
"그룹을 삭제하시겠습니까?",
),
)
) {
deleteMutation.mutate(id);
}
}}
onAddSubGroup={handleAddSubGroup}
addMemberMutation={addMemberMutation}
@@ -464,15 +515,22 @@ function TenantGroupsPage() {
})}
</CardTitle>
<CardDescription>
{t("ui.admin.groups.detail.members_subtitle", "그룹에 속한 멤버들을 확인하고 관리합니다.")}
{t(
"ui.admin.groups.detail.members_subtitle",
"그룹에 속한 멤버들을 확인하고 관리합니다.",
)}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex justify-end mb-4">
<Button size="sm" onClick={() => handleAddMember(currentGroup.id)} disabled={addMemberMutation.isPending}>
<UserPlus size={14} className="mr-1" />
{t("ui.common.add", "멤버 추가")}
</Button>
<Button
size="sm"
onClick={() => handleAddMember(currentGroup.id)}
disabled={addMemberMutation.isPending}
>
<UserPlus size={14} className="mr-1" />
{t("ui.common.add", "멤버 추가")}
</Button>
</div>
<Table>
<TableHeader>
@@ -1,12 +1,6 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
CornerDownRight,
Pencil,
Plus,
RefreshCw,
Trash2,
} from "lucide-react";
import { CornerDownRight, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import React from "react";
import { Link, useNavigate } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
@@ -72,7 +66,9 @@ const TenantRow: React.FC<{
<TableRow key={tenant.id}>
<TableCell style={{ paddingLeft: `${1 + level * 1.5}rem` }}>
<div className="flex items-center gap-2">
{level > 0 && <CornerDownRight size={14} className="text-muted-foreground" />}
{level > 0 && (
<CornerDownRight size={14} className="text-muted-foreground" />
)}
<span className="font-semibold">{tenant.name}</span>
</div>
</TableCell>
@@ -88,8 +84,8 @@ const TenantRow: React.FC<{
tenant.status === "active"
? "default"
: tenant.status === "pending"
? "secondary"
: "muted"
? "secondary"
: "muted"
}
>
{t(`ui.common.status.${tenant.status}`, tenant.status)}
@@ -267,7 +263,10 @@ function TenantListPage() {
)}
{!query.isLoading && tenantTree.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
<TableCell
colSpan={6}
className="text-center py-8 text-muted-foreground"
>
{t(
"msg.admin.tenants.empty",
"아직 등록된 테넌트가 없습니다.",
@@ -293,4 +292,3 @@ function TenantListPage() {
}
export default TenantListPage;
@@ -29,7 +29,9 @@ export function TenantProfilePage() {
const queryClient = useQueryClient();
if (!tenantId) {
return <div>{t("msg.admin.tenants.missing_id", "테넌트 ID가 없습니다.")}</div>;
return (
<div>{t("msg.admin.tenants.missing_id", "테넌트 ID가 없습니다.")}</div>
);
}
const tenantQuery = useQuery({
@@ -74,8 +76,11 @@ export function TenantProfilePage() {
toast.success(t("msg.info.saved_success", "저장되었습니다."));
},
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(err.response?.data?.error || t("err.common.unknown", "오류가 발생했습니다."));
}
toast.error(
err.response?.data?.error ||
t("err.common.unknown", "오류가 발생했습니다."),
);
},
});
const approveMutation = useMutation({
@@ -83,18 +88,25 @@ export function TenantProfilePage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenants"] });
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
toast.success(t("msg.admin.tenants.approve_success", "테넌트가 승인되었습니다."));
toast.success(
t("msg.admin.tenants.approve_success", "테넌트가 승인되었습니다."),
);
},
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(err.response?.data?.error || t("err.common.unknown", "오류가 발생했습니다."));
}
toast.error(
err.response?.data?.error ||
t("err.common.unknown", "오류가 발생했습니다."),
);
},
});
const deleteMutation = useMutation({
mutationFn: () => deleteTenant(tenantId),
onSuccess: () => {
navigate("/tenants");
toast.success(t("msg.admin.tenants.delete_success", "테넌트가 삭제되었습니다."));
toast.success(
t("msg.admin.tenants.delete_success", "테넌트가 삭제되었습니다."),
);
},
});
@@ -104,13 +116,23 @@ export function TenantProfilePage() {
?.response?.data?.error;
const handleDelete = () => {
if (window.confirm(t("msg.admin.tenants.delete_confirm", "삭제하시겠습니까?", { name: tenantQuery.data?.name ?? "" }))) {
if (
window.confirm(
t("msg.admin.tenants.delete_confirm", "삭제하시겠습니까?", {
name: tenantQuery.data?.name ?? "",
}),
)
) {
deleteMutation.mutate();
}
};
const handleApprove = () => {
if (window.confirm(t("msg.admin.tenants.approve_confirm", "이 테넌트를 승인하시겠습니까?"))) {
if (
window.confirm(
t("msg.admin.tenants.approve_confirm", "이 테넌트를 승인하시겠습니까?"),
)
) {
approveMutation.mutate();
}
};
@@ -119,9 +141,14 @@ export function TenantProfilePage() {
<>
<Card className="bg-[var(--color-panel)] mt-6">
<CardHeader>
<CardTitle>{t("ui.admin.tenants.profile.title", "테넌트 프로필")}</CardTitle>
<CardTitle>
{t("ui.admin.tenants.profile.title", "테넌트 프로필")}
</CardTitle>
<CardDescription>
{t("ui.admin.tenants.profile.subtitle", "슬러그 및 상태 변경은 즉시 적용됩니다.")}
{t(
"ui.admin.tenants.profile.subtitle",
"슬러그 및 상태 변경은 즉시 적용됩니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -132,30 +159,54 @@ export function TenantProfilePage() {
)}
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.name", "테넌트 이름")} <span className="text-destructive">*</span>
{t("ui.admin.tenants.profile.name", "테넌트 이름")}{" "}
<span className="text-destructive">*</span>
</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">{t("ui.admin.tenants.profile.type", "테넌트 유형")}</Label>
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.type", "테넌트 유형")}
</Label>
<select
id="type"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={type}
onChange={(e) => setType(e.target.value)}
>
<option value="COMPANY">{t("domain.tenant_type.company", "COMPANY (일반 기업)")}</option>
<option value="COMPANY_GROUP">{t("domain.tenant_type.company_group", "COMPANY_GROUP (그룹사/지주사)")}</option>
<option value="USER_GROUP">{t("domain.tenant_type.user_group", "USER_GROUP (내부 부서/팀)")}</option>
<option value="PERSONAL">{t("domain.tenant_type.personal", "PERSONAL (개인 워크스페이스)")}</option>
<option value="COMPANY">
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
</option>
<option value="COMPANY_GROUP">
{t(
"domain.tenant_type.company_group",
"COMPANY_GROUP (그룹사/지주사)",
)}
</option>
<option value="USER_GROUP">
{t(
"domain.tenant_type.user_group",
"USER_GROUP (내부 부서/팀)",
)}
</option>
<option value="PERSONAL">
{t(
"domain.tenant_type.personal",
"PERSONAL (개인 워크스페이스)",
)}
</option>
</select>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}</Label>
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
</Label>
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">{t("ui.admin.tenants.profile.description", "설명")}</Label>
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.description", "설명")}
</Label>
<Textarea
rows={3}
value={description}
@@ -164,7 +215,10 @@ export function TenantProfilePage() {
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.allowed_domains", "허용된 도메인 (콤마로 구분)")}
{t(
"ui.admin.tenants.profile.allowed_domains",
"허용된 도메인 (콤마로 구분)",
)}
</Label>
<Input
value={domains}
@@ -172,11 +226,16 @@ export function TenantProfilePage() {
placeholder="example.com, example.kr"
/>
<p className="text-xs text-muted-foreground">
{t("ui.admin.tenants.profile.allowed_domains_help", "이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.")}
{t(
"ui.admin.tenants.profile.allowed_domains_help",
"이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.",
)}
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">{t("ui.admin.tenants.profile.status", "상태")}</Label>
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.status", "상태")}
</Label>
<div className="flex gap-3">
<Button
type="button"
@@ -81,10 +81,18 @@ export function TenantSchemaPage() {
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
toast.success(t("msg.admin.tenants.schema.update_success", "스키마가 저장되었습니다."));
toast.success(
t(
"msg.admin.tenants.schema.update_success",
"스키마가 저장되었습니다.",
),
);
},
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(err.response?.data?.error || t("msg.admin.tenants.schema.update_error", "저장에 실패했습니다."));
toast.error(
err.response?.data?.error ||
t("msg.admin.tenants.schema.update_error", "저장에 실패했습니다."),
);
},
});
@@ -196,13 +204,22 @@ export function TenantSchemaPage() {
}}
>
<option value="text">
{t("ui.admin.tenants.schema.field.type_text", "텍스트 (Text)")}
{t(
"ui.admin.tenants.schema.field.type_text",
"텍스트 (Text)",
)}
</option>
<option value="number">
{t("ui.admin.tenants.schema.field.type_number", "숫자 (Number)")}
{t(
"ui.admin.tenants.schema.field.type_number",
"숫자 (Number)",
)}
</option>
<option value="boolean">
{t("ui.admin.tenants.schema.field.type_boolean", "불리언 (Boolean)")}
{t(
"ui.admin.tenants.schema.field.type_boolean",
"불리언 (Boolean)",
)}
</option>
</select>
</div>
@@ -46,31 +46,31 @@ import { t } from "../../../lib/i18n";
type UserGroupNode = GroupSummary & { children: UserGroupNode[] };
function buildGroupTree(groups: GroupSummary[]): UserGroupNode[] {
const nodeMap = new Map<string, UserGroupNode>();
const rootNodes: UserGroupNode[] = [];
groups.forEach((group) => {
nodeMap.set(group.id, { ...group, children: [] });
});
groups.forEach((group) => {
const node = nodeMap.get(group.id)!;
if (group.parentId && nodeMap.has(group.parentId)) {
const parent = nodeMap.get(group.parentId)!;
parent.children.push(node);
} else {
rootNodes.push(node);
}
});
const nodeMap = new Map<string, UserGroupNode>();
const rootNodes: UserGroupNode[] = [];
const sortNodes = (nodes: UserGroupNode[]) => {
nodes.sort((a, b) => a.name.localeCompare(b.name));
nodes.forEach(node => sortNodes(node.children));
};
sortNodes(rootNodes);
groups.forEach((group) => {
nodeMap.set(group.id, { ...group, children: [] });
});
return rootNodes;
}
groups.forEach((group) => {
const node = nodeMap.get(group.id)!;
if (group.parentId && nodeMap.has(group.parentId)) {
const parent = nodeMap.get(group.parentId)!;
parent.children.push(node);
} else {
rootNodes.push(node);
}
});
const sortNodes = (nodes: UserGroupNode[]) => {
nodes.sort((a, b) => a.name.localeCompare(b.name));
nodes.forEach((node) => sortNodes(node.children));
};
sortNodes(rootNodes);
return rootNodes;
}
interface UserGroupTreeNodeProps {
node: UserGroupNode;
@@ -113,7 +113,11 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
setIsExpanded(!isExpanded);
}}
>
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
{isExpanded ? (
<ChevronDown size={16} />
) : (
<ChevronRight size={16} />
)}
</Button>
)}
{!hasChildren && <div className="w-6" />}
@@ -125,7 +129,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
</div>
</TableCell>
<TableCell className="text-center">
<Badge variant="secondary">{node.members?.length || 0}</Badge>
<Badge variant="secondary">{node.members?.length || 0}</Badge>
</TableCell>
<TableCell className="text-right">
<Button
@@ -151,237 +155,376 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
</Button>
</TableCell>
</TableRow>
{isExpanded && hasChildren && node.children.map((child) => (
<UserGroupTreeNode
key={child.id}
node={child}
level={level + 1}
onSelect={onSelect}
selectedGroupId={selectedGroupId}
onDelete={onDelete}
onAddSubGroup={onAddSubGroup}
/>
))}
{isExpanded &&
hasChildren &&
node.children.map((child) => (
<UserGroupTreeNode
key={child.id}
node={child}
level={level + 1}
onSelect={onSelect}
selectedGroupId={selectedGroupId}
onDelete={onDelete}
onAddSubGroup={onAddSubGroup}
/>
))}
</>
);
};
export function TenantUserGroupsTab() {
const params = useParams<{ tenantId: string }>();
const tenantId = params.tenantId ?? "";
const [newGroupName, setNewGroupName] = useState("");
const [newGroupDesc, setNewGroupNameDesc] = useState("");
const [newGroupUnitType, setNewGroupUnitType] = useState("Team");
const [newGroupParentId, setNewGroupParentId] = useState<string | null>(null);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const groupsQuery = useQuery({
queryKey: ["groups", tenantId],
queryFn: () => fetchGroups(tenantId),
enabled: !!tenantId,
});
const createMutation = useMutation({
mutationFn: () =>
createGroup(tenantId, {
name: newGroupName,
description: newGroupDesc,
unitType: newGroupUnitType,
parentId: newGroupParentId || undefined,
}),
onSuccess: () => {
toast.success(t("msg.admin.groups.list.create_success", "그룹이 성공적으로 생성되었습니다."));
groupsQuery.refetch();
setNewGroupName("");
setNewGroupNameDesc("");
setNewGroupUnitType("Team");
setNewGroupParentId(null);
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.admin.groups.list.create_error", "그룹 생성 실패"), { description: error.response?.data?.error || error.message });
}
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteGroup(tenantId, id),
onSuccess: () => {
toast.success(t("msg.admin.groups.list.delete_success", "그룹이 삭제되었습니다."));
groupsQuery.refetch();
if (selectedGroupId && selectedGroupId === (deleteMutation.variables as any)) {
setSelectedGroupId(null);
}
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.common.error", "그룹 삭제 실패"), { description: error.response?.data?.error || error.message });
}
const params = useParams<{ tenantId: string }>();
const tenantId = params.tenantId ?? "";
const [newGroupName, setNewGroupName] = useState("");
const [newGroupDesc, setNewGroupNameDesc] = useState("");
const [newGroupUnitType, setNewGroupUnitType] = useState("Team");
const [newGroupParentId, setNewGroupParentId] = useState<string | null>(null);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const groupsQuery = useQuery({
queryKey: ["groups", tenantId],
queryFn: () => fetchGroups(tenantId),
enabled: !!tenantId,
});
const createMutation = useMutation({
mutationFn: () =>
createGroup(tenantId, {
name: newGroupName,
description: newGroupDesc,
unitType: newGroupUnitType,
parentId: newGroupParentId || undefined,
}),
onSuccess: () => {
toast.success(
t(
"msg.admin.groups.list.create_success",
"그룹이 성공적으로 생성되었습니다.",
),
);
groupsQuery.refetch();
setNewGroupName("");
setNewGroupNameDesc("");
setNewGroupUnitType("Team");
setNewGroupParentId(null);
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.admin.groups.list.create_error", "그룹 생성 실패"), {
description: error.response?.data?.error || error.message,
});
const addMemberMutation = useMutation({
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
addGroupMember(tenantId, groupId, userId),
onSuccess: () => {
toast.success(t("msg.admin.groups.members.add_success", "멤버가 추가되었습니다."));
groupsQuery.refetch();
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.common.error", "오류 발생"), { description: error.response?.data?.error || error.message });
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteGroup(tenantId, id),
onSuccess: () => {
toast.success(
t("msg.admin.groups.list.delete_success", "그룹이 삭제되었습니다."),
);
groupsQuery.refetch();
if (
selectedGroupId &&
selectedGroupId === (deleteMutation.variables as any)
) {
setSelectedGroupId(null);
}
});
const removeMemberMutation = useMutation({
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
removeGroupMember(tenantId, groupId, userId),
onSuccess: () => {
toast.success(t("msg.admin.groups.members.remove_success", "멤버가 제거되었습니다."));
groupsQuery.refetch();
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.common.error", "오류 발생"), { description: error.response?.data?.error || error.message });
}
});
const groupTree = groupsQuery.data ? buildGroupTree(groupsQuery.data) : [];
const handleAddSubGroup = (parentId: string) => {
setNewGroupParentId(parentId);
};
const handleDeleteGroup = (groupId: string, groupName: string) => {
if (window.confirm(t("msg.admin.groups.list.delete_confirm", `그룹 "{{name}}"을(를) 삭제하시겠습니까?`, { name: groupName }))) {
deleteMutation.mutate(groupId);
}
};
const handleAddMember = (groupId: string) => {
const userId = window.prompt(t("msg.admin.groups.prompt.user_id", "추가할 사용자의 UUID를 입력하세요:"));
if (userId) {
addMemberMutation.mutate({ groupId, userId });
}
};
const currentGroup = groupsQuery.data?.find((g) => g.id === selectedGroupId);
return (
<div className="space-y-6 mt-6">
<div className="grid gap-6 md:grid-cols-3">
<Card className="bg-[var(--color-panel)] md:col-span-1 border-primary/20">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Plus size={18} /> {t("ui.admin.groups.create.title", "새 그룹 생성")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.common.error", "그룹 삭제 실패"), {
description: error.response?.data?.error || error.message,
});
},
});
const addMemberMutation = useMutation({
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
addGroupMember(tenantId, groupId, userId),
onSuccess: () => {
toast.success(
t("msg.admin.groups.members.add_success", "멤버가 추가되었습니다."),
);
groupsQuery.refetch();
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.common.error", "오류 발생"), {
description: error.response?.data?.error || error.message,
});
},
});
const removeMemberMutation = useMutation({
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
removeGroupMember(tenantId, groupId, userId),
onSuccess: () => {
toast.success(
t("msg.admin.groups.members.remove_success", "멤버가 제거되었습니다."),
);
groupsQuery.refetch();
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.common.error", "오류 발생"), {
description: error.response?.data?.error || error.message,
});
},
});
const groupTree = groupsQuery.data ? buildGroupTree(groupsQuery.data) : [];
const handleAddSubGroup = (parentId: string) => {
setNewGroupParentId(parentId);
};
const handleDeleteGroup = (groupId: string, groupName: string) => {
if (
window.confirm(
t(
"msg.admin.groups.list.delete_confirm",
`그룹 "{{name}}"을(를) 삭제하시겠습니까?`,
{ name: groupName },
),
)
) {
deleteMutation.mutate(groupId);
}
};
const handleAddMember = (groupId: string) => {
const userId = window.prompt(
t(
"msg.admin.groups.prompt.user_id",
"추가할 사용자의 UUID를 입력하세요:",
),
);
if (userId) {
addMemberMutation.mutate({ groupId, userId });
}
};
const currentGroup = groupsQuery.data?.find((g) => g.id === selectedGroupId);
return (
<div className="space-y-6 mt-6">
<div className="grid gap-6 md:grid-cols-3">
<Card className="bg-[var(--color-panel)] md:col-span-1 border-primary/20">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Plus size={18} />{" "}
{t("ui.admin.groups.create.title", "새 그룹 생성")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<Label htmlFor="name">{t("ui.admin.groups.form.name_label", "그룹 이름")}</Label>
<Input id="name" value={newGroupName} onChange={(e) => setNewGroupName(e.target.value)} />
<Label htmlFor="name">
{t("ui.admin.groups.form.name_label", "그룹 이름")}
</Label>
<Input
id="name"
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="unitType">{t("ui.admin.groups.form.unit_level_label", "조직 단위 레벨")}</Label>
<Input id="unitType" value={newGroupUnitType} onChange={(e) => setNewGroupUnitType(e.target.value)} />
<Label htmlFor="unitType">
{t("ui.admin.groups.form.unit_level_label", "조직 단위 레벨")}
</Label>
<Input
id="unitType"
value={newGroupUnitType}
onChange={(e) => setNewGroupUnitType(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="parentId">{t("ui.admin.groups.form.parent_label", "상위 그룹 (선택)")}</Label>
<Label htmlFor="parentId">
{t("ui.admin.groups.form.parent_label", "상위 그룹 (선택)")}
</Label>
<select
id="parentId"
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
value={newGroupParentId || ""}
onChange={(e) => setNewGroupParentId(e.target.value || null)}
>
<option value="">{t("ui.admin.groups.form.parent_none", "없음 (최상위)")}</option>
{groupsQuery.data?.map(group => <option key={group.id} value={group.id}>{group.name}</option>)}
<option value="">
{t("ui.admin.groups.form.parent_none", "없음 (최상위)")}
</option>
{groupsQuery.data?.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</select>
</div>
<div className="space-y-1">
<Label htmlFor="desc">{t("ui.admin.groups.form.desc_label", "설명")}</Label>
<Input id="desc" value={newGroupDesc} onChange={(e) => setNewGroupNameDesc(e.target.value)} />
<Label htmlFor="desc">
{t("ui.admin.groups.form.desc_label", "설명")}
</Label>
<Input
id="desc"
value={newGroupDesc}
onChange={(e) => setNewGroupNameDesc(e.target.value)}
/>
</div>
<Button className="w-full" onClick={() => createMutation.mutate()} disabled={!newGroupName || createMutation.isPending}>
<Button
className="w-full"
onClick={() => createMutation.mutate()}
disabled={!newGroupName || createMutation.isPending}
>
{t("ui.admin.groups.form.submit", "생성하기")}
</Button>
</CardContent>
</Card>
<Card className="bg-[var(--color-panel)] md:col-span-2">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>{t("ui.admin.groups.list.title", "User Groups")}</CardTitle>
<CardDescription>{t("msg.admin.groups.list.subtitle", "이 테넌트에 정의된 사용자 그룹 목록입니다.")}</CardDescription>
</div>
<Button variant="ghost" size="sm" onClick={() => groupsQuery.refetch()}><RefreshCw size={14} /></Button>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("ui.admin.groups.table.name", "NAME")}</TableHead>
<TableHead className="text-center">{t("ui.admin.groups.table.members", "MEMBERS")}</TableHead>
<TableHead className="text-right">{t("ui.admin.groups.table.actions", "ACTIONS")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groupsQuery.isLoading && <TableRow><TableCell colSpan={3}>{t("msg.admin.groups.list.loading", "로딩 중...")}</TableCell></TableRow>}
{!groupsQuery.isLoading && groupTree.length === 0 && <TableRow><TableCell colSpan={3} className="text-center py-8 text-muted-foreground">{t("msg.admin.groups.list.empty", "아직 등록된 그룹이 없습니다.")}</TableCell></TableRow>}
{groupTree.map(node => (
<UserGroupTreeNode
key={node.id}
node={node}
level={0}
onSelect={setSelectedGroupId}
selectedGroupId={selectedGroupId}
onDelete={handleDeleteGroup}
onAddSubGroup={handleAddSubGroup}
/>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
{currentGroup && (
<Card className="bg-[var(--color-panel)] border-t-4 border-t-primary">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield size={18} className="text-primary" /> {t("msg.admin.groups.members.title", "[{{name}}] 멤버 관리", { name: currentGroup.name })}
</CardContent>
</Card>
<Card className="bg-[var(--color-panel)] md:col-span-2">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>
{t("ui.admin.groups.list.title", "User Groups")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex justify-end mb-4">
<Button size="sm" onClick={() => handleAddMember(currentGroup.id)} disabled={addMemberMutation.isPending}>
<UserPlus size={14} className="mr-1" /> {t("ui.common.add", "멤버 추가")}
</Button>
</div>
<Table>
<TableHeader>
<CardDescription>
{t(
"msg.admin.groups.list.subtitle",
"이 테넌트에 정의된 사용자 그룹 목록입니다.",
)}
</CardDescription>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => groupsQuery.refetch()}
>
<RefreshCw size={14} />
</Button>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>
{t("ui.admin.groups.table.name", "NAME")}
</TableHead>
<TableHead className="text-center">
{t("ui.admin.groups.table.members", "MEMBERS")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.groups.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groupsQuery.isLoading && (
<TableRow>
<TableHead>{t("ui.admin.groups.members.table.name", "이름")}</TableHead>
<TableHead>{t("ui.admin.groups.members.table.email", "이메일")}</TableHead>
<TableHead className="text-right">{t("ui.admin.groups.members.table.remove", "제거")}</TableHead>
<TableCell colSpan={3}>
{t("msg.admin.groups.list.loading", "로딩 중...")}
</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{currentGroup.members?.length === 0 && <TableRow><TableCell colSpan={3} className="text-center py-4 text-muted-foreground">{t("msg.admin.groups.members.empty", "멤버가 없습니다.")}</TableCell></TableRow>}
{currentGroup.members?.map(user => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell className="text-muted-foreground">{user.email}</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => removeMemberMutation.mutate({ groupId: currentGroup.id, userId: user.id })} disabled={removeMemberMutation.isPending}>
<UserMinus size={14} className="text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
)}
{!groupsQuery.isLoading && groupTree.length === 0 && (
<TableRow>
<TableCell
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
{t(
"msg.admin.groups.list.empty",
"아직 등록된 그룹이 없습니다.",
)}
</TableCell>
</TableRow>
)}
{groupTree.map((node) => (
<UserGroupTreeNode
key={node.id}
node={node}
level={0}
onSelect={setSelectedGroupId}
selectedGroupId={selectedGroupId}
onDelete={handleDeleteGroup}
onAddSubGroup={handleAddSubGroup}
/>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
export default TenantUserGroupsTab;
{currentGroup && (
<Card className="bg-[var(--color-panel)] border-t-4 border-t-primary">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield size={18} className="text-primary" />{" "}
{t("msg.admin.groups.members.title", "[{{name}}] 멤버 관리", {
name: currentGroup.name,
})}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex justify-end mb-4">
<Button
size="sm"
onClick={() => handleAddMember(currentGroup.id)}
disabled={addMemberMutation.isPending}
>
<UserPlus size={14} className="mr-1" />{" "}
{t("ui.common.add", "멤버 추가")}
</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>
{t("ui.admin.groups.members.table.name", "이름")}
</TableHead>
<TableHead>
{t("ui.admin.groups.members.table.email", "이메일")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.groups.members.table.remove", "제거")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{currentGroup.members?.length === 0 && (
<TableRow>
<TableCell
colSpan={3}
className="text-center py-4 text-muted-foreground"
>
{t("msg.admin.groups.members.empty", "멤버가 없습니다.")}
</TableCell>
</TableRow>
)}
{currentGroup.members?.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell className="text-muted-foreground">
{user.email}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() =>
removeMemberMutation.mutate({
groupId: currentGroup.id,
userId: user.id,
})
}
disabled={removeMemberMutation.isPending}
>
<UserMinus size={14} className="text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
);
}
export default TenantUserGroupsTab;
@@ -101,10 +101,14 @@ export function UserGroupDetailPage() {
queryClient.invalidateQueries({ queryKey: ["user-group-detail", id] });
setIsAddMemberOpen(false);
setSelectedUserId("");
toast.success(t("msg.admin.groups.members.add_success", "구성원이 추가되었습니다."));
toast.success(
t("msg.admin.groups.members.add_success", "구성원이 추가되었습니다."),
);
},
onError: (error: any) => {
toast.error(error.message || t("err.common.unknown", "오류가 발생했습니다."));
toast.error(
error.message || t("err.common.unknown", "오류가 발생했습니다."),
);
},
});
@@ -112,7 +116,12 @@ export function UserGroupDetailPage() {
mutationFn: (userId: string) => removeGroupMember(tenantId!, id!, userId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user-group-detail", id] });
toast.success(t("msg.admin.groups.members.remove_success", "구성원이 제외되었습니다."));
toast.success(
t(
"msg.admin.groups.members.remove_success",
"구성원이 제외되었습니다.",
),
);
},
});
@@ -122,10 +131,14 @@ export function UserGroupDetailPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user-group-roles", id] });
setIsAddRoleOpen(false);
toast.success(t("msg.admin.groups.roles.assign_success", "역할이 할당되었습니다."));
toast.success(
t("msg.admin.groups.roles.assign_success", "역할이 할당되었습니다."),
);
},
onError: (error: any) => {
toast.error(error.message || t("err.common.unknown", "오류가 발생했습니다."));
toast.error(
error.message || t("err.common.unknown", "오류가 발생했습니다."),
);
},
});
@@ -134,7 +147,9 @@ export function UserGroupDetailPage() {
removeGroupRole(tenantId!, id!, role.targetTenantId, role.relation),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user-group-roles", id] });
toast.success(t("msg.admin.groups.roles.remove_success", "역할이 회수되었습니다."));
toast.success(
t("msg.admin.groups.roles.remove_success", "역할이 회수되었습니다."),
);
},
});
@@ -170,7 +185,10 @@ export function UserGroupDetailPage() {
to={`/tenants/${tenantId}/organization`}
className="text-primary hover:underline text-sm"
>
{t("ui.admin.groups.detail.breadcrumb_org", "조직 관리 목록으로 돌아가기")}
{t(
"ui.admin.groups.detail.breadcrumb_org",
"조직 관리 목록으로 돌아가기",
)}
</Link>
</div>
</div>
@@ -196,7 +214,9 @@ export function UserGroupDetailPage() {
{t("ui.admin.groups.detail.breadcrumb_org", "조직 관리")}
</Link>
<span>/</span>
<span className="text-foreground">{t("ui.admin.groups.detail.breadcrumb_unit", "조직 단위")}</span>
<span className="text-foreground">
{t("ui.admin.groups.detail.breadcrumb_unit", "조직 단위")}
</span>
</div>
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
@@ -210,12 +230,17 @@ export function UserGroupDetailPage() {
)}
</div>
<p className="text-sm text-muted-foreground">
{currentGroup.description || t("msg.common.no_description", "설명이 없습니다.")}
{currentGroup.description ||
t("msg.common.no_description", "설명이 없습니다.")}
</p>
</div>
<div className="flex gap-2">
<Badge variant="outline" className="font-normal">{t("ui.admin.groups.detail.breadcrumb_unit", "조직 단위")}</Badge>
<Badge variant="muted" className="font-normal">ID: {id?.split("-")[0]}...</Badge>
<Badge variant="outline" className="font-normal">
{t("ui.admin.groups.detail.breadcrumb_unit", "조직 단위")}
</Badge>
<Badge variant="muted" className="font-normal">
ID: {id?.split("-")[0]}...
</Badge>
</div>
</header>
@@ -224,8 +249,15 @@ export function UserGroupDetailPage() {
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>{t("ui.admin.groups.detail.members_title", "구성원 관리")}</CardTitle>
<CardDescription>{t("ui.admin.groups.detail.members_subtitle", "이 조직에 소속된 사용자를 관리합니다.")}</CardDescription>
<CardTitle>
{t("ui.admin.groups.detail.members_title", "구성원 관리")}
</CardTitle>
<CardDescription>
{t(
"ui.admin.groups.detail.members_subtitle",
"이 조직에 소속된 사용자를 관리합니다.",
)}
</CardDescription>
</div>
<Dialog open={isAddMemberOpen} onOpenChange={setIsAddMemberOpen}>
<DialogTrigger asChild>
@@ -236,16 +268,24 @@ export function UserGroupDetailPage() {
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("ui.admin.groups.detail.members_title", "구성원 추가")}</DialogTitle>
<DialogTitle>
{t("ui.admin.groups.detail.members_title", "구성원 추가")}
</DialogTitle>
<DialogDescription>
{t("ui.admin.groups.detail.members_subtitle", "사용자를 검색하여 조직 구성원으로 추가합니다.")}
{t(
"ui.admin.groups.detail.members_subtitle",
"사용자를 검색하여 조직 구성원으로 추가합니다.",
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>{t("ui.common.search", "사용자 검색")}</Label>
<Input
placeholder={t("ui.admin.users.list.search_placeholder", "이메일 또는 이름으로 검색...")}
placeholder={t(
"ui.admin.users.list.search_placeholder",
"이메일 또는 이름으로 검색...",
)}
value={searchUser}
onChange={(e) => setSearchUser(e.target.value)}
/>
@@ -257,7 +297,12 @@ export function UserGroupDetailPage() {
onValueChange={setSelectedUserId}
>
<SelectTrigger>
<SelectValue placeholder={t("ui.common.select_placeholder", "사용자를 선택하세요")} />
<SelectValue
placeholder={t(
"ui.common.select_placeholder",
"사용자를 선택하세요",
)}
/>
</SelectTrigger>
<SelectContent>
{userList?.items.map((user) => (
@@ -291,30 +336,43 @@ export function UserGroupDetailPage() {
<Table>
<TableHeader className="bg-muted/30">
<TableRow>
<TableHead className="font-bold">{t("ui.admin.users.list.table.name_email", "사용자")}</TableHead>
<TableHead className="text-right font-bold">{t("ui.admin.groups.table.actions", "액션")}</TableHead>
<TableHead className="font-bold">
{t("ui.admin.users.list.table.name_email", "사용자")}
</TableHead>
<TableHead className="text-right font-bold">
{t("ui.admin.groups.table.actions", "액션")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{!currentGroup.members || currentGroup.members.length === 0 ? (
{!currentGroup.members ||
currentGroup.members.length === 0 ? (
<TableRow>
<TableCell
colSpan={2}
className="text-center py-8 text-muted-foreground"
>
{t("msg.admin.groups.members.empty", "구성원이 없습니다.")}
{t(
"msg.admin.groups.members.empty",
"구성원이 없습니다.",
)}
</TableCell>
</TableRow>
) : (
currentGroup.members.map((member) => (
<TableRow key={member.id} className="hover:bg-muted/30 transition-colors">
<TableRow
key={member.id}
className="hover:bg-muted/30 transition-colors"
>
<TableCell>
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
{member.name.charAt(0)}
</div>
<div>
<p className="font-medium text-sm">{member.name}</p>
<p className="font-medium text-sm">
{member.name}
</p>
<p className="text-xs text-muted-foreground">
{member.email}
</p>
@@ -327,7 +385,15 @@ export function UserGroupDetailPage() {
size="icon"
className="text-destructive hover:bg-destructive/10"
onClick={() => {
if (confirm(t("msg.admin.groups.members.remove_confirm", "제거하시겠습니까?", { name: member.name }))) {
if (
confirm(
t(
"msg.admin.groups.members.remove_confirm",
"제거하시겠습니까?",
{ name: member.name },
),
)
) {
removeMemberMutation.mutate(member.id);
}
}}
@@ -348,9 +414,14 @@ export function UserGroupDetailPage() {
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>{t("ui.admin.groups.detail.permissions_title", "권한 관리")}</CardTitle>
<CardTitle>
{t("ui.admin.groups.detail.permissions_title", "권한 관리")}
</CardTitle>
<CardDescription>
{t("ui.admin.groups.detail.permissions_subtitle", "이 조직이 다른 테넌트에 가지는 역할을 정의합니다.")}
{t(
"ui.admin.groups.detail.permissions_subtitle",
"이 조직이 다른 테넌트에 가지는 역할을 정의합니다.",
)}
</CardDescription>
</div>
<Dialog open={isAddRoleOpen} onOpenChange={setIsAddRoleOpen}>
@@ -362,20 +433,35 @@ export function UserGroupDetailPage() {
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("ui.admin.groups.detail.permissions_title", "테넌트 역할 할당")}</DialogTitle>
<DialogTitle>
{t(
"ui.admin.groups.detail.permissions_title",
"테넌트 역할 할당",
)}
</DialogTitle>
<DialogDescription>
{t("msg.admin.groups.roles.description", "이 조직의 구성원들이 대상 테넌트에서 상속받을 역할을 선택하세요.")}
{t(
"msg.admin.groups.roles.description",
"이 조직의 구성원들이 대상 테넌트에서 상속받을 역할을 선택하세요.",
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>{t("ui.admin.users.detail.form.tenant", "대상 테넌트")}</Label>
<Label>
{t("ui.admin.users.detail.form.tenant", "대상 테넌트")}
</Label>
<Select
value={selectedTargetTenantId}
onValueChange={setSelectedTargetTenantId}
>
<SelectTrigger>
<SelectValue placeholder={t("ui.admin.tenants.list.select_placeholder", "테넌트를 선택하세요")} />
<SelectValue
placeholder={t(
"ui.admin.tenants.list.select_placeholder",
"테넌트를 선택하세요",
)}
/>
</SelectTrigger>
<SelectContent>
{tenantList?.items.map((t) => (
@@ -387,7 +473,9 @@ export function UserGroupDetailPage() {
</Select>
</div>
<div className="space-y-2">
<Label>{t("ui.admin.users.detail.form.role", "역할 (Relation)")}</Label>
<Label>
{t("ui.admin.users.detail.form.role", "역할 (Relation)")}
</Label>
<Select
value={selectedRelation}
onValueChange={setSelectedRelation}
@@ -431,9 +519,15 @@ export function UserGroupDetailPage() {
<Table>
<TableHeader className="bg-muted/30">
<TableRow>
<TableHead className="font-bold">{t("ui.admin.users.detail.form.tenant", "대상 테넌트")}</TableHead>
<TableHead className="font-bold">{t("ui.admin.users.detail.form.role", "역할")}</TableHead>
<TableHead className="text-right font-bold">{t("ui.admin.groups.table.actions", "액션")}</TableHead>
<TableHead className="font-bold">
{t("ui.admin.users.detail.form.tenant", "대상 테넌트")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.users.detail.form.role", "역할")}
</TableHead>
<TableHead className="text-right font-bold">
{t("ui.admin.groups.table.actions", "액션")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -449,19 +543,28 @@ export function UserGroupDetailPage() {
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
{t("msg.admin.groups.roles.empty", "할당된 역할이 없습니다.")}
{t(
"msg.admin.groups.roles.empty",
"할당된 역할이 없습니다.",
)}
</TableCell>
</TableRow>
) : (
groupRoles.map((role, idx) => (
<TableRow key={`${role.tenantId}-${role.relation}-${idx}`} className="hover:bg-muted/30 transition-colors">
<TableRow
key={`${role.tenantId}-${role.relation}-${idx}`}
className="hover:bg-muted/30 transition-colors"
>
<TableCell>
<div className="font-medium text-sm">
{role.tenantName || role.tenantId}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize font-normal">
<Badge
variant="outline"
className="capitalize font-normal"
>
{role.relation}
</Badge>
</TableCell>
@@ -471,7 +574,11 @@ export function UserGroupDetailPage() {
size="icon"
className="text-destructive hover:bg-destructive/10"
onClick={() => {
if (confirm(t("msg.admin.groups.roles.remove_confirm"))) {
if (
confirm(
t("msg.admin.groups.roles.remove_confirm"),
)
) {
removeRoleMutation.mutate({
targetTenantId: role.tenantId,
relation: role.relation,
@@ -200,7 +200,10 @@ function UserListPage() {
)}
</TableHead>
<TableHead>
{t("ui.admin.users.list.table.position_job", "POSITION / JOB")}
{t(
"ui.admin.users.list.table.position_job",
"POSITION / JOB",
)}
</TableHead>
<TableHead>
{t("ui.admin.users.list.table.created", "CREATED")}
@@ -277,7 +280,9 @@ function UserListPage() {
</TableCell>
<TableCell>
<div className="flex flex-col text-sm">
<span className="font-medium">{user.position || "-"}</span>
<span className="font-medium">
{user.position || "-"}
</span>
<span className="text-xs text-muted-foreground">
{user.jobTitle || "-"}
</span>
@@ -44,12 +44,8 @@ function LanguageSelector() {
className="rounded-full border border-border bg-transparent px-3 py-2 text-sm text-muted-foreground transition hover:bg-muted/20"
aria-label={t("ui.common.language", "언어")}
>
<option value="ko">
{t("ui.common.language_ko", "한국어")}
</option>
<option value="en">
{t("ui.common.language_en", "English")}
</option>
<option value="ko">{t("ui.common.language_ko", "한국어")}</option>
<option value="en">{t("ui.common.language_en", "English")}</option>
</select>
);
}
+14 -12
View File
@@ -35,18 +35,18 @@ function LoginPage() {
<Card className="border-primary/20 bg-card/50 backdrop-blur-xl shadow-2xl">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl flex items-center gap-2">
<LogIn size={20} className="text-primary" />
개발자 포털 로그인
<LogIn size={20} className="text-primary" />
개발자 포털 로그인
</CardTitle>
<CardDescription>
Baron 통합 인증(SSO)을 통해 개발자 포털에 접속합니다.
</CardDescription>
</CardHeader>
<CardContent className="pt-4 pb-8 space-y-3">
<Button
onClick={handleSSOLogin}
className="w-full h-14 text-lg font-semibold flex gap-3 shadow-lg"
disabled={auth.isLoading}
<Button
onClick={handleSSOLogin}
className="w-full h-14 text-lg font-semibold flex gap-3 shadow-lg"
disabled={auth.isLoading}
>
{auth.isLoading ? (
<>
@@ -61,22 +61,24 @@ function LoginPage() {
</>
)}
</Button>
<p className="mt-6 text-xs text-center text-muted-foreground leading-relaxed">
개발자 포털 세션은 브라우저 정책에 따라 유지됩니다.<br />
개발자 포털 세션은 브라우저 정책에 따라 유지됩니다.
<br />
민감한 작업 시 재인증을 요구할 수 있습니다.
</p>
</CardContent>
</Card>
<div className="flex justify-center gap-4">
<div className="h-1 w-1 rounded-full bg-primary/30"></div>
<div className="h-1 w-1 rounded-full bg-primary/30"></div>
<div className="h-1 w-1 rounded-full bg-primary/30"></div>
<div className="h-1 w-1 rounded-full bg-primary/30"></div>
<div className="h-1 w-1 rounded-full bg-primary/30"></div>
<div className="h-1 w-1 rounded-full bg-primary/30"></div>
</div>
<p className="px-8 text-center text-sm text-muted-foreground">
인증 정보가 없거나 로그인이 되지 않는 경우<br />
인증 정보가 없거나 로그인이 되지 않는 경우
<br />
시스템 관리자에게 문의하세요.
</p>
</div>
@@ -208,11 +208,7 @@ function ClientsPage() {
<div className="mt-1 flex items-baseline gap-2">
<span className="text-3xl font-bold">{item.value}</span>
<Badge
variant={
item.tone === "up"
? "success"
: "muted"
}
variant={item.tone === "up" ? "success" : "muted"}
className={cn(
"px-2",
item.tone === "stable" && "bg-muted/40 text-foreground",
+2 -6
View File
@@ -34,16 +34,12 @@ class _LocaleGateState extends State<LocaleGate> {
Future<void> _applyLocale() async {
final normalized = normalizeLocaleCode(widget.localeCode);
LocaleStorage.write(normalized);
webWindow.setTitle(
tr('ui.userfront.app_title'),
);
webWindow.setTitle(tr('ui.userfront.app_title'));
if (context.locale.languageCode == normalized) {
return;
}
await context.setLocale(Locale(normalized));
webWindow.setTitle(
tr('ui.userfront.app_title'),
);
webWindow.setTitle(tr('ui.userfront.app_title'));
}
@override
+1 -1
View File
@@ -76,7 +76,7 @@ String buildLocalizedPath(String localeCode, Uri uri) {
}
}
final newPath = '/${[localeCode, ...restSegments].join('/')}';
// Return only the path and query part to avoid GoRouter confusion with full URLs
final newUri = uri.replace(path: newPath);
String result = newUri.path;
@@ -299,10 +299,7 @@ class AuthProxyService {
} else {
final errorBody = jsonDecode(response.body);
throw Exception(
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.consent_fetch',
),
errorBody['error'] ?? tr('err.userfront.auth_proxy.consent_fetch'),
);
}
} finally {
@@ -333,10 +330,7 @@ class AuthProxyService {
} else {
final errorBody = jsonDecode(response.body);
throw Exception(
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.consent_accept',
),
errorBody['error'] ?? tr('err.userfront.auth_proxy.consent_accept'),
);
}
} finally {
@@ -363,10 +357,7 @@ class AuthProxyService {
} else {
final errorBody = jsonDecode(response.body);
throw Exception(
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.consent_reject',
),
errorBody['error'] ?? tr('err.userfront.auth_proxy.consent_reject'),
);
}
} finally {
@@ -810,7 +801,7 @@ class AuthProxyService {
body: jsonEncode({
'level': level,
'message': message,
if (data != null) 'data': data,
'data': ?data,
}),
);
_recordClientLogSuccess();
@@ -934,7 +925,7 @@ class AuthProxyService {
'name': name,
'phone': phone,
'affiliationType': affiliationType,
if (companyCode != null) 'companyCode': companyCode,
'companyCode': ?companyCode,
'department': department,
'termsAccepted': termsAccepted,
}),
@@ -15,10 +15,7 @@ class LanguageSelector extends StatelessWidget {
Widget build(BuildContext context) {
final current = context.locale.languageCode;
final items = [
DropdownMenuItem(
value: 'ko',
child: Text(tr('ui.common.language_ko')),
),
DropdownMenuItem(value: 'ko', child: Text(tr('ui.common.language_ko'))),
DropdownMenuItem(
value: 'en',
child: Text(tr('ui.common.language_en', fallback: 'English')),
@@ -39,9 +39,7 @@ class ErrorScreen extends StatelessWidget {
'msg.userfront.error.title_with_code',
params: {'code': normalizedCode},
)
: tr(
'msg.userfront.error.title_generic',
));
: tr('msg.userfront.error.title_generic'));
final detail = isProd
? (isInternalWhitelisted
? tr(
@@ -51,23 +49,16 @@ class ErrorScreen extends StatelessWidget {
: (isOryBypass
? tr(
'msg.userfront.error.ory.$normalizedCode',
fallback:
(description?.isNotEmpty == true)
? description
: tr('msg.userfront.error.detail_request'),
fallback: (description?.isNotEmpty == true)
? description
: tr('msg.userfront.error.detail_request'),
)
: tr(
'msg.userfront.error.detail_contact',
)))
: tr('msg.userfront.error.detail_contact')))
: ((description?.isNotEmpty == true)
? description!
: (hasCode
? tr(
'msg.userfront.error.detail_generic',
)
: tr(
'msg.userfront.error.detail_request',
)));
? tr('msg.userfront.error.detail_generic')
: tr('msg.userfront.error.detail_request')));
return Scaffold(
backgroundColor: const Color(0xFFF7F8FA),
@@ -104,10 +95,7 @@ class ErrorScreen extends StatelessWidget {
),
const SizedBox(height: 12),
Text(
tr(
'msg.userfront.error.type',
params: {'type': errorType},
),
tr('msg.userfront.error.type', params: {'type': errorType}),
style: theme.textTheme.bodySmall?.copyWith(
color: const Color(0xFF6B7280),
),
@@ -115,10 +103,7 @@ class ErrorScreen extends StatelessWidget {
if (errorId != null && errorId!.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
tr(
'msg.userfront.error.id',
params: {'id': errorId!},
),
tr('msg.userfront.error.id', params: {'id': errorId!}),
style: theme.textTheme.bodySmall?.copyWith(
color: const Color(0xFF6B7280),
),
@@ -142,11 +127,7 @@ class ErrorScreen extends StatelessWidget {
borderRadius: BorderRadius.circular(10),
),
),
child: Text(
tr(
'ui.userfront.error.go_login',
),
),
child: Text(tr('ui.userfront.error.go_login')),
),
OutlinedButton(
onPressed: () => context.go('/'),
@@ -161,9 +142,7 @@ class ErrorScreen extends StatelessWidget {
borderRadius: BorderRadius.circular(10),
),
),
child: Text(
tr('ui.userfront.error.go_home'),
),
child: Text(tr('ui.userfront.error.go_home')),
),
],
),
@@ -25,11 +25,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
Future<void> _handlePasswordReset() async {
final input = _loginIdController.text.trim();
if (input.isEmpty) {
_showError(
tr(
'msg.userfront.forgot.input_required',
),
);
_showError(tr('msg.userfront.forgot.input_required'));
return;
}
@@ -52,11 +48,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr(
'msg.userfront.forgot.sent',
),
),
content: Text(tr('msg.userfront.forgot.sent')),
backgroundColor: Colors.green,
),
);
@@ -65,10 +57,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
} catch (e) {
if (mounted) {
_showError(
tr(
'msg.userfront.forgot.error',
params: {'error': e.toString()},
),
tr('msg.userfront.forgot.error', params: {'error': e.toString()}),
);
}
} finally {
@@ -133,9 +122,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
const SizedBox(width: 8),
Expanded(
child: Text(
tr(
'msg.userfront.forgot.dry_send',
),
tr('msg.userfront.forgot.dry_send'),
style: const TextStyle(
color: Color(0xFF8A6D3B),
fontSize: 12,
@@ -148,9 +135,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
],
const SizedBox(height: 16),
Text(
tr(
'msg.userfront.forgot.description',
),
tr('msg.userfront.forgot.description'),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey),
),
@@ -158,9 +143,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
TextField(
controller: _loginIdController,
decoration: InputDecoration(
labelText: tr(
'ui.userfront.forgot.input_label',
),
labelText: tr('ui.userfront.forgot.input_label'),
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.person_outline),
),
@@ -181,9 +164,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
color: Colors.white,
),
)
: Text(
tr('ui.userfront.forgot.submit'),
),
: Text(tr('ui.userfront.forgot.submit')),
),
],
),
@@ -900,8 +900,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
_onLoginSuccess(jwt, provider: provider, redirectTo: redirectTo);
} else if (redirectTo != null && redirectTo.isNotEmpty) {
webWindow.redirectTo(redirectTo);
} else {
}
} else {}
} catch (e) {
if (e.toString().contains("User not registered")) {
_showUnregisteredDialog();
@@ -1124,11 +1123,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
}
Future<void> _onLoginSuccess(String token, {String? provider, String? redirectTo}) async {
Future<void> _onLoginSuccess(
String token, {
String? provider,
String? redirectTo,
}) async {
try {
if (!mounted) {
return;
return;
}
// [Priority 1] Immediate External Redirection
@@ -1139,7 +1141,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
} catch (stErr) {
// ignore
}
webWindow.redirectTo(redirectTo); // Removed await as it's void
return;
}
@@ -1150,24 +1152,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
// Save token first, it's needed for acceptance
final providerName = provider ?? AuthTokenStore.getProvider();
AuthTokenStore.setToken(token, provider: providerName);
final res = await AuthProxyService.acceptOidcLogin(
_loginChallenge!,
token: token,
);
final nextRedirectTo = res['redirectTo'] as String?;
if (nextRedirectTo != null && nextRedirectTo.isNotEmpty) {
webWindow.redirectTo(nextRedirectTo); // Removed await
return;
} else {
}
} else {}
} catch (e) {
_showError(
tr(
'msg.userfront.login.oidc_failed',
),
);
_showError(tr('msg.userfront.login.oidc_failed'));
return;
}
}
@@ -1188,7 +1185,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final uri = Uri.base;
final redirectParam =
uri.queryParameters['redirect_uri'] ?? uri.queryParameters['redirect_url'];
uri.queryParameters['redirect_uri'] ??
uri.queryParameters['redirect_url'];
final hasRedirectParam =
redirectParam != null && redirectParam.isNotEmpty;
@@ -26,9 +26,7 @@ class LoginSuccessScreen extends StatelessWidget {
),
const SizedBox(height: 16),
Text(
tr(
'msg.userfront.login_success.subtitle',
),
tr('msg.userfront.login_success.subtitle'),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey, fontSize: 16),
),
@@ -40,11 +38,7 @@ class LoginSuccessScreen extends StatelessWidget {
context.push('/scan');
},
icon: const Icon(Icons.camera_alt, size: 28),
label: Text(
tr(
'ui.userfront.login_success.qr',
),
),
label: Text(tr('ui.userfront.login_success.qr')),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(80), // 버튼 높이를 더 크게
backgroundColor: Colors.blue.shade700,
@@ -63,9 +57,7 @@ class LoginSuccessScreen extends StatelessWidget {
context.go('/');
},
child: Text(
tr(
'ui.userfront.login_success.later',
),
tr('ui.userfront.login_success.later'),
style: const TextStyle(color: Colors.grey),
),
),
@@ -21,7 +21,9 @@ class _QRScanScreenState extends State<QRScanScreen> {
),
),
body: const Center(
child: Text('QR Scanner is temporarily disabled for WASM build stability.'),
child: Text(
'QR Scanner is temporarily disabled for WASM build stability.',
),
),
);
}
@@ -69,11 +69,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
if (_formKey.currentState?.validate() != true) return;
if ((_loginId == null || _loginId!.isEmpty) &&
(_token == null || _token!.isEmpty)) {
_showError(
tr(
'msg.userfront.reset.invalid_link',
),
);
_showError(tr('msg.userfront.reset.invalid_link'));
return;
}
@@ -89,11 +85,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr(
'msg.userfront.reset.success',
),
),
content: Text(tr('msg.userfront.reset.success')),
backgroundColor: Colors.green,
),
);
@@ -123,9 +115,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
String _buildPolicyDescription() {
if (_isPolicyLoading) {
return tr(
'msg.userfront.reset.policy_loading',
);
return tr('msg.userfront.reset.policy_loading');
}
final minLength = (_policy?['minLength'] as int?) ?? 12;
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
@@ -149,22 +139,16 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
);
}
if (requiresLower) {
parts.add(
tr('msg.userfront.reset.policy.lowercase'),
);
parts.add(tr('msg.userfront.reset.policy.lowercase'));
}
if (requiresUpper) {
parts.add(
tr('msg.userfront.reset.policy.uppercase'),
);
parts.add(tr('msg.userfront.reset.policy.uppercase'));
}
if (requiresNumber) {
parts.add(tr('msg.userfront.reset.policy.number'));
}
if (requiresSymbol) {
parts.add(
tr('msg.userfront.reset.policy.symbol'),
);
parts.add(tr('msg.userfront.reset.policy.symbol'));
}
return parts.join(", ");
@@ -192,9 +176,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
tr(
'ui.userfront.reset.subtitle',
),
tr('ui.userfront.reset.subtitle'),
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
@@ -212,9 +194,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
controller: _passwordController,
obscureText: _isPasswordObscured,
decoration: InputDecoration(
labelText: tr(
'ui.userfront.reset.new_password',
),
labelText: tr('ui.userfront.reset.new_password'),
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
@@ -265,25 +245,17 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
}
if ((_policy?['lowercase'] ?? true) && !hasLower) {
return tr(
'msg.userfront.reset.error.lowercase',
);
return tr('msg.userfront.reset.error.lowercase');
}
if ((_policy?['uppercase'] ?? false) && !hasUpper) {
return tr(
'msg.userfront.reset.error.uppercase',
);
return tr('msg.userfront.reset.error.uppercase');
}
if ((_policy?['number'] ?? true) && !hasNumber) {
return tr(
'msg.userfront.reset.error.number',
);
return tr('msg.userfront.reset.error.number');
}
if ((_policy?['nonAlphanumeric'] ?? true) &&
!hasSymbol) {
return tr(
'msg.userfront.reset.error.symbol',
);
return tr('msg.userfront.reset.error.symbol');
}
return null;
},
@@ -293,9 +265,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
controller: _confirmPasswordController,
obscureText: _isConfirmPasswordObscured,
decoration: InputDecoration(
labelText: tr(
'ui.userfront.reset.confirm_password',
),
labelText: tr('ui.userfront.reset.confirm_password'),
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
@@ -314,9 +284,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
),
validator: (value) {
if (value != _passwordController.text) {
return tr(
'msg.userfront.reset.error.mismatch',
);
return tr('msg.userfront.reset.error.mismatch');
}
return null;
},
@@ -336,11 +304,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
color: Colors.white,
),
)
: Text(
tr(
'ui.userfront.reset.submit',
),
),
: Text(tr('ui.userfront.reset.submit')),
),
],
),
@@ -364,9 +328,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
),
const SizedBox(height: 8),
Text(
tr(
'msg.userfront.reset.invalid_body',
),
tr('msg.userfront.reset.invalid_body'),
textAlign: TextAlign.center,
),
],
@@ -164,11 +164,7 @@ class _SignupScreenState extends State<SignupScreen> {
final email = _emailController.text.trim();
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
if (!emailRegex.hasMatch(email)) {
setState(
() => _emailError = tr(
'msg.userfront.signup.email.invalid',
),
);
setState(() => _emailError = tr('msg.userfront.signup.email.invalid'));
return;
}
setState(() {
@@ -179,9 +175,7 @@ class _SignupScreenState extends State<SignupScreen> {
final available = await AuthProxyService.checkEmailAvailability(email);
if (!available) {
setState(
() => _emailError = tr(
'msg.userfront.signup.email.duplicate',
),
() => _emailError = tr('msg.userfront.signup.email.duplicate'),
);
return;
}
@@ -217,9 +211,7 @@ class _SignupScreenState extends State<SignupScreen> {
});
} else {
setState(
() => _emailError = tr(
'msg.userfront.signup.email.code_mismatch',
),
() => _emailError = tr('msg.userfront.signup.email.code_mismatch'),
);
}
} catch (e) {
@@ -272,9 +264,7 @@ class _SignupScreenState extends State<SignupScreen> {
});
} else {
setState(
() => _phoneError = tr(
'msg.userfront.signup.phone.code_mismatch',
),
() => _phoneError = tr('msg.userfront.signup.phone.code_mismatch'),
);
}
} catch (e) {
@@ -329,17 +319,11 @@ class _SignupScreenState extends State<SignupScreen> {
'msg.userfront.signup.password.lowercase_required',
);
} else if (eStr.contains('digit') || eStr.contains('number')) {
_passwordError = tr(
'msg.userfront.signup.password.number_required',
);
_passwordError = tr('msg.userfront.signup.password.number_required');
} else if (eStr.contains('symbol') || eStr.contains('special')) {
_passwordError = tr(
'msg.userfront.signup.password.symbol_required',
);
_passwordError = tr('msg.userfront.signup.password.symbol_required');
} else if (eStr.contains('length') || eStr.contains('12 characters')) {
_passwordError = tr(
'msg.userfront.signup.password.length_required',
);
_passwordError = tr('msg.userfront.signup.password.length_required');
} else {
_passwordError = tr(
'msg.userfront.signup.failed',
@@ -357,18 +341,12 @@ class _SignupScreenState extends State<SignupScreen> {
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: Text(
tr('msg.userfront.signup.success.title'),
),
content: Text(
tr('msg.userfront.signup.success.body'),
),
title: Text(tr('msg.userfront.signup.success.title')),
content: Text(tr('msg.userfront.signup.success.body')),
actions: [
TextButton(
onPressed: () => context.go('/signin'),
child: Text(
tr('ui.userfront.signup.success.action'),
),
child: Text(tr('ui.userfront.signup.success.action')),
),
],
),
@@ -382,25 +360,13 @@ class _SignupScreenState extends State<SignupScreen> {
padding: const EdgeInsets.symmetric(vertical: 20),
child: Row(
children: [
_stepCircle(
1,
tr('ui.userfront.signup.steps.agreement'),
),
_stepCircle(1, tr('ui.userfront.signup.steps.agreement')),
_stepLine(1),
_stepCircle(
2,
tr('ui.userfront.signup.steps.verify'),
),
_stepCircle(2, tr('ui.userfront.signup.steps.verify')),
_stepLine(2),
_stepCircle(
3,
tr('ui.userfront.signup.steps.profile'),
),
_stepCircle(3, tr('ui.userfront.signup.steps.profile')),
_stepLine(3),
_stepCircle(
4,
tr('ui.userfront.signup.steps.password'),
),
_stepCircle(4, tr('ui.userfront.signup.steps.password')),
],
),
);
@@ -454,9 +420,7 @@ class _SignupScreenState extends State<SignupScreen> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
tr(
'msg.userfront.signup.agreement.title',
),
tr('msg.userfront.signup.agreement.title'),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
@@ -489,18 +453,14 @@ class _SignupScreenState extends State<SignupScreen> {
),
const SizedBox(height: 16),
_agreementSection(
title: tr(
'ui.userfront.signup.agreement.tos_title',
),
title: tr('ui.userfront.signup.agreement.tos_title'),
content: _tosText,
value: _termsAccepted,
onChanged: (val) => setState(() => _termsAccepted = val!),
),
const SizedBox(height: 16),
_agreementSection(
title: tr(
'ui.userfront.signup.agreement.privacy_title',
),
title: tr('ui.userfront.signup.agreement.privacy_title'),
content: _privacyText,
value: _privacyAccepted,
onChanged: (val) => setState(() => _privacyAccepted = val!),
@@ -745,9 +705,7 @@ class _SignupScreenState extends State<SignupScreen> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
tr(
'msg.userfront.signup.auth.title',
),
tr('msg.userfront.signup.auth.title'),
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
@@ -764,9 +722,7 @@ class _SignupScreenState extends State<SignupScreen> {
const SizedBox(width: 8),
Expanded(
child: Text(
tr(
'msg.userfront.signup.auth.affiliate_notice',
),
tr('msg.userfront.signup.auth.affiliate_notice'),
style: const TextStyle(
fontSize: 12,
color: Colors.blue,
@@ -790,9 +746,7 @@ class _SignupScreenState extends State<SignupScreen> {
controller: _emailController,
onChanged: _checkEmailAffiliation, // 도메인 실시간 체크
decoration: InputDecoration(
labelText: tr(
'ui.userfront.signup.auth.email.label',
),
labelText: tr('ui.userfront.signup.auth.email.label'),
border: const OutlineInputBorder(),
errorText: _emailError,
hintText: 'example@hanmaceng.co.kr',
@@ -815,9 +769,7 @@ class _SignupScreenState extends State<SignupScreen> {
child: Text(
_emailSeconds > 0
? tr('ui.common.resend')
: tr(
'ui.userfront.signup.auth.request_code',
),
: tr('ui.userfront.signup.auth.request_code'),
),
),
),
@@ -828,9 +780,7 @@ class _SignupScreenState extends State<SignupScreen> {
TextFormField(
controller: _emailCodeController,
decoration: InputDecoration(
labelText: tr(
'ui.userfront.signup.auth.code_label',
),
labelText: tr('ui.userfront.signup.auth.code_label'),
suffixText: _formatTime(_emailSeconds),
border: const OutlineInputBorder(),
),
@@ -848,9 +798,7 @@ class _SignupScreenState extends State<SignupScreen> {
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
tr(
'msg.userfront.signup.email.verified',
),
tr('msg.userfront.signup.email.verified'),
style: const TextStyle(
color: Colors.green,
fontSize: 13,
@@ -870,9 +818,7 @@ class _SignupScreenState extends State<SignupScreen> {
child: TextFormField(
controller: _phoneController,
decoration: InputDecoration(
labelText: tr(
'ui.userfront.signup.phone.label',
),
labelText: tr('ui.userfront.signup.phone.label'),
border: const OutlineInputBorder(),
errorText: _phoneError,
),
@@ -895,9 +841,7 @@ class _SignupScreenState extends State<SignupScreen> {
child: Text(
_phoneSeconds > 0
? tr('ui.common.resend')
: tr(
'ui.userfront.signup.auth.request_code',
),
: tr('ui.userfront.signup.auth.request_code'),
),
),
),
@@ -908,9 +852,7 @@ class _SignupScreenState extends State<SignupScreen> {
TextFormField(
controller: _phoneCodeController,
decoration: InputDecoration(
labelText: tr(
'ui.userfront.signup.auth.code_label',
),
labelText: tr('ui.userfront.signup.auth.code_label'),
suffixText: _formatTime(_phoneSeconds),
border: const OutlineInputBorder(),
),
@@ -928,9 +870,7 @@ class _SignupScreenState extends State<SignupScreen> {
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
tr(
'msg.userfront.signup.phone.verified',
),
tr('msg.userfront.signup.phone.verified'),
style: const TextStyle(
color: Colors.green,
fontSize: 13,
@@ -947,9 +887,7 @@ class _SignupScreenState extends State<SignupScreen> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
tr(
'msg.userfront.signup.profile.title',
),
tr('msg.userfront.signup.profile.title'),
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 24),
@@ -971,28 +909,20 @@ class _SignupScreenState extends State<SignupScreen> {
key: ValueKey(_affiliationType),
initialValue: _affiliationType,
decoration: InputDecoration(
labelText: tr(
'ui.userfront.signup.profile.affiliation_type',
),
labelText: tr('ui.userfront.signup.profile.affiliation_type'),
border: const OutlineInputBorder(),
helperText: _isAffiliateEmail
? tr(
'msg.userfront.signup.profile.affiliate_hint',
)
? tr('msg.userfront.signup.profile.affiliate_hint')
: null,
),
items: [
DropdownMenuItem(
value: 'GENERAL',
child: Text(
tr('domain.affiliation.general'),
),
child: Text(tr('domain.affiliation.general')),
),
DropdownMenuItem(
value: 'AFFILIATE',
child: Text(
tr('domain.affiliation.affiliate'),
),
child: Text(tr('domain.affiliation.affiliate')),
),
],
onChanged: _isAffiliateEmail
@@ -1019,9 +949,7 @@ class _SignupScreenState extends State<SignupScreen> {
key: ValueKey(_companyCode ?? 'none'),
initialValue: _companyCode,
decoration: InputDecoration(
labelText: tr(
'ui.userfront.signup.profile.company',
),
labelText: tr('ui.userfront.signup.profile.company'),
border: const OutlineInputBorder(),
),
items: [
@@ -1064,9 +992,7 @@ class _SignupScreenState extends State<SignupScreen> {
decoration: InputDecoration(
labelText: _affiliationType == 'AFFILIATE'
? tr('ui.userfront.signup.profile.department')
: tr(
'ui.userfront.signup.profile.department_optional',
),
: tr('ui.userfront.signup.profile.department_optional'),
border: const OutlineInputBorder(),
),
),
@@ -1076,9 +1002,7 @@ class _SignupScreenState extends State<SignupScreen> {
String _buildPolicyDescription() {
if (_isPolicyLoading) {
return tr(
'msg.userfront.signup.policy.loading',
);
return tr('msg.userfront.signup.policy.loading');
}
final minLength = (_policy?['minLength'] as int?) ?? 12;
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
@@ -1147,9 +1071,7 @@ class _SignupScreenState extends State<SignupScreen> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
tr(
'msg.userfront.signup.password.title',
),
tr('msg.userfront.signup.password.title'),
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
@@ -1183,9 +1105,7 @@ class _SignupScreenState extends State<SignupScreen> {
obscureText: true,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
labelText: tr(
'ui.userfront.signup.password.label',
),
labelText: tr('ui.userfront.signup.password.label'),
border: const OutlineInputBorder(),
errorText: _passwordError,
),
@@ -1211,16 +1131,12 @@ class _SignupScreenState extends State<SignupScreen> {
),
if (requiresUpper)
_cryptoCheck(
tr(
'msg.userfront.signup.password.rule.uppercase',
),
tr('msg.userfront.signup.password.rule.uppercase'),
hasUpper,
),
if (requiresLower)
_cryptoCheck(
tr(
'msg.userfront.signup.password.rule.lowercase',
),
tr('msg.userfront.signup.password.rule.lowercase'),
hasLower,
),
if (requiresNumber)
@@ -1230,9 +1146,7 @@ class _SignupScreenState extends State<SignupScreen> {
),
if (requiresSymbol)
_cryptoCheck(
tr(
'msg.userfront.signup.password.rule.symbol',
),
tr('msg.userfront.signup.password.rule.symbol'),
hasSpecial,
),
],
@@ -1244,16 +1158,12 @@ class _SignupScreenState extends State<SignupScreen> {
onChanged: (val) {
setState(() {
_confirmPasswordError = (val != _passwordController.text)
? tr(
'msg.userfront.signup.password.mismatch',
)
? tr('msg.userfront.signup.password.mismatch')
: null;
});
},
decoration: InputDecoration(
labelText: tr(
'ui.userfront.signup.password.confirm_label',
),
labelText: tr('ui.userfront.signup.password.confirm_label'),
border: const OutlineInputBorder(),
errorText: _confirmPasswordError,
),
@@ -1379,12 +1289,8 @@ class _SignupScreenState extends State<SignupScreen> {
)
: Text(
_currentStep < 4
? tr(
'ui.userfront.signup.next_step',
)
: tr(
'ui.userfront.signup.complete',
),
? tr('ui.userfront.signup.next_step')
: tr('ui.userfront.signup.complete'),
),
),
),
@@ -172,9 +172,7 @@ class AuthTimelineNotifier extends Notifier<AuthTimelineState> {
state = state.copyWith(
isLoading: false,
isLoadingMore: false,
error: tr(
'msg.userfront.dashboard.timeline.load_error',
),
error: tr('msg.userfront.dashboard.timeline.load_error'),
);
}
}
@@ -71,9 +71,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(
tr('ui.userfront.dashboard.revoke.title'),
),
title: Text(tr('ui.userfront.dashboard.revoke.title')),
content: Text(
tr(
'msg.userfront.dashboard.revoke.confirm',
@@ -88,11 +86,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(
tr(
'ui.userfront.dashboard.revoke.confirm_button',
),
),
child: Text(tr('ui.userfront.dashboard.revoke.confirm_button')),
),
],
),
@@ -166,17 +160,13 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tr(
'ui.userfront.dashboard.scopes.title',
),
tr('ui.userfront.dashboard.scopes.title'),
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
if (item.scopes.isEmpty)
Text(
tr(
'msg.userfront.dashboard.scopes.empty',
),
tr('msg.userfront.dashboard.scopes.empty'),
style: const TextStyle(color: Colors.grey),
)
else
@@ -199,9 +189,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
const SizedBox(height: 24),
Text(
tr(
'ui.userfront.dashboard.status_history',
),
tr('ui.userfront.dashboard.status_history'),
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
@@ -219,9 +207,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
builder: (context) {
final statusLabel = item.status == 'active'
? tr('ui.common.status.active')
: tr(
'ui.userfront.dashboard.status.revoked',
);
: tr('ui.userfront.dashboard.status.revoked');
return Text(
tr(
'msg.userfront.dashboard.current_status',
@@ -534,12 +520,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
? log.detailMap['approved_session_id'].toString()
: log.sessionId;
final tooltipLabel = isOidc
? tr(
'ui.userfront.dashboard.approved_session.userfront',
)
: tr(
'ui.userfront.dashboard.approved_session.default',
);
? tr('ui.userfront.dashboard.approved_session.userfront')
: tr('ui.userfront.dashboard.approved_session.default');
final tooltip = approvedSessionId.isEmpty
? tr(
'msg.userfront.dashboard.approved_session.none',
@@ -558,9 +540,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr(
'msg.userfront.dashboard.session_id_copied',
),
tr('msg.userfront.dashboard.session_id_copied'),
),
),
);
@@ -628,12 +608,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
? log.detailMap['approved_session_id'].toString()
: log.sessionId;
final tooltipLabel = isOidc
? tr(
'ui.userfront.dashboard.approved_session.userfront',
)
: tr(
'ui.userfront.dashboard.approved_session.default',
);
? tr('ui.userfront.dashboard.approved_session.userfront')
: tr('ui.userfront.dashboard.approved_session.default');
return InkWell(
onTap: approvedSessionId.isEmpty
? null
@@ -643,9 +619,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr(
'msg.userfront.dashboard.session_id_copied',
),
tr('msg.userfront.dashboard.session_id_copied'),
),
),
);
@@ -692,9 +666,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final label = _appLabelForLog(log);
final clientId = log.clientId;
final tooltip = clientId.isEmpty
? tr(
'msg.userfront.dashboard.client_id_missing',
)
? tr('msg.userfront.dashboard.client_id_missing')
: tr(
'msg.userfront.dashboard.client_id',
fallback: 'Client ID: {{id}}',
@@ -814,21 +786,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
const SizedBox(height: 28),
],
_buildSectionTitle(
tr(
'ui.userfront.sections.apps',
),
tr(
'msg.userfront.sections.apps_subtitle',
),
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',
),
tr('msg.userfront.sections.audit_subtitle'),
),
const SizedBox(height: 12),
_buildAccessHistory(timelineState, timelineWide),
@@ -857,10 +823,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tr(
'msg.userfront.greeting',
params: {'name': userName},
),
tr('msg.userfront.greeting', params: {'name': userName}),
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
@@ -963,9 +926,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tr(
'msg.userfront.dashboard.activities.empty',
),
tr('msg.userfront.dashboard.activities.empty'),
style: TextStyle(
fontSize: 14,
color: Colors.grey[700],
@@ -974,9 +935,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
const SizedBox(height: 6),
Text(
tr(
'msg.userfront.dashboard.activities.empty_detail',
),
tr('msg.userfront.dashboard.activities.empty_detail'),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
],
@@ -992,9 +951,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tr(
'msg.userfront.dashboard.activities.error',
),
tr('msg.userfront.dashboard.activities.error'),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
const SizedBox(height: 8),
@@ -1194,9 +1151,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
child: Text(
item.status == 'active'
? tr('ui.common.status.active')
: tr(
'ui.userfront.dashboard.status.revoked',
),
: tr('ui.userfront.dashboard.status.revoked'),
style: TextStyle(
fontSize: 11,
color: statusColor,
@@ -1264,12 +1219,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
)
: Text(
item.isRevoked
? tr(
'ui.userfront.dashboard.status.revoked',
)
: tr(
'ui.userfront.dashboard.revoke.title',
),
? tr('ui.userfront.dashboard.status.revoked')
: tr('ui.userfront.dashboard.revoke.title'),
style: const TextStyle(fontSize: 13),
),
),
@@ -1303,22 +1254,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
}
messenger.showSnackBar(
SnackBar(
content: Text(
tr(
'msg.userfront.dashboard.link_open_error',
),
),
content: Text(tr('msg.userfront.dashboard.link_open_error')),
),
);
} else {
if (!mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text(
tr(
'msg.userfront.dashboard.link_missing',
),
),
content: Text(tr('msg.userfront.dashboard.link_missing')),
),
);
}
@@ -1344,11 +1287,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
tr(
'msg.userfront.dashboard.audit_load_error',
),
),
Text(tr('msg.userfront.dashboard.audit_load_error')),
const SizedBox(height: 8),
TextButton(
onPressed: () =>
@@ -1365,9 +1304,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
return _buildHistoryContainer(
child: Center(
child: Text(
tr(
'msg.userfront.dashboard.audit_empty',
),
tr('msg.userfront.dashboard.audit_empty'),
style: TextStyle(color: Colors.grey[600]),
),
),
@@ -1416,16 +1353,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
),
DataColumn(
label: Text(
tr('ui.userfront.audit.table.date'),
),
label: Text(tr('ui.userfront.audit.table.date')),
),
DataColumn(
label: Text(
tr(
'ui.userfront.audit.table.app',
),
),
label: Text(tr('ui.userfront.audit.table.app')),
),
DataColumn(
label: Text(
@@ -1433,30 +1364,16 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
),
DataColumn(
label: Text(
tr(
'ui.userfront.audit.table.device',
),
),
label: Text(tr('ui.userfront.audit.table.device')),
),
DataColumn(
label: Text(
tr(
'ui.userfront.audit.table.auth_method',
),
),
label: Text(tr('ui.userfront.audit.table.auth_method')),
),
DataColumn(
label: Text(
tr(
'ui.userfront.audit.table.result',
),
),
label: Text(tr('ui.userfront.audit.table.result')),
),
DataColumn(
label: Text(
tr('ui.userfront.audit.table.status'),
),
label: Text(tr('ui.userfront.audit.table.status')),
),
],
rows: state.items.map((log) {
@@ -1505,9 +1422,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
DataCell(
_selectableText(
tr(
'ui.userfront.audit.table.pending',
),
tr('ui.userfront.audit.table.pending'),
style: const TextStyle(color: Colors.grey),
),
),
@@ -1643,11 +1558,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
tr(
'msg.userfront.audit.load_more_error',
),
),
Text(tr('msg.userfront.audit.load_more_error')),
TextButton(
onPressed: () =>
ref.read(authTimelineProvider.notifier).loadMore(),
@@ -25,9 +25,7 @@ class ProfileRepository {
final token = await _getToken();
final useCookie = AuthTokenStore.usesCookie();
if (token == null && !useCookie) {
throw Exception(
tr('err.userfront.session.missing'),
);
throw Exception(tr('err.userfront.session.missing'));
}
final url = Uri.parse('$_baseUrl/api/v1/user/me');
@@ -59,9 +57,7 @@ class ProfileRepository {
final token = await _getToken();
final useCookie = AuthTokenStore.usesCookie();
if (token == null && !useCookie) {
throw Exception(
tr('err.userfront.session.missing'),
);
throw Exception(tr('err.userfront.session.missing'));
}
final url = Uri.parse('$_baseUrl/api/v1/user/me');
@@ -95,9 +91,7 @@ class ProfileRepository {
final token = await _getToken();
final useCookie = AuthTokenStore.usesCookie();
if (token == null && !useCookie) {
throw Exception(
tr('err.userfront.session.missing'),
);
throw Exception(tr('err.userfront.session.missing'));
}
final url = Uri.parse('$_baseUrl/api/v1/user/me/send-code');
@@ -130,9 +124,7 @@ class ProfileRepository {
final token = await _getToken();
final useCookie = AuthTokenStore.usesCookie();
if (token == null && !useCookie) {
throw Exception(
tr('err.userfront.session.missing'),
);
throw Exception(tr('err.userfront.session.missing'));
}
final url = Uri.parse('$_baseUrl/api/v1/user/me/password');
@@ -165,9 +157,7 @@ class ProfileRepository {
final token = await _getToken();
final useCookie = AuthTokenStore.usesCookie();
if (token == null && !useCookie) {
throw Exception(
tr('err.userfront.session.missing'),
);
throw Exception(tr('err.userfront.session.missing'));
}
final url = Uri.parse('$_baseUrl/api/v1/user/me/verify-code');
@@ -232,13 +232,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr(
'msg.userfront.profile.phone.code_sent',
),
),
),
SnackBar(content: Text(tr('msg.userfront.profile.phone.code_sent'))),
);
}
} catch (e) {
@@ -272,11 +266,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr('msg.userfront.profile.phone.verified'),
),
),
SnackBar(content: Text(tr('msg.userfront.profile.phone.verified'))),
);
}
if (_editingField == 'phone') {
@@ -315,17 +305,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
}
if (newPassword.isEmpty) {
setState(
() => _passwordError = tr(
'msg.userfront.profile.password.new_required',
),
() =>
_passwordError = tr('msg.userfront.profile.password.new_required'),
);
return;
}
if (newPassword != confirmPassword) {
setState(
() => _passwordError = tr(
'msg.userfront.profile.password.mismatch',
),
() => _passwordError = tr('msg.userfront.profile.password.mismatch'),
);
return;
}
@@ -347,9 +334,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_newPasswordController?.clear();
_confirmPasswordController?.clear();
setState(() {
_passwordSuccess = tr(
'msg.userfront.profile.password.changed',
);
_passwordSuccess = tr('msg.userfront.profile.password.changed');
});
} catch (e) {
final message = e.toString().replaceFirst('Exception: ', '');
@@ -431,22 +416,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
if (_editingField == 'name' && nextName.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr('msg.userfront.profile.name_required'),
),
),
SnackBar(content: Text(tr('msg.userfront.profile.name_required'))),
);
return;
}
if (_editingField == 'department' && nextDepartment.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr(
'msg.userfront.profile.department_required',
),
),
content: Text(tr('msg.userfront.profile.department_required')),
),
);
return;
@@ -454,24 +431,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
if (_editingField == 'phone') {
if (nextPhone.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr(
'msg.userfront.profile.phone_required',
),
),
),
SnackBar(content: Text(tr('msg.userfront.profile.phone_required'))),
);
return;
}
if (_isPhoneChanged && !_isPhoneVerified) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr(
'msg.userfront.profile.phone_verify_required',
),
),
content: Text(tr('msg.userfront.profile.phone_verify_required')),
),
);
return;
@@ -511,13 +478,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_departmentTouched = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr(
'msg.userfront.profile.update_success',
),
),
),
SnackBar(content: Text(tr('msg.userfront.profile.update_success'))),
);
}
} catch (e) {
@@ -657,10 +618,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tr(
'msg.userfront.profile.greeting',
params: {'name': name},
),
tr('msg.userfront.profile.greeting', params: {'name': name}),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
@@ -833,9 +791,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
child: Text(
_isCodeSent
? tr('ui.common.resend')
: tr(
'ui.userfront.profile.phone.request_code',
),
: tr('ui.userfront.profile.phone.request_code'),
),
),
const SizedBox(width: 8),
@@ -859,9 +815,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
onSubmitted: (_) => _verifyCode(profile),
decoration: InputDecoration(
border: const OutlineInputBorder(),
hintText: tr(
'ui.userfront.profile.phone.code_hint',
),
hintText: tr('ui.userfront.profile.phone.code_hint'),
),
),
),
@@ -877,9 +831,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
tr(
'msg.userfront.profile.phone.verify_notice',
),
tr('msg.userfront.profile.phone.verify_notice'),
style: const TextStyle(color: Colors.orange, fontSize: 12),
),
),
@@ -898,9 +850,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
const SizedBox(height: 8),
Text(
tr(
'msg.userfront.profile.password.subtitle',
),
tr('msg.userfront.profile.password.subtitle'),
style: const TextStyle(color: Color(0xFF6B7280)),
),
const SizedBox(height: 16),
@@ -908,9 +858,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
controller: _currentPasswordController,
obscureText: !_showCurrentPassword,
decoration: InputDecoration(
labelText: tr(
'ui.userfront.profile.password.current',
),
labelText: tr('ui.userfront.profile.password.current'),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(
@@ -929,9 +877,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
controller: _newPasswordController,
obscureText: !_showNewPassword,
decoration: InputDecoration(
labelText: tr(
'ui.userfront.profile.password.new',
),
labelText: tr('ui.userfront.profile.password.new'),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(
@@ -948,9 +894,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
controller: _confirmPasswordController,
obscureText: !_showConfirmPassword,
decoration: InputDecoration(
labelText: tr(
'ui.userfront.profile.password.confirm',
),
labelText: tr('ui.userfront.profile.password.confirm'),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(
@@ -986,20 +930,12 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(
tr(
'ui.userfront.profile.password.change',
),
),
: Text(tr('ui.userfront.profile.password.change')),
),
const SizedBox(width: 12),
TextButton(
onPressed: () => context.go('/recovery'),
child: Text(
tr(
'ui.userfront.profile.password.forgot',
),
),
child: Text(tr('ui.userfront.profile.password.forgot')),
),
],
),
@@ -1024,9 +960,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
const SizedBox(height: 28),
_buildSectionTitle(
tr('ui.userfront.profile.section.basic'),
tr(
'msg.userfront.profile.section.basic',
),
tr('msg.userfront.profile.section.basic'),
),
const SizedBox(height: 12),
_buildCard(
@@ -1034,9 +968,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
children: [
_buildEditableTile(
field: 'name',
label: tr(
'ui.userfront.profile.field.name',
),
label: tr('ui.userfront.profile.field.name'),
value: profile.name,
profile: profile,
isUpdating: isUpdating,
@@ -1044,9 +976,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
const Divider(height: 24),
_buildReadOnlyTile(
tr(
'ui.userfront.profile.field.email',
),
tr('ui.userfront.profile.field.email'),
profile.email,
),
const Divider(height: 24),
@@ -1056,12 +986,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
const SizedBox(height: 28),
_buildSectionTitle(
tr(
'ui.userfront.profile.section.organization',
),
tr(
'msg.userfront.profile.section.organization',
),
tr('ui.userfront.profile.section.organization'),
tr('msg.userfront.profile.section.organization'),
),
const SizedBox(height: 12),
_buildCard(
@@ -1069,9 +995,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
children: [
_buildEditableTile(
field: 'department',
label: tr(
'ui.userfront.profile.field.department',
),
label: tr('ui.userfront.profile.field.department'),
value: profile.department,
profile: profile,
isUpdating: isUpdating,
@@ -1079,26 +1003,20 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
const Divider(height: 24),
_buildReadOnlyTile(
tr(
'ui.userfront.profile.field.affiliation',
),
tr('ui.userfront.profile.field.affiliation'),
profile.affiliationType,
),
if (profile.tenant != null) ...[
const Divider(height: 24),
_buildReadOnlyTile(
tr(
'ui.userfront.profile.field.tenant',
),
tr('ui.userfront.profile.field.tenant'),
profile.tenant!.name,
),
],
if (profile.companyCode.isNotEmpty) ...[
const Divider(height: 24),
_buildReadOnlyTile(
tr(
'ui.userfront.profile.field.company_code',
),
tr('ui.userfront.profile.field.company_code'),
profile.companyCode,
),
],
@@ -1108,9 +1026,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
const SizedBox(height: 28),
_buildSectionTitle(
tr('ui.userfront.profile.section.security'),
tr(
'msg.userfront.profile.section.security',
),
tr('msg.userfront.profile.section.security'),
),
const SizedBox(height: 12),
_buildPasswordSection(),
@@ -1137,20 +1053,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
final profile = profileState.value ?? _cachedProfile;
if (profile == null) {
return Scaffold(
appBar: AppBar(
title: Text(tr('ui.userfront.nav.profile')),
),
appBar: AppBar(title: Text(tr('ui.userfront.nav.profile'))),
body: profileState.isLoading
? const Center(child: CircularProgressIndicator())
: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
tr(
'msg.userfront.profile.load_failed',
),
),
Text(tr('msg.userfront.profile.load_failed')),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () =>
+126 -63
View File
@@ -29,7 +29,8 @@ const Map<String, String> koStrings = {
"msg.admin.api_keys.create.scopes_count": "총 {{count}}개의 권한이 할당됩니다.",
"msg.admin.api_keys.create.scopes_hint": "생성 즉시 활성화되어 사용 가능합니다.",
"msg.admin.api_keys.create.subtitle": "내부 시스템 연동을 위한 보안 인증 키를 구성합니다.",
"msg.admin.api_keys.create.success.copy_hint": "복사 버튼을 눌러 안전한 곳(비밀번호 관리자 등)에 저장하세요.",
"msg.admin.api_keys.create.success.copy_hint":
"복사 버튼을 눌러 안전한 곳(비밀번호 관리자 등)에 저장하세요.",
"msg.admin.api_keys.create.success.notice": "아래의 비밀번호(Secret)는 보안을 위해 ",
"msg.admin.api_keys.create.success.notice_emphasis": "지금 한 번만",
"msg.admin.api_keys.create.success.notice_suffix": "표시됩니다.",
@@ -37,14 +38,16 @@ const Map<String, String> koStrings = {
"msg.admin.api_keys.list.empty": "등록된 API 키가 없습니다.",
"msg.admin.api_keys.list.fetch_error": "API 키 목록 조회에 실패했습니다.",
"msg.admin.api_keys.list.registry.count": "총 {{count}}개 API 키",
"msg.admin.api_keys.list.subtitle": "서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고 관리합니다.",
"msg.admin.api_keys.list.subtitle":
"서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고 관리합니다.",
"msg.admin.audit.empty": "아직 수집된 감사 로그가 없습니다.",
"msg.admin.audit.end": "End of audit feed",
"msg.admin.audit.filters.empty": "필터 없음",
"msg.admin.audit.load_error": "Error loading logs: {{error}}",
"msg.admin.audit.loading": "Loading audit logs...",
"msg.admin.audit.registry.count": "로드된 로그 {{count}}건",
"msg.admin.audit.subtitle": "Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 추후 세션 연동 시 자동 채워집니다.",
"msg.admin.audit.subtitle":
"Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 추후 세션 연동 시 자동 채워집니다.",
"msg.admin.groups.list.subtitle": "이 테넌트에 정의된 사용자 그룹 목록입니다.",
"msg.admin.groups.members.count": "{{count}} 명",
"msg.admin.groups.members.empty": "멤버가 없습니다.",
@@ -52,33 +55,43 @@ const Map<String, String> koStrings = {
"msg.admin.groups.prompt.user_id": "추가할 사용자의 UUID를 입력하세요:",
"msg.admin.header.subtitle": "Tenant isolation & least privilege by default",
"msg.admin.idp_env_prod": "IDP env: prod",
"msg.admin.notice.idp_policy": "IDP 관리 키는 서버 내부 래핑 API로만 사용하며, 감사·레이트리밋을 기본 적용합니다.",
"msg.admin.notice.idp_policy":
"IDP 관리 키는 서버 내부 래핑 API로만 사용하며, 감사·레이트리밋을 기본 적용합니다.",
"msg.admin.notice.scope": "관리 기능은 /admin 네임스페이스에서만 노출합니다.",
"msg.admin.overview.description": "모든 테넌트 공통 지표와 정책 상태를 한 곳에서 확인합니다.",
"msg.admin.overview.idp_fallback": "Fallback: Descope",
"msg.admin.overview.idp_primary": "IDP: Ory primary",
"msg.admin.overview.playbook.description": "운영 정책, 레이트리밋, 감사 로그의 기본 룰을 요약합니다.",
"msg.admin.overview.playbook.idp_body": "모든 IDP 호출은 backend를 통해서만 수행하며, Hydra/Kratos admin 포트는 외부에 노출하지 않습니다.",
"msg.admin.overview.playbook.description":
"운영 정책, 레이트리밋, 감사 로그의 기본 룰을 요약합니다.",
"msg.admin.overview.playbook.idp_body":
"모든 IDP 호출은 backend를 통해서만 수행하며, Hydra/Kratos admin 포트는 외부에 노출하지 않습니다.",
"msg.admin.overview.playbook.idp_title": "Backend-only IDP access",
"msg.admin.overview.playbook.tenant_body": "Tenant 헤더와 감사 로그 규칙을 기본 적용하며, 향후 Keto 정책으로 확장 예정입니다.",
"msg.admin.overview.playbook.tenant_body":
"Tenant 헤더와 감사 로그 규칙을 기본 적용하며, 향후 Keto 정책으로 확장 예정입니다.",
"msg.admin.overview.playbook.tenant_title": "Tenant isolation",
"msg.admin.overview.quick_links.description": "주요 운영 화면으로 바로 이동합니다.",
"msg.admin.scope_admin": "Scoped to /admin",
"msg.admin.session_ttl": "Session TTL: 15m admin",
"msg.admin.tenant_headers": "Tenant-aware headers",
"msg.admin.tenants.create.form.domains_help": "Users with these email domains will be automatically assigned to this tenant.",
"msg.admin.tenants.create.memo.body": "생성 직후에는 기본 활성 상태로 부여되며, 필요 시 상태를 수정하세요.",
"msg.admin.tenants.create.memo.subtitle": "Tenant 권한 정책은 추후 Keto 연계로 확장 예정입니다.",
"msg.admin.tenants.create.profile.subtitle": "필수 정보만 입력해도 생성 가능합니다. Slug는 없으면 자동 생성됩니다.",
"msg.admin.tenants.create.form.domains_help":
"Users with these email domains will be automatically assigned to this tenant.",
"msg.admin.tenants.create.memo.body":
"생성 직후에는 기본 활성 상태로 부여되며, 필요 시 상태를 수정하세요.",
"msg.admin.tenants.create.memo.subtitle":
"Tenant 권한 정책은 추후 Keto 연계로 확장 예정입니다.",
"msg.admin.tenants.create.profile.subtitle":
"필수 정보만 입력해도 생성 가능합니다. Slug는 없으면 자동 생성됩니다.",
"msg.admin.tenants.create.subtitle": "글로벌 운영 기준의 신규 테넌트를 등록합니다.",
"msg.admin.tenants.delete_confirm": "테넌트 \\\"{{name}}\\\"를 삭제할까요?",
"msg.admin.tenants.empty": "아직 등록된 테넌트가 없습니다.",
"msg.admin.tenants.fetch_error": "테넌트 목록 조회에 실패했습니다.",
"msg.admin.tenants.members.empty": "소속된 사용자가 없습니다.",
"msg.admin.tenants.registry.count": "총 {{count}}개 테넌트",
"msg.admin.tenants.schema.empty": "No custom fields defined. Click \\\"Add Field\\\" to begin.",
"msg.admin.tenants.schema.empty":
"No custom fields defined. Click \\\"Add Field\\\" to begin.",
"msg.admin.tenants.schema.missing_id": "Tenant ID missing",
"msg.admin.tenants.schema.subtitle": "Define custom attributes for users in this tenant.",
"msg.admin.tenants.schema.subtitle":
"Define custom attributes for users in this tenant.",
"msg.admin.tenants.schema.update_error": "Failed to update schema",
"msg.admin.tenants.schema.update_success": "Schema updated successfully",
"msg.admin.tenants.sub.empty": "하위 테넌트가 없습니다.",
@@ -88,19 +101,23 @@ const Map<String, String> koStrings = {
"msg.admin.users.create.error": "사용자 생성에 실패했습니다.",
"msg.admin.users.create.form.email_required": "이메일은 필수입니다.",
"msg.admin.users.create.form.name_required": "이름은 필수입니다.",
"msg.admin.users.create.form.password_auto_help": "비워두면 시스템이 초기 비밀번호를 자동 생성합니다.",
"msg.admin.users.create.form.password_auto_help":
"비워두면 시스템이 초기 비밀번호를 자동 생성합니다.",
"msg.admin.users.create.form.password_manual_help": "초기 비밀번호를 직접 설정합니다.",
"msg.admin.users.create.form.role_help": "시스템 접근 권한을 결정합니다.",
"msg.admin.users.create.password_generated.default": "초기 비밀번호가 생성되었습니다.",
"msg.admin.users.create.password_generated.with_email": "{{email}} 계정의 초기 비밀번호입니다.",
"msg.admin.users.create.password_generated.with_email":
"{{email}} 계정의 초기 비밀번호입니다.",
"msg.admin.users.create.password_required": "비밀번호를 입력하거나 자동 생성을 사용해 주세요.",
"msg.admin.users.detail.edit_subtitle": "{{email}} 계정의 정보를 수정합니다.",
"msg.admin.users.detail.form.name_required": "이름은 필수입니다.",
"msg.admin.users.detail.not_found": "사용자를 찾을 수 없습니다.",
"msg.admin.users.detail.security.password_hint": "비밀번호를 변경하려면 입력하세요. 비워두면 현재 비밀번호가 유지됩니다.",
"msg.admin.users.detail.security.password_hint":
"비밀번호를 변경하려면 입력하세요. 비워두면 현재 비밀번호가 유지됩니다.",
"msg.admin.users.detail.update_error": "사용자 수정에 실패했습니다.",
"msg.admin.users.detail.update_success": "사용자 정보가 수정되었습니다.",
"msg.admin.users.list.delete_confirm": "사용자 \\\"{{name}}\\\"을(를) 정말 삭제하시겠습니까?",
"msg.admin.users.list.delete_confirm":
"사용자 \\\"{{name}}\\\"을(를) 정말 삭제하시겠습니까?",
"msg.admin.users.list.empty": "검색 결과가 없습니다.",
"msg.admin.users.list.fetch_error": "사용자 목록 조회에 실패했습니다.",
"msg.admin.users.list.registry.count": "총 {{count}}명의 사용자가 등록되어 있습니다.",
@@ -111,7 +128,8 @@ const Map<String, String> koStrings = {
"msg.dev.clients.consents.empty": "No consents found.",
"msg.dev.clients.consents.load_error": "Error loading consents: {{error}}",
"msg.dev.clients.consents.loading": "Loading consents...",
"msg.dev.clients.consents.showing": "Showing {{from}} to {{to}} of {{total}} users",
"msg.dev.clients.consents.showing":
"Showing {{from}} to {{to}} of {{total}} users",
"msg.dev.clients.consents.subtitle": "OIDC Relying Party 사용자 권한을 검토·관리합니다.",
"msg.dev.clients.copy_client_id": "클라이언트 ID가 복사되었습니다.",
"msg.dev.clients.details.copy_client_id": "Client ID가 복사되었습니다.",
@@ -120,39 +138,51 @@ const Map<String, String> koStrings = {
"msg.dev.clients.details.load_error": "Error loading client: {{error}}",
"msg.dev.clients.details.loading": "Loading client...",
"msg.dev.clients.details.missing_id": "Client ID가 필요합니다.",
"msg.dev.clients.details.redirect.description": "인증 성공 후 사용자를 리다이렉트할 허용된 URL 목록입니다. 콤마(,)로 구분하여 여러 개 입력할 수 있습니다.",
"msg.dev.clients.details.redirect.description":
"인증 성공 후 사용자를 리다이렉트할 허용된 URL 목록입니다. 콤마(,)로 구분하여 여러 개 입력할 수 있습니다.",
"msg.dev.clients.details.redirect_saved": "Redirect URIs가 저장되었습니다.",
"msg.dev.clients.details.rotate_confirm": "경고: Client Secret을 재발급하면 기존 시크릿은 즉시 무효화됩니다.\\\\n연동된 애플리케이션이 중단될 수 있습니다. 계속하시겠습니까?",
"msg.dev.clients.details.rotate_confirm":
"경고: Client Secret을 재발급하면 기존 시크릿은 즉시 무효화됩니다.\\\\n연동된 애플리케이션이 중단될 수 있습니다. 계속하시겠습니까?",
"msg.dev.clients.details.rotate_error": "재발급 실패: {{error}}",
"msg.dev.clients.details.save_error": "저장 실패: {{error}}",
"msg.dev.clients.details.secret_rotated": "Client Secret이 재발급되었습니다.",
"msg.dev.clients.details.secret_unavailable": "SECRET_NOT_AVAILABLE",
"msg.dev.clients.details.security.footer": "비밀키 재발행 작업에는 관리자 세션 TTL 확인과 레이트리밋, 알림 연동을 권장합니다.",
"msg.dev.clients.details.security.note": "엔드포인트는 읽기 전용으로 유지하고, 비밀키 재발행/복사는 감사 로그와 연계하세요.",
"msg.dev.clients.details.security.footer":
"비밀키 재발행 작업에는 관리자 세션 TTL 확인과 레이트리밋, 알림 연동을 권장합니다.",
"msg.dev.clients.details.security.note":
"엔드포인트는 읽기 전용으로 유지하고, 비밀키 재발행/복사는 감사 로그와 연계하세요.",
"msg.dev.clients.details.subtitle": "OIDC 자격 증명과 엔드포인트를 관리합니다.",
"msg.dev.clients.general.identity.logo_help": "인증 화면에 표시될 PNG/SVG URL입니다.",
"msg.dev.clients.general.identity.subtitle": "앱 이름과 설명, 로고를 설정합니다.",
"msg.dev.clients.general.load_error": "Error loading client: {{error}}",
"msg.dev.clients.general.loading": "Loading client...",
"msg.dev.clients.general.redirect.help": "인증 후 리다이렉트될 URI를 입력하세요. 생성 후 Connection 탭에서 수정 가능합니다.",
"msg.dev.clients.general.redirect.help":
"인증 후 리다이렉트될 URI를 입력하세요. 생성 후 Connection 탭에서 수정 가능합니다.",
"msg.dev.clients.general.saved": "설정이 저장되었습니다.",
"msg.dev.clients.general.scopes.empty": "등록된 스코프가 없습니다.",
"msg.dev.clients.general.scopes.subtitle": "이 클라이언트가 요청할 수 있는 권한 범위를 정의합니다.",
"msg.dev.clients.general.security.confidential_help": "서버 사이드 앱(예: Node.js, Java)처럼 비밀키를 안전하게 보관 가능한 경우.",
"msg.dev.clients.general.security.public_help": "SPA/모바일 앱처럼 비밀키 보관이 어려운 경우. PKCE를 기본 사용합니다.",
"msg.dev.clients.general.security.subtitle": "클라이언트 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다.",
"msg.dev.clients.help.docs_body": "Includes PKCE, client_secret_basic, redirect URI validation tips.",
"msg.dev.clients.help.subtitle": "Developer guides for Confidential/Public clients, redirect URIs, and auth methods.",
"msg.dev.clients.general.security.confidential_help":
"서버 사이드 앱(예: Node.js, Java)처럼 비밀키를 안전하게 보관 가능한 경우.",
"msg.dev.clients.general.security.public_help":
"SPA/모바일 앱처럼 비밀키 보관이 어려운 경우. PKCE를 기본 사용합니다.",
"msg.dev.clients.general.security.subtitle":
"클라이언트 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다.",
"msg.dev.clients.help.docs_body":
"Includes PKCE, client_secret_basic, redirect URI validation tips.",
"msg.dev.clients.help.subtitle":
"Developer guides for Confidential/Public clients, redirect URIs, and auth methods.",
"msg.dev.clients.load_error": "Error loading clients: {{error}}",
"msg.dev.clients.loading": "Loading clients...",
"msg.dev.clients.registry.description": "OIDC 클라이언트, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다.",
"msg.dev.clients.registry.description":
"OIDC 클라이언트, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다.",
"msg.dev.clients.scopes.email": "이메일 주소 접근",
"msg.dev.clients.scopes.openid": "OIDC 인증 필수 스코프",
"msg.dev.clients.scopes.profile": "기본 프로필 정보 접근",
"msg.dev.clients.showing": "Showing {{shown}} of {{total}} clients",
"msg.dev.clients.status_update_error": "Failed to update client status",
"msg.dev.clients.status_updated": "클라이언트가 {{status}}되었습니다.",
"msg.dev.dashboard.hero.body": "Hydra Admin API와 동기화된 RP 목록, 상태 토글, Consent 회수까지 devfront에서 처리하도록 준비합니다.",
"msg.dev.dashboard.hero.body":
"Hydra Admin API와 동기화된 RP 목록, 상태 토글, Consent 회수까지 devfront에서 처리하도록 준비합니다.",
"msg.dev.dashboard.hero.title_emphasis": " 하나의 화면",
"msg.dev.dashboard.hero.title_prefix": "RP 등록 현황과 Consent 상태를",
"msg.dev.dashboard.hero.title_suffix": "에서 관리합니다.",
@@ -171,12 +201,15 @@ const Map<String, String> koStrings = {
"msg.userfront.audit.session_id": "Session ID: {{value}}",
"msg.userfront.audit.status": "현황: (준비중)",
"msg.userfront.dashboard.activities.empty": "연동된 앱이 없습니다.",
"msg.userfront.dashboard.activities.empty_detail": "앱을 연동하면 최근 활동과 상태가 표시됩니다.",
"msg.userfront.dashboard.activities.empty_detail":
"앱을 연동하면 최근 활동과 상태가 표시됩니다.",
"msg.userfront.dashboard.activities.error": "연동 정보를 불러오지 못했습니다.",
"msg.userfront.dashboard.approved_device": "승인 기기: {{device}}",
"msg.userfront.dashboard.approved_ip": "승인 IP: {{ip}}",
"msg.userfront.dashboard.approved_session.copy_click": "{{label}}: {{id}}\\\\\\\\n클릭하면 복사됩니다.",
"msg.userfront.dashboard.approved_session.copy_tap": "{{label}}: {{id}}\\\\\\\\n탭하면 복사됩니다.",
"msg.userfront.dashboard.approved_session.copy_click":
"{{label}}: {{id}}\\\\\\\\n클릭하면 복사됩니다.",
"msg.userfront.dashboard.approved_session.copy_tap":
"{{label}}: {{id}}\\\\\\\\n탭하면 복사됩니다.",
"msg.userfront.dashboard.approved_session.none": "{{label}} 없음",
"msg.userfront.dashboard.audit_empty": "최근 접속 이력이 없습니다.",
"msg.userfront.dashboard.audit_load_error": "접속이력을 불러오지 못했습니다.",
@@ -187,7 +220,8 @@ const Map<String, String> koStrings = {
"msg.userfront.dashboard.last_auth": "최근 인증: {{value}}",
"msg.userfront.dashboard.link_missing": "이동할 페이지 주소(Client URI)가 설정되지 않았습니다.",
"msg.userfront.dashboard.link_open_error": "해당 링크를 열 수 없습니다.",
"msg.userfront.dashboard.revoke.confirm": "{{app}} 앱과의 연동을 해지하시겠습니까?\\\\\\\\n해지하면 다음 로그인 시 다시 동의가 필요합니다.",
"msg.userfront.dashboard.revoke.confirm":
"{{app}} 앱과의 연동을 해지하시겠습니까?\\\\\\\\n해지하면 다음 로그인 시 다시 동의가 필요합니다.",
"msg.userfront.dashboard.revoke.error": "해지 실패: {{error}}",
"msg.userfront.dashboard.revoke.success": "{{app}} 연동이 해지되었습니다.",
"msg.userfront.dashboard.scopes.empty": "요청된 권한이 없습니다.",
@@ -202,7 +236,8 @@ const Map<String, String> koStrings = {
"msg.userfront.error.title_with_code": "오류: {{code}}",
"msg.userfront.error.type": "오류 종류: {{type}}",
"msg.userfront.error.whitelist.\$normalizedCode": "에러가 계속되면 관리자에게 문의해주세요",
"msg.userfront.forgot.description": "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다.",
"msg.userfront.forgot.description":
"계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다.",
"msg.userfront.forgot.dry_send": "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.",
"msg.userfront.forgot.error": "전송에 실패했습니다: {{error}}",
"msg.userfront.forgot.input_required": "이메일 또는 휴대폰 번호를 입력해주세요.",
@@ -215,7 +250,8 @@ const Map<String, String> koStrings = {
"msg.userfront.login.link.missing_login_id": "이메일 또는 휴대폰 번호를 입력해 주세요.",
"msg.userfront.login.link.missing_phone": "휴대폰 번호를 입력해 주세요.",
"msg.userfront.login.link.resend_wait": "재발송은 {{time}} 후 가능합니다.",
"msg.userfront.login.link.short_code_help": "링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다.",
"msg.userfront.login.link.short_code_help":
"링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다.",
"msg.userfront.login.link_failed": "오류: {{error}}",
"msg.userfront.login.link_send_failed": "전송 실패: {{error}}",
"msg.userfront.login.link_sent_email": "입력하신 이메일로 로그인 링크를 보냈습니다.",
@@ -224,7 +260,8 @@ const Map<String, String> koStrings = {
"msg.userfront.login.no_account": "계정이 없으신가요?",
"msg.userfront.login.oidc_failed": "OIDC 로그인 처리에 실패했습니다. 다시 시도해 주세요.",
"msg.userfront.login.password.failed": "로그인 실패: {{error}}",
"msg.userfront.login.password.missing_credentials": "이메일(또는 전화번호)와 비밀번호를 모두 입력해주세요.",
"msg.userfront.login.password.missing_credentials":
"이메일(또는 전화번호)와 비밀번호를 모두 입력해주세요.",
"msg.userfront.login.qr.load_failed": "QR 코드를 불러오지 못했습니다.",
"msg.userfront.login.qr.scan_hint": "모바일 앱으로 스캔하세요",
"msg.userfront.login.qr_expired": "QR 세션이 만료되었습니다.",
@@ -234,7 +271,8 @@ const Map<String, String> koStrings = {
"msg.userfront.login.token_missing": "로그인 토큰을 확인할 수 없습니다.",
"msg.userfront.login.unregistered.body": "가입되지 않은 정보입니다.\\\\n회원가입 후 이용해 주세요.",
"msg.userfront.login.verification.approved": "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
"msg.userfront.login.verification.approved_local": "승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다",
"msg.userfront.login.verification.approved_local":
"승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다",
"msg.userfront.login.verification.success": "로그인 승인에 성공했습니다.",
"msg.userfront.login.verification_failed": "승인 처리에 실패했습니다: {{error}}",
"msg.userfront.login_success.subtitle": "성공적으로 로그인되었습니다.",
@@ -272,7 +310,8 @@ const Map<String, String> koStrings = {
"msg.userfront.reset.error.generic": "비밀번호 변경에 실패했습니다: {{error}}",
"msg.userfront.reset.error.lowercase": "최소 1개 이상의 소문자를 포함해야 합니다.",
"msg.userfront.reset.error.min_length": "비밀번호는 최소 {{count}}자 이상이어야 합니다.",
"msg.userfront.reset.error.min_types": "비밀번호는 영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상 포함해야 합니다.",
"msg.userfront.reset.error.min_types":
"비밀번호는 영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상 포함해야 합니다.",
"msg.userfront.reset.error.mismatch": "비밀번호가 일치하지 않습니다.",
"msg.userfront.reset.error.number": "최소 1개 이상의 숫자를 포함해야 합니다.",
"msg.userfront.reset.error.symbol": "최소 1개 이상의 특수문자를 포함해야 합니다.",
@@ -292,7 +331,8 @@ const Map<String, String> koStrings = {
"msg.userfront.sections.audit_subtitle": "Baron 로그인 기준의 최근 접근 기록입니다.",
"msg.userfront.settings.disabled": "현재 계정 설정 화면은 준비 중입니다.",
"msg.userfront.signup.agreement.title": "서비스 이용을 위해\\\\n약관에 동의해주세요",
"msg.userfront.signup.auth.affiliate_notice": "가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요.",
"msg.userfront.signup.auth.affiliate_notice":
"가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요.",
"msg.userfront.signup.auth.title": "본인 확인을 위해\\\\n인증을 진행해주세요",
"msg.userfront.signup.email.code_mismatch": "인증코드가 일치하지 않습니다.",
"msg.userfront.signup.email.duplicate": "이미 가입된 이메일입니다.",
@@ -302,7 +342,8 @@ const Map<String, String> koStrings = {
"msg.userfront.signup.email.verify_failed": "인증 실패: {{error}}",
"msg.userfront.signup.failed": "가입 실패: {{error}}",
"msg.userfront.signup.password.length_required": "비밀번호는 최소 12자 이상이어야 합니다.",
"msg.userfront.signup.password.lowercase_required": "소문자가 최소 1개 이상 포함되어야 합니다.",
"msg.userfront.signup.password.lowercase_required":
"소문자가 최소 1개 이상 포함되어야 합니다.",
"msg.userfront.signup.password.mismatch": "비밀번호가 일치하지 않습니다.",
"msg.userfront.signup.password.number_required": "숫자가 최소 1개 이상 포함되어야 합니다.",
"msg.userfront.signup.password.rule.lowercase": "소문자",
@@ -313,7 +354,8 @@ const Map<String, String> koStrings = {
"msg.userfront.signup.password.rule.uppercase": "대문자",
"msg.userfront.signup.password.symbol_required": "특수문자가 최소 1개 이상 포함되어야 합니다.",
"msg.userfront.signup.password.title": "마지막으로\\\\n비밀번호를 설정해주세요",
"msg.userfront.signup.password.uppercase_required": "대문자가 최소 1개 이상 포함되어야 합니다.",
"msg.userfront.signup.password.uppercase_required":
"대문자가 최소 1개 이상 포함되어야 합니다.",
"msg.userfront.signup.phone.code_mismatch": "인증코드가 일치하지 않습니다.",
"msg.userfront.signup.phone.send_failed": "발송 실패: {{error}}",
"msg.userfront.signup.phone.verified": "✅ 휴대폰 인증 완료",
@@ -326,14 +368,17 @@ const Map<String, String> koStrings = {
"msg.userfront.signup.policy.summary": "보안 정책: {{rules}}",
"msg.userfront.signup.policy.symbol": "특수문자",
"msg.userfront.signup.policy.uppercase": "대문자",
"msg.userfront.signup.privacy_full": "\\n개인정보 수집 및 이용 동의\\n\\n바론서비스 개인정보처리방침\\n\\n제1조 (목적)\\n바론컨설턴트(이하 \\\"회사\\\")는 바론서비스(이하 \\\"서비스\\\")를 이용하는 고객(이하 \\\"이용자\\\")의 개인정보를 보호하고, 「개인정보 보호법」에 따라 책임과 의무를 다하기 위해 본 개인정보처리방침을 마련했습니다. 본 방침은 이용자가 제공한 개인정보가 어떻게 수집, 이용, 보관, 보호되는지를 설명합니다.\\n제2조 (개인정보의 처리목적)\\n회사는 다음의 목적을 위해 개인정보를 처리합니다. 처리하고 있는 개인정보는 다음의 목적 이외의 용도로는 이용되지 않으며, 이용 목적이 변경되는 경우에는 「개인정보 보호법」 제18조에 따라 별도의 동의를 받는 등 필요한 조치를 이행할 예정입니다.\\n- 본인확인: 회원가입 및 관리를 위한 본인 확인, 전화 또는 이메일을 통한 연락\\n- 서비스 제공: 각종 통보 및 서비스 제공을 위한 업무 처리\\n- 제품소개서 다운로드: 설명자료 전달\\n- 상담 및 데모 신청: 상담 제공 및 데모 제공, 계약 처리자 정보 수집\\n- 행사 참가 신청: 참석 안내 및 세미나/설명회/교육 제공\\n- 보안가이드 제공: 안내자료 전달\\n- 기술지원 문의: 서비스 사용 지원\\n- 서비스 개선 의견 접수: 서비스 품질 개선\\n- 마케팅 활동: 동의한 고객에 한해 뉴스레터 및 매거진 발송\\n제3조 (개인정보의 처리 및 보유 기간)\\n① 회사는 법령에 따른 개인정보 보유 및 이용기간 또는 정보주체로부터 개인정보를 수집 시 동의받은 개인정보 보유 및 이용기간 내에서 개인정보를 처리 및 보유합니다.\\n② 각각의 개인정보 처리 및 보유 기간은 다음과 같습니다:\\n- 회원정보: 회원가입일부터 회원탈퇴 후 1년까지\\n- 홍보, 상담, 계약용 개인정보: 2년\\n제4조 (개인정보의 제3자 제공)\\n① 회사는 정보주체의 개인정보를 제2조에서 명시한 범위 내에서만 처리하며, 정보 주체의 동의, 법률의 특별한 규정 등 「개인정보 보호법」 제17조 및 제18조에 해당하는 경우에만 개인정보를 제3자에게 제공합니다.\\n② 회사는 다음과 같이 개인정보를 제3자에게 제공하고 있습니다:\\n- 제공받는 자: 수사기관 및 유관기관, 피신고업체\\n- 이용 목적: 개인정보 침해 민원 처리\\n- 제공하는 개인정보 항목: 성명, 연락처, 이메일\\n- 보유 및 이용기간: 법령에서 정한 보존기간 및 제공목적 달성 시 파기\\n제5조 (개인정보 처리 위탁)\\n① 회사는 개인정보 처리업무를 외부 업체에 위탁하지 않으며, 자체적으로 처리하고 있습니다.\\n② 회사가 특정 업무(예: 채용 업무)를 외부 업체에 위탁할 경우, 개인정보 처리방침 시행 전 회사 홈페이지에서 공지한 후 정보주체의 동의를 받은 후 위탁합니다.\\n제6조 (정보주체의 권리·의무 및 행사 방법)\\n① 정보주체는 회사에 대해 언제든지 개인정보 열람, 정정, 삭제, 처리정지 요구 등의 권리를 행사할 수 있습니다.\\n② 권리 행사는 다음과 같은 방법으로 할 수 있습니다:\\n- 서면: 회사 주소로 서면 제출\\n- 전자우편: 회사 이메일로 요청\\n- 모사전송(FAX): 회사 FAX로 요청\\n③ 권리 행사는 정보주체의 법정대리인이나 위임을 받은 자를 통해 대리로도 가능합니다. 이 경우 “개인정보 처리 방법에 관한 고시” 별지 제11호 서식에 따른 위임장을 제출해야 합니다.\\n④ 개인정보 열람 및 처리정지 요구는 「개인정보 보호법」 제35조 제4항, 제37조 제2항에 따라 제한될 수 있습니다.\\n⑤ 개인정보의 정정 및 삭제 요구는 다른 법령에 따라 수집된 개인정보인 경우 제한될 수 있습니다.\\n⑥ 회사는 권리 행사를 요청한 자가 본인 또는 정당한 대리인인지를 확인합니다.\\n제7조 (처리하는 개인정보의 항목)\\n회사는 다음의 개인정보 항목을 처리합니다:\\n- 수집 항목:\\n- 필수 항목: 성명, 휴대전화번호, 이메일\\n- 선택 항목: 회사전화번호, 문의사항\\n- 수집 방법:\\n- 홈페이지, 전화, 이메일을 통해 수집\\n제8조 (개인정보의 파기)\\n① 회사는 개인정보 보유 기간의 경과, 처리 목적 달성 등 개인정보가 불필요하게 되었을 때 지체 없이 해당 개인정보를 파기합니다.\\n② 정보주체로부터 동의받은 개인정보 보유 기간이 경과하거나 처리 목적이 달성된 경우에도 다른 법령에 따라 개인정보를 계속 보존해야 할 경우에는, 해당 개Line truncated
"msg.userfront.signup.privacy_full":
"\\n개인정보 수집 및 이용 동의\\n\\n바론서비스 개인정보처리방침\\n\\n제1조 (목적)\\n바론컨설턴트(이하 \\\"회사\\\")는 바론서비스(이하 \\\"서비스\\\")를 이용하는 고객(이하 \\\"이용자\\\")의 개인정보를 보호하고, 「개인정보 보호법」에 따라 책임과 의무를 다하기 위해 본 개인정보처리방침을 마련했습니다. 본 방침은 이용자가 제공한 개인정보가 어떻게 수집, 이용, 보관, 보호되는지를 설명합니다.\\n제2조 (개인정보의 처리목적)\\n회사는 다음의 목적을 위해 개인정보를 처리합니다. 처리하고 있는 개인정보는 다음의 목적 이외의 용도로는 이용되지 않으며, 이용 목적이 변경되는 경우에는 「개인정보 보호법」 제18조에 따라 별도의 동의를 받는 등 필요한 조치를 이행할 예정입니다.\\n- 본인확인: 회원가입 및 관리를 위한 본인 확인, 전화 또는 이메일을 통한 연락\\n- 서비스 제공: 각종 통보 및 서비스 제공을 위한 업무 처리\\n- 제품소개서 다운로드: 설명자료 전달\\n- 상담 및 데모 신청: 상담 제공 및 데모 제공, 계약 처리자 정보 수집\\n- 행사 참가 신청: 참석 안내 및 세미나/설명회/교육 제공\\n- 보안가이드 제공: 안내자료 전달\\n- 기술지원 문의: 서비스 사용 지원\\n- 서비스 개선 의견 접수: 서비스 품질 개선\\n- 마케팅 활동: 동의한 고객에 한해 뉴스레터 및 매거진 발송\\n제3조 (개인정보의 처리 및 보유 기간)\\n① 회사는 법령에 따른 개인정보 보유 및 이용기간 또는 정보주체로부터 개인정보를 수집 시 동의받은 개인정보 보유 및 이용기간 내에서 개인정보를 처리 및 보유합니다.\\n② 각각의 개인정보 처리 및 보유 기간은 다음과 같습니다:\\n- 회원정보: 회원가입일부터 회원탈퇴 후 1년까지\\n- 홍보, 상담, 계약용 개인정보: 2년\\n제4조 (개인정보의 제3자 제공)\\n① 회사는 정보주체의 개인정보를 제2조에서 명시한 범위 내에서만 처리하며, 정보 주체의 동의, 법률의 특별한 규정 등 「개인정보 보호법」 제17조 및 제18조에 해당하는 경우에만 개인정보를 제3자에게 제공합니다.\\n② 회사는 다음과 같이 개인정보를 제3자에게 제공하고 있습니다:\\n- 제공받는 자: 수사기관 및 유관기관, 피신고업체\\n- 이용 목적: 개인정보 침해 민원 처리\\n- 제공하는 개인정보 항목: 성명, 연락처, 이메일\\n- 보유 및 이용기간: 법령에서 정한 보존기간 및 제공목적 달성 시 파기\\n제5조 (개인정보 처리 위탁)\\n① 회사는 개인정보 처리업무를 외부 업체에 위탁하지 않으며, 자체적으로 처리하고 있습니다.\\n② 회사가 특정 업무(예: 채용 업무)를 외부 업체에 위탁할 경우, 개인정보 처리방침 시행 전 회사 홈페이지에서 공지한 후 정보주체의 동의를 받은 후 위탁합니다.\\n제6조 (정보주체의 권리·의무 및 행사 방법)\\n① 정보주체는 회사에 대해 언제든지 개인정보 열람, 정정, 삭제, 처리정지 요구 등의 권리를 행사할 수 있습니다.\\n② 권리 행사는 다음과 같은 방법으로 할 수 있습니다:\\n- 서면: 회사 주소로 서면 제출\\n- 전자우편: 회사 이메일로 요청\\n- 모사전송(FAX): 회사 FAX로 요청\\n③ 권리 행사는 정보주체의 법정대리인이나 위임을 받은 자를 통해 대리로도 가능합니다. 이 경우 “개인정보 처리 방법에 관한 고시” 별지 제11호 서식에 따른 위임장을 제출해야 합니다.\\n④ 개인정보 열람 및 처리정지 요구는 「개인정보 보호법」 제35조 제4항, 제37조 제2항에 따라 제한될 수 있습니다.\\n⑤ 개인정보의 정정 및 삭제 요구는 다른 법령에 따라 수집된 개인정보인 경우 제한될 수 있습니다.\\n⑥ 회사는 권리 행사를 요청한 자가 본인 또는 정당한 대리인인지를 확인합니다.\\n제7조 (처리하는 개인정보의 항목)\\n회사는 다음의 개인정보 항목을 처리합니다:\\n- 수집 항목:\\n- 필수 항목: 성명, 휴대전화번호, 이메일\\n- 선택 항목: 회사전화번호, 문의사항\\n- 수집 방법:\\n- 홈페이지, 전화, 이메일을 통해 수집\\n제8조 (개인정보의 파기)\\n① 회사는 개인정보 보유 기간의 경과, 처리 목적 달성 등 개인정보가 불필요하게 되었을 때 지체 없이 해당 개인정보를 파기합니다.\\n② 정보주체로부터 동의받은 개인정보 보유 기간이 경과하거나 처리 목적이 달성된 경우에도 다른 법령에 따라 개인정보를 계속 보존해야 할 경우에는, 해당 개인정보를 별도의 데이터Line truncated
"msg.userfront.signup.profile.affiliate_hint": "가족사 이메일 사용 시 자동으로 선택됩니다.",
"msg.userfront.signup.profile.title": "회원님의\\\\n소속 정보를 알려주세요",
"msg.userfront.signup.success.body": "성공적으로 가입되었습니다.",
"msg.userfront.signup.success.title": "회원가입 완료",
"msg.userfront.signup.tos_full": "\\n바론 소프트웨어 이용약관\\n\\n제1장 총칙\\n제1조 (목적)\\n이 약관은 바론컨설턴트(이하 \\\"회사\\\"라 합니다)가 제공하는 바론소프트웨어(이하 \\\"서비스\\\"라 합니다)를 이용함에 있어 회사와 이용자 간의 권리, 의무 및 책임사항과 기타 필요한 사항을 정하는 것을 목적으로 합니다.\\n제2조 (용어의 정의)\\n① 본 약관에서 사용하는 용어의 정의는 다음과 같습니다:\\n- “서비스”란 회사가 제공하는 소프트웨어 및 관련 제반 서비스를 의미합니다.\\n- “이용자”란 회사의 서비스에 접속하여 본 약관에 따라 회사가 제공하는 서비스를 이용하는 회원 및 비회원을 말합니다.\\n- “회원”이란 본 약관에 동의하고 회사와 이용계약을 체결한 자를 의미합니다.\\n- “비회원”이란 회원가입을 하지 않고 회사가 제공하는 일부 서비스를 이용하는 자를 말합니다.\\n제3조 (약관의 효력 및 변경)\\n① 본 약관은 이용자가 본 약관에 동의하고, 회사가 이에 대한 승낙을 완료함으로써 효력이 발생합니다. ② 회사는 필요한 경우 본 약관을 변경할 수 있으며, 변경된 약관은 서비스 화면에 공지된 후 효력이 발생합니다.\\n제4조 (약관 외 준칙)\\n본 약관에 명시되지 않은 사항에 대해서는 대한민국의 관련 법령과 상관습에 따릅니다.\\n제2장 서비스 이용계약\\n제5조 (이용계약의 성립)\\n이용계약은 이용자가 약관의 내용에 동의하고, 회사가 제공하는 소정의 회원가입 신청서를 작성하여 가입을 완료한 후, 회사가 이를 승인함으로써 성립합니다.\\n제6조 (이용계약의 유보와 거절)\\n① 회사는 다음 각 호에 해당하는 경우 이용계약의 성립을 유보하거나 거절할 수 있습니다: - 신청서의 내용이 허위로 판명된 경우 - 서비스 제공이 기술적으로 어려운 경우\\n제7조 (계약사항의 변경)\\n회원은 개인정보 관리 메뉴를 통해 언제든지 자신의 정보를 열람하고 수정할 수 있습니다. 회원의 정보가 변경된 경우 즉시 수정해야 하며, 수정하지 않아 발생하는 문제의 책임은 회원에게 있습니다.\\n제3장 개인정보 보호\\n제8조 (개인정보 보호의 원칙)\\n① 회원의 개인정보는 관련 법령에 따라 보호됩니다. ② 회사는 개인정보 보호와 관련된 세부 사항을 별도로 마련한 개인정보처리방침에 따라 관리하며, 이용자는 언제든지 해당 방침을 통해 개인정보 관리에 대한 자세한 내용을 확인할 수 있습니다.\\n제9조 (개인정보처리방침 준수)\\n① 회사는 개인정보 보호와 관련된 구체적인 사항을 개인정보처리방침에 따라 관리합니다. ② 개인정보의 수집, 이용, 제공, 보관, 보호 등에 관한 사항은 회사의 개인정보처리방침을 따르며, 이용자는 회사 웹사이트에서 이를 확인할 수 있습니다. ③ 회사는 개인정보 보호를 위해 최선을 다하며, 관련 법령에 따라 이용자의 개인정보를 안전하게 관리합니다.\\n제10조 (14세 미만 아동의 개인정보 보호)\\n① 회사는 14세 미만 아동의 개인정보를 수집할 경우, 반드시 법정대리인의 동의를 받아야 합니다. ② 법정대리인은 아동의 개인정보 열람, 수정, 삭제를 요청할 수 있으며, 회사는 이를 신속하게 처리합니다. ③ 14세 미만 아동의 개인정보 보호와 관련된 구체적인 사항은 개인정보처리방침에 명시되어 있습니다.\\n제4장 서비스 제공 및 이용\\n제11조 (서비스 제공)\\n회사는 회원의 이용 신청을 승인한 때부터 서비스를 개시합니다. 서비스 이용은 연중무휴 24시간을 원칙으로 합니다.\\n제12조 (서비스의 변경 및 중단)\\n회사는 서비스 제공이 어려운 경우 사전 고지 후 서비스를 변경하거나 중단할 수 있습니다.\\n제5장 정보 제공 및 광고\\n제13조 (정보 제공 및 광고)\\n① 회사는 서비스 이용 중 필요하다고 인정되는 정보 및 광고를 제공할 수 있습니다. ② 회원은 원치 않는 정보를 수신 거부할 수 있습니다.\\n제6장 게시물 관리\\n제14조 (게시물의 관리)\\n회사는 회원이 게시한 내용이 불법적이거나 약관에 위배될 경우 이를 삭제할 수 있습니다.\\n제15조 (게시물의 저작권)\\n게시물의 저작권은 회원에게 있으며, 회사는 이를 서비스 홍보 및 개선 목적으로 사용할 수 있습니다.\\n제7장 계약 해지 및 이용 제한\\n제16조 (계약 해지)\\n회원은 언제든지 계약 해지를 요청할 수 있으며, 회사는 신속하게 처리합니다.\\n제17조 (이용 제한Line truncated
"msg.userfront.signup.tos_full":
"\\n바론 소프트웨어 이용약관\\n\\n제1장 총칙\\n제1조 (목적)\\n이 약관은 바론컨설턴트(이하 \\\"회사\\\"라 합니다)가 제공하는 바론소프트웨어(이하 \\\"서비스\\\"라 합니다)를 이용함에 있어 회사와 이용자 간의 권리, 의무 및 책임사항과 기타 필요한 사항을 정하는 것을 목적으로 합니다.\\n제2조 (용어의 정의)\\n① 본 약관에서 사용하는 용어의 정의는 다음과 같습니다:\\n- “서비스”란 회사가 제공하는 소프트웨어 및 관련 제반 서비스를 의미합니다.\\n- “이용자”란 회사의 서비스에 접속하여 본 약관에 따라 회사가 제공하는 서비스를 이용하는 회원 및 비회원을 말합니다.\\n- “회원”이란 본 약관에 동의하고 회사와 이용계약을 체결한 자를 의미합니다.\\n- “비회원”이란 회원가입을 하지 않고 회사가 제공하는 일부 서비스를 이용하는 자를 말합니다.\\n제3조 (약관의 효력 및 변경)\\n① 본 약관은 이용자가 본 약관에 동의하고, 회사가 이에 대한 승낙을 완료함으로써 효력이 발생합니다. ② 회사는 필요한 경우 본 약관을 변경할 수 있으며, 변경된 약관은 서비스 화면에 공지된 후 효력이 발생합니다.\\n제4조 (약관 외 준칙)\\n본 약관에 명시되지 않은 사항에 대해서는 대한민국의 관련 법령과 상관습에 따릅니다.\\n제2장 서비스 이용계약\\n제5조 (이용계약의 성립)\\n이용계약은 이용자가 약관의 내용에 동의하고, 회사가 제공하는 소정의 회원가입 신청서를 작성하여 가입을 완료한 후, 회사가 이를 승인함으로써 성립합니다.\\n제6조 (이용계약의 유보와 거절)\\n① 회사는 다음 각 호에 해당하는 경우 이용계약의 성립을 유보하거나 거절할 수 있습니다: - 신청서의 내용이 허위로 판명된 경우 - 서비스 제공이 기술적으로 어려운 경우\\n제7조 (계약사항의 변경)\\n회원은 개인정보 관리 메뉴를 통해 언제든지 자신의 정보를 열람하고 수정할 수 있습니다. 회원의 정보가 변경된 경우 즉시 수정해야 하며, 수정하지 않아 발생하는 문제의 책임은 회원에게 있습니다.\\n제3장 개인정보 보호\\n제8조 (개인정보 보호의 원칙)\\n① 회원의 개인정보는 관련 법령에 따라 보호됩니다. ② 회사는 개인정보 보호와 관련된 세부 사항을 별도로 마련한 개인정보처리방침에 따라 관리하며, 이용자는 언제든지 해당 방침을 통해 개인정보 관리에 대한 자세한 내용을 확인할 수 있습니다.\\n제9조 (개인정보처리방침 준수)\\n① 회사는 개인정보 보호와 관련된 구체적인 사항을 개인정보처리방침에 따라 관리합니다. ② 개인정보의 수집, 이용, 제공, 보관, 보호 등에 관한 사항은 회사의 개인정보처리방침을 따르며, 이용자는 회사 웹사이트에서 이를 확인할 수 있습니다. ③ 회사는 개인정보 보호를 위해 최선을 다하며, 관련 법령에 따라 이용자의 개인정보를 안전하게 관리합니다.\\n제10조 (14세 미만 아동의 개인정보 보호)\\n① 회사는 14세 미만 아동의 개인정보를 수집할 경우, 반드시 법정대리인의 동의를 받아야 합니다. ② 법정대리인은 아동의 개인정보 열람, 수정, 삭제를 요청할 수 있으며, 회사는 이를 신속하게 처리합니다. ③ 14세 미만 아동의 개인정보 보호와 관련된 구체적인 사항은 개인정보처리방침에 명시되어 있습니다.\\n제4장 서비스 제공 및 이용\\n제11조 (서비스 제공)\\n회사는 회원의 이용 신청을 승인한 때부터 서비스를 개시합니다. 서비스 이용은 연중무휴 24시간을 원칙으로 합니다.\\n제12조 (서비스의 변경 및 중단)\\n회사는 서비스 제공이 어려운 경우 사전 고지 후 서비스를 변경하거나 중단할 수 있습니다.\\n제5장 정보 제공 및 광고\\n제13조 (정보 제공 및 광고)\\n① 회사는 서비스 이용 중 필요하다고 인정되는 정보 및 광고를 제공할 수 있습니다. ② 회원은 원치 않는 정보를 수신 거부할 수 있습니다.\\n제6장 게시물 관리\\n제14조 (게시물의 관리)\\n회사는 회원이 게시한 내용이 불법적이거나 약관에 위배될 경우 이를 삭제할 수 있습니다.\\n제15조 (게시물의 저작권)\\n게시물의 저작권은 회원에게 있으며, 회사는 이를 서비스 홍보 및 개선 목적으로 사용할 수 있습니다.\\n제7장 계약 해지 및 이용 제한\\n제16조 (계약 해지)\\n회원은 언제든지 계약 해지를 요청할 수 있으며, 회사는 신속하게 처리합니다.\\n제17조 (이용 제한)\\n회사는 회원이 약Line truncated
"ui.admin.api_keys.create.name_label": "서비스 또는 목적 식별 이름",
"ui.admin.api_keys.create.name_placeholder": "예: Jenkins-CI, Grafana-Dashboard",
"ui.admin.api_keys.create.name_placeholder":
"예: Jenkins-CI, Grafana-Dashboard",
"ui.admin.api_keys.create.section_name": "키 이름 지정",
"ui.admin.api_keys.create.section_scopes": "권한 범위(Scopes) 선택",
"ui.admin.api_keys.create.submit": "API 키 발급하기",
@@ -415,7 +460,8 @@ const Map<String, String> koStrings = {
"ui.admin.tenants.create.breadcrumb.action": "Create",
"ui.admin.tenants.create.breadcrumb.section": "Tenants",
"ui.admin.tenants.create.form.description": "Description",
"ui.admin.tenants.create.form.domains_label": "Allowed Domains (Comma separated)",
"ui.admin.tenants.create.form.domains_label":
"Allowed Domains (Comma separated)",
"ui.admin.tenants.create.form.domains_placeholder": "example.com, example.kr",
"ui.admin.tenants.create.form.name": "Tenant name",
"ui.admin.tenants.create.form.slug": "Slug",
@@ -591,7 +637,8 @@ const Map<String, String> koStrings = {
"ui.dev.clients.details.endpoints.title": "OIDC 엔드포인트",
"ui.dev.clients.details.redirect.callback_label": "인증 콜백 URL",
"ui.dev.clients.details.redirect.label": "Redirect URIs",
"ui.dev.clients.details.redirect.placeholder": "https://your-app.com/callback, http://localhost:3000/auth/callback",
"ui.dev.clients.details.redirect.placeholder":
"https://your-app.com/callback, http://localhost:3000/auth/callback",
"ui.dev.clients.details.redirect.save": "Redirect URIs 저장",
"ui.dev.clients.details.redirect.title": "리디렉션 URI 설정",
"ui.dev.clients.details.secret.hide": "비밀키 숨기기",
@@ -607,15 +654,18 @@ const Map<String, String> koStrings = {
"ui.dev.clients.general.footer.client_id": "Client ID",
"ui.dev.clients.general.footer.created_on": "Created On",
"ui.dev.clients.general.identity.description": "Description",
"ui.dev.clients.general.identity.description_placeholder": "앱에 대한 간단한 설명을 입력하세요.",
"ui.dev.clients.general.identity.description_placeholder":
"앱에 대한 간단한 설명을 입력하세요.",
"ui.dev.clients.general.identity.logo": "App Logo URL",
"ui.dev.clients.general.identity.logo_placeholder": "https://example.com/logo.png",
"ui.dev.clients.general.identity.logo_placeholder":
"https://example.com/logo.png",
"ui.dev.clients.general.identity.logo_preview": "Logo Preview",
"ui.dev.clients.general.identity.name": "앱 이름",
"ui.dev.clients.general.identity.name_placeholder": "My Awesome Application",
"ui.dev.clients.general.identity.title": "Application Identity",
"ui.dev.clients.general.redirect.label": "Redirect URIs",
"ui.dev.clients.general.redirect.placeholder": "https://app.example.com/callback, http://localhost:3000/auth/callback (콤마로 구분)",
"ui.dev.clients.general.redirect.placeholder":
"https://app.example.com/callback, http://localhost:3000/auth/callback (콤마로 구분)",
"ui.dev.clients.general.save": "설정 저장",
"ui.dev.clients.general.scopes.add": "Scope 추가",
"ui.dev.clients.general.scopes.description_placeholder": "권한에 대한 설명",
@@ -872,7 +922,8 @@ const Map<String, String> enStrings = {
"msg.admin.scope_admin": "Scoped to /admin",
"msg.admin.session_ttl": "Session TTL: 15m admin",
"msg.admin.tenant_headers": "Tenant-aware headers",
"msg.admin.tenants.create.form.domains_help": "Users with these email domains will be automatically assigned to this tenant.",
"msg.admin.tenants.create.form.domains_help":
"Users with these email domains will be automatically assigned to this tenant.",
"msg.admin.tenants.create.memo.body": "Body",
"msg.admin.tenants.create.memo.subtitle": "Subtitle",
"msg.admin.tenants.create.profile.subtitle": "Subtitle",
@@ -882,9 +933,11 @@ const Map<String, String> enStrings = {
"msg.admin.tenants.fetch_error": "Fetch Error",
"msg.admin.tenants.members.empty": "Empty",
"msg.admin.tenants.registry.count": "Count",
"msg.admin.tenants.schema.empty": "No custom fields defined. Click \\\"Add Field\\\" to begin.",
"msg.admin.tenants.schema.empty":
"No custom fields defined. Click \\\"Add Field\\\" to begin.",
"msg.admin.tenants.schema.missing_id": "Tenant ID missing",
"msg.admin.tenants.schema.subtitle": "Define custom attributes for users in this tenant.",
"msg.admin.tenants.schema.subtitle":
"Define custom attributes for users in this tenant.",
"msg.admin.tenants.schema.update_error": "Failed to update schema",
"msg.admin.tenants.schema.update_success": "Schema updated successfully",
"msg.admin.tenants.sub.empty": "Empty",
@@ -917,7 +970,8 @@ const Map<String, String> enStrings = {
"msg.dev.clients.consents.empty": "No consents found.",
"msg.dev.clients.consents.load_error": "Error loading consents: {{error}}",
"msg.dev.clients.consents.loading": "Loading consents...",
"msg.dev.clients.consents.showing": "Showing {{from}} to {{to}} of {{total}} users",
"msg.dev.clients.consents.showing":
"Showing {{from}} to {{to}} of {{total}} users",
"msg.dev.clients.consents.subtitle": "Subtitle",
"msg.dev.clients.copy_client_id": "Copy Client Id",
"msg.dev.clients.details.copy_client_id": "Client ID copied.",
@@ -947,8 +1001,10 @@ const Map<String, String> enStrings = {
"msg.dev.clients.general.security.confidential_help": "Confidential Help",
"msg.dev.clients.general.security.public_help": "Public Help",
"msg.dev.clients.general.security.subtitle": "Subtitle",
"msg.dev.clients.help.docs_body": "Includes PKCE, client_secret_basic, redirect URI validation tips.",
"msg.dev.clients.help.subtitle": "Developer guides for Confidential/Public clients, redirect URIs, and auth methods.",
"msg.dev.clients.help.docs_body":
"Includes PKCE, client_secret_basic, redirect URI validation tips.",
"msg.dev.clients.help.subtitle":
"Developer guides for Confidential/Public clients, redirect URIs, and auth methods.",
"msg.dev.clients.load_error": "Error loading clients: {{error}}",
"msg.dev.clients.loading": "Loading clients...",
"msg.dev.clients.registry.description": "Description",
@@ -1132,12 +1188,14 @@ const Map<String, String> enStrings = {
"msg.userfront.signup.policy.summary": "Summary",
"msg.userfront.signup.policy.symbol": "Symbol",
"msg.userfront.signup.policy.uppercase": "Uppercase",
"msg.userfront.signup.privacy_full": "\\n개인정보 수집 및 이용 동의\\n\\n바론서비스 개인정보처리방침\\n\\n제1조 (목적)\\n바론컨설턴트(이하 \\\"회사\\\")는 바론서비스(이하 \\\"서비스\\\")를 이용하는 고객(이하 \\\"이용자\\\")의 개인정보를 보호하고, 「개인정보 보호법」에 따라 책임과 의무를 다하기 위해 본 개인정보처리방침을 마련했습니다. 본 방침은 이용자가 제공한 개인정보가 어떻게 수집, 이용, 보관, 보호되는지를 설명합니다.\\n제2조 (개인정보의 처리목적)\\n회사는 다음의 목적을 위해 개인정보를 처리합니다. 처리하고 있는 개인정보는 다음의 목적 이외의 용도로는 이용되지 않으며, 이용 목적이 변경되는 경우에는 「개인정보 보호법」 제18조에 따라 별도의 동의를 받는 등 필요한 조치를 이행할 예정입니다.\\n- 본인확인: 회원가입 및 관리를 위한 본인 확인, 전화 또는 이메일을 통한 연락\\n- 서비스 제공: 각종 통보 및 서비스 제공을 위한 업무 처리\\n- 제품소개서 다운로드: 설명자료 전달\\n- 상담 및 데모 신청: 상담 제공 및 데모 제공, 계약 처리자 정보 수집\\n- 행사 참가 신청: 참석 안내 및 세미나/설명회/교육 제공\\n- 보안가이드 제공: 안내자료 전달\\n- 기술지원 문의: 서비스 사용 지원\\n- 서비스 개선 의견 접수: 서비스 품질 개선\\n- 마케팅 활동: 동의한 고객에 한해 뉴스레터 및 매거진 발송\\n제3조 (개인정보의 처리 및 보유 기간)\\n① 회사는 법령에 따른 개인정보 보유 및 이용기간 또는 정보주체로부터 개인정보를 수집 시 동의받은 개인정보 보유 및 이용기간 내에서 개인정보를 처리 및 보유합니다.\\n② 각각의 개인정보 처리 및 보유 기간은 다음과 같습니다:\\n- 회원정보: 회원가입일부터 회원탈퇴 후 1년까지\\n- 홍보, 상담, 계약용 개인정보: 2년\\n제4조 (개인정보의 제3자 제공)\\n① 회사는 정보주체의 개인정보를 제2조에서 명시한 범위 내에서만 처리하며, 정보 주체의 동의, 법률의 특별한 규정 등 「개인정보 보호법」 제17조 및 제18조에 해당하는 경우에만 개인정보를 제3자에게 제공합니다.\\n② 회사는 다음과 같이 개인정보를 제3자에게 제공하고 있습니다:\\n- 제공받는 자: 수사기관 및 유관기관, 피신고업체\\n- 이용 목적: 개인정보 침해 민원 처리\\n- 제공하는 개인정보 항목: 성명, 연락처, 이메일\\n- 보유 및 이용기간: 법령에서 정한 보존기간 및 제공목적 달성 시 파기\\n제5조 (개인정보 처리 위탁)\\n① 회사는 개인정보 처리업무를 외부 업체에 위탁하지 않으며, 자체적으로 처리하고 있습니다.\\n② 회사가 특정 업무(예: 채용 업무)를 외부 업체에 위탁할 경우, 개인정보 처리방침 시행 전 회사 홈페이지에서 공지한 후 정보주체의 동의를 받은 후 위탁합니다.\\n제6조 (정보주체의 권리·의무 및 행사 방법)\\n① 정보주체는 회사에 대해 언제든지 개인정보 열람, 정정, 삭제, 처리정지 요구 등의 권리를 행사할 수 있습니다.\\n② 권리 행사는 다음과 같은 방법으로 할 수 있습니다:\\n- 서면: 회사 주소로 서면 제출\\n- 전자우편: 회사 이메일로 요청\\n- 모사전송(FAX): 회사 FAX로 요청\\n③ 권리 행사는 정보주체의 법정대리인이나 위임을 받은 자를 통해 대리로도 가능합니다. 이 경우 “개인정보 처리 방법에 관한 고시” 별지 제11호 서식에 따른 위임장을 제출해야 합니다.\\n④ 개인정보 열람 및 처리정지 요구는 「개인정보 보호법」 제35조 제4항, 제37조 제2항에 따라 제한될 수 있습니다.\\n⑤ 개인정보의 정정 및 삭제 요구는 다른 법령에 따라 수집된 개인정보인 경우 제한될 수 있습니다.\\n⑥ 회사는 권리 행사를 요청한 자가 본인 또는 정당한 대리인인지를 확인합니다.\\n제7조 (처리하는 개인정보의 항목)\\n회사는 다음의 개인정보 항목을 처리합니다:\\n- 수집 항목:\\n- 필수 항목: 성명, 휴대전화번호, 이메일\\n- 선택 항목: 회사전화번호, 문의사항\\n- 수집 방법:\\n- 홈페이지, 전화, 이메일을 통해 수집\\n제8조 (개인정보의 파기)\\n① 회사는 개인정보 보유 기간의 경과, 처리 목적 달성 등 개인정보가 불필요하게 되었을 때 지체 없이 해당 개인정보를 파기합니다.\\n② 정보주체로부터 동의받은 개인정보 보유 기간이 경과하거나 처리 목적이 달성된 경우에도 다른 법령에 따라 개인정보를 계속 보존해야 할 경우에는, 해당 개Line truncated
"msg.userfront.signup.privacy_full":
"\\n개인정보 수집 및 이용 동의\\n\\n바론서비스 개인정보처리방침\\n\\n제1조 (목적)\\n바론컨설턴트(이하 \\\"회사\\\")는 바론서비스(이하 \\\"서비스\\\")를 이용하는 고객(이하 \\\"이용자\\\")의 개인정보를 보호하고, 「개인정보 보호법」에 따라 책임과 의무를 다하기 위해 본 개인정보처리방침을 마련했습니다. 본 방침은 이용자가 제공한 개인정보가 어떻게 수집, 이용, 보관, 보호되는지를 설명합니다.\\n제2조 (개인정보의 처리목적)\\n회사는 다음의 목적을 위해 개인정보를 처리합니다. 처리하고 있는 개인정보는 다음의 목적 이외의 용도로는 이용되지 않으며, 이용 목적이 변경되는 경우에는 「개인정보 보호법」 제18조에 따라 별도의 동의를 받는 등 필요한 조치를 이행할 예정입니다.\\n- 본인확인: 회원가입 및 관리를 위한 본인 확인, 전화 또는 이메일을 통한 연락\\n- 서비스 제공: 각종 통보 및 서비스 제공을 위한 업무 처리\\n- 제품소개서 다운로드: 설명자료 전달\\n- 상담 및 데모 신청: 상담 제공 및 데모 제공, 계약 처리자 정보 수집\\n- 행사 참가 신청: 참석 안내 및 세미나/설명회/교육 제공\\n- 보안가이드 제공: 안내자료 전달\\n- 기술지원 문의: 서비스 사용 지원\\n- 서비스 개선 의견 접수: 서비스 품질 개선\\n- 마케팅 활동: 동의한 고객에 한해 뉴스레터 및 매거진 발송\\n제3조 (개인정보의 처리 및 보유 기간)\\n① 회사는 법령에 따른 개인정보 보유 및 이용기간 또는 정보주체로부터 개인정보를 수집 시 동의받은 개인정보 보유 및 이용기간 내에서 개인정보를 처리 및 보유합니다.\\n② 각각의 개인정보 처리 및 보유 기간은 다음과 같습니다:\\n- 회원정보: 회원가입일부터 회원탈퇴 후 1년까지\\n- 홍보, 상담, 계약용 개인정보: 2년\\n제4조 (개인정보의 제3자 제공)\\n① 회사는 정보주체의 개인정보를 제2조에서 명시한 범위 내에서만 처리하며, 정보 주체의 동의, 법률의 특별한 규정 등 「개인정보 보호법」 제17조 및 제18조에 해당하는 경우에만 개인정보를 제3자에게 제공합니다.\\n② 회사는 다음과 같이 개인정보를 제3자에게 제공하고 있습니다:\\n- 제공받는 자: 수사기관 및 유관기관, 피신고업체\\n- 이용 목적: 개인정보 침해 민원 처리\\n- 제공하는 개인정보 항목: 성명, 연락처, 이메일\\n- 보유 및 이용기간: 법령에서 정한 보존기간 및 제공목적 달성 시 파기\\n제5조 (개인정보 처리 위탁)\\n① 회사는 개인정보 처리업무를 외부 업체에 위탁하지 않으며, 자체적으로 처리하고 있습니다.\\n② 회사가 특정 업무(예: 채용 업무)를 외부 업체에 위탁할 경우, 개인정보 처리방침 시행 전 회사 홈페이지에서 공지한 후 정보주체의 동의를 받은 후 위탁합니다.\\n제6조 (정보주체의 권리·의무 및 행사 방법)\\n① 정보주체는 회사에 대해 언제든지 개인정보 열람, 정정, 삭제, 처리정지 요구 등의 권리를 행사할 수 있습니다.\\n② 권리 행사는 다음과 같은 방법으로 할 수 있습니다:\\n- 서면: 회사 주소로 서면 제출\\n- 전자우편: 회사 이메일로 요청\\n- 모사전송(FAX): 회사 FAX로 요청\\n③ 권리 행사는 정보주체의 법정대리인이나 위임을 받은 자를 통해 대리로도 가능합니다. 이 경우 “개인정보 처리 방법에 관한 고시” 별지 제11호 서식에 따른 위임장을 제출해야 합니다.\\n④ 개인정보 열람 및 처리정지 요구는 「개인정보 보호법」 제35조 제4항, 제37조 제2항에 따라 제한될 수 있습니다.\\n⑤ 개인정보의 정정 및 삭제 요구는 다른 법령에 따라 수집된 개인정보인 경우 제한될 수 있습니다.\\n⑥ 회사는 권리 행사를 요청한 자가 본인 또는 정당한 대리인인지를 확인합니다.\\n제7조 (처리하는 개인정보의 항목)\\n회사는 다음의 개인정보 항목을 처리합니다:\\n- 수집 항목:\\n- 필수 항목: 성명, 휴대전화번호, 이메일\\n- 선택 항목: 회사전화번호, 문의사항\\n- 수집 방법:\\n- 홈페이지, 전화, 이메일을 통해 수집\\n제8조 (개인정보의 파기)\\n① 회사는 개인정보 보유 기간의 경과, 처리 목적 달성 등 개인정보가 불필요하게 되었을 때 지체 없이 해당 개인정보를 파기합니다.\\n② 정보주체로부터 동의받은 개인정보 보유 기간이 경과하거나 처리 목적이 달성된 경우에도 다른 법령에 따라 개인정보를 계속 보존해야 할 경우에는, 해당 개인정보를 별도의 데이터Line truncated
"msg.userfront.signup.profile.affiliate_hint": "Affiliate Hint",
"msg.userfront.signup.profile.title": "Title",
"msg.userfront.signup.success.body": "Body",
"msg.userfront.signup.success.title": "Title",
"msg.userfront.signup.tos_full": "\\n바론 소프트웨어 이용약관\\n\\n제1장 총칙\\n제1조 (목적)\\n이 약관은 바론컨설턴트(이하 \\\"회사\\\"라 합니다)가 제공하는 바론소프트웨어(이하 \\\"서비스\\\"라 합니다)를 이용함에 있어 회사와 이용자 간의 권리, 의무 및 책임사항과 기타 필요한 사항을 정하는 것을 목적으로 합니다.\\n제2조 (용어의 정의)\\n① 본 약관에서 사용하는 용어의 정의는 다음과 같습니다:\\n- “서비스”란 회사가 제공하는 소프트웨어 및 관련 제반 서비스를 의미합니다.\\n- “이용자”란 회사의 서비스에 접속하여 본 약관에 따라 회사가 제공하는 서비스를 이용하는 회원 및 비회원을 말합니다.\\n- “회원”이란 본 약관에 동의하고 회사와 이용계약을 체결한 자를 의미합니다.\\n- “비회원”이란 회원가입을 하지 않고 회사가 제공하는 일부 서비스를 이용하는 자를 말합니다.\\n제3조 (약관의 효력 및 변경)\\n① 본 약관은 이용자가 본 약관에 동의하고, 회사가 이에 대한 승낙을 완료함으로써 효력이 발생합니다. ② 회사는 필요한 경우 본 약관을 변경할 수 있으며, 변경된 약관은 서비스 화면에 공지된 후 효력이 발생합니다.\\n제4조 (약관 외 준칙)\\n본 약관에 명시되지 않은 사항에 대해서는 대한민국의 관련 법령과 상관습에 따릅니다.\\n제2장 서비스 이용계약\\n제5조 (이용계약의 성립)\\n이용계약은 이용자가 약관의 내용에 동의하고, 회사가 제공하는 소정의 회원가입 신청서를 작성하여 가입을 완료한 후, 회사가 이를 승인함으로써 성립합니다.\\n제6조 (이용계약의 유보와 거절)\\n① 회사는 다음 각 호에 해당하는 경우 이용계약의 성립을 유보하거나 거절할 수 있습니다: - 신청서의 내용이 허위로 판명된 경우 - 서비스 제공이 기술적으로 어려운 경우\\n제7조 (계약사항의 변경)\\n회원은 개인정보 관리 메뉴를 통해 언제든지 자신의 정보를 열람하고 수정할 수 있습니다. 회원의 정보가 변경된 경우 즉시 수정해야 하며, 수정하지 않아 발생하는 문제의 책임은 회원에게 있습니다.\\n제3장 개인정보 보호\\n제8조 (개인정보 보호의 원칙)\\n① 회원의 개인정보는 관련 법령에 따라 보호됩니다. ② 회사는 개인정보 보호와 관련된 세부 사항을 별도로 마련한 개인정보처리방침에 따라 관리하며, 이용자는 언제든지 해당 방침을 통해 개인정보 관리에 대한 자세한 내용을 확인할 수 있습니다.\\n제9조 (개인정보처리방침 준수)\\n① 회사는 개인정보 보호와 관련된 구체적인 사항을 개인정보처리방침에 따라 관리합니다. ② 개인정보의 수집, 이용, 제공, 보관, 보호 등에 관한 사항은 회사의 개인정보처리방침을 따르며, 이용자는 회사 웹사이트에서 이를 확인할 수 있습니다. ③ 회사는 개인정보 보호를 위해 최선을 다하며, 관련 법령에 따라 이용자의 개인정보를 안전하게 관리합니다.\\n제10조 (14세 미만 아동의 개인정보 보호)\\n① 회사는 14세 미만 아동의 개인정보를 수집할 경우, 반드시 법정대리인의 동의를 받아야 합니다. ② 법정대리인은 아동의 개인정보 열람, 수정, 삭제를 요청할 수 있으며, 회사는 이를 신속하게 처리합니다. ③ 14세 미만 아동의 개인정보 보호와 관련된 구체적인 사항은 개인정보처리방침에 명시되어 있습니다.\\n제4장 서비스 제공 및 이용\\n제11조 (서비스 제공)\\n회사는 회원의 이용 신청을 승인한 때부터 서비스를 개시합니다. 서비스 이용은 연중무휴 24시간을 원칙으로 합니다.\\n제12조 (서비스의 변경 및 중단)\\n회사는 서비스 제공이 어려운 경우 사전 고지 후 서비스를 변경하거나 중단할 수 있습니다.\\n제5장 정보 제공 및 광고\\n제13조 (정보 제공 및 광고)\\n① 회사는 서비스 이용 중 필요하다고 인정되는 정보 및 광고를 제공할 수 있습니다. ② 회원은 원치 않는 정보를 수신 거부할 수 있습니다.\\n제6장 게시물 관리\\n제14조 (게시물의 관리)\\n회사는 회원이 게시한 내용이 불법적이거나 약관에 위배될 경우 이를 삭제할 수 있습니다.\\n제15조 (게시물의 저작권)\\n게시물의 저작권은 회원에게 있으며, 회사는 이를 서비스 홍보 및 개선 목적으로 사용할 수 있습니다.\\n제7장 계약 해지 및 이용 제한\\n제16조 (계약 해지)\\n회원은 언제든지 계약 해지를 요청할 수 있으며, 회사는 신속하게 처리합니다.\\n제17조 (이용 제한Line truncated
"msg.userfront.signup.tos_full":
"\\n바론 소프트웨어 이용약관\\n\\n제1장 총칙\\n제1조 (목적)\\n이 약관은 바론컨설턴트(이하 \\\"회사\\\"라 합니다)가 제공하는 바론소프트웨어(이하 \\\"서비스\\\"라 합니다)를 이용함에 있어 회사와 이용자 간의 권리, 의무 및 책임사항과 기타 필요한 사항을 정하는 것을 목적으로 합니다.\\n제2조 (용어의 정의)\\n① 본 약관에서 사용하는 용어의 정의는 다음과 같습니다:\\n- “서비스”란 회사가 제공하는 소프트웨어 및 관련 제반 서비스를 의미합니다.\\n- “이용자”란 회사의 서비스에 접속하여 본 약관에 따라 회사가 제공하는 서비스를 이용하는 회원 및 비회원을 말합니다.\\n- “회원”이란 본 약관에 동의하고 회사와 이용계약을 체결한 자를 의미합니다.\\n- “비회원”이란 회원가입을 하지 않고 회사가 제공하는 일부 서비스를 이용하는 자를 말합니다.\\n제3조 (약관의 효력 및 변경)\\n① 본 약관은 이용자가 본 약관에 동의하고, 회사가 이에 대한 승낙을 완료함으로써 효력이 발생합니다. ② 회사는 필요한 경우 본 약관을 변경할 수 있으며, 변경된 약관은 서비스 화면에 공지된 후 효력이 발생합니다.\\n제4조 (약관 외 준칙)\\n본 약관에 명시되지 않은 사항에 대해서는 대한민국의 관련 법령과 상관습에 따릅니다.\\n제2장 서비스 이용계약\\n제5조 (이용계약의 성립)\\n이용계약은 이용자가 약관의 내용에 동의하고, 회사가 제공하는 소정의 회원가입 신청서를 작성하여 가입을 완료한 후, 회사가 이를 승인함으로써 성립합니다.\\n제6조 (이용계약의 유보와 거절)\\n① 회사는 다음 각 호에 해당하는 경우 이용계약의 성립을 유보하거나 거절할 수 있습니다: - 신청서의 내용이 허위로 판명된 경우 - 서비스 제공이 기술적으로 어려운 경우\\n제7조 (계약사항의 변경)\\n회원은 개인정보 관리 메뉴를 통해 언제든지 자신의 정보를 열람하고 수정할 수 있습니다. 회원의 정보가 변경된 경우 즉시 수정해야 하며, 수정하지 않아 발생하는 문제의 책임은 회원에게 있습니다.\\n제3장 개인정보 보호\\n제8조 (개인정보 보호의 원칙)\\n① 회원의 개인정보는 관련 법령에 따라 보호됩니다. ② 회사는 개인정보 보호와 관련된 세부 사항을 별도로 마련한 개인정보처리방침에 따라 관리하며, 이용자는 언제든지 해당 방침을 통해 개인정보 관리에 대한 자세한 내용을 확인할 수 있습니다.\\n제9조 (개인정보처리방침 준수)\\n① 회사는 개인정보 보호와 관련된 구체적인 사항을 개인정보처리방침에 따라 관리합니다. ② 개인정보의 수집, 이용, 제공, 보관, 보호 등에 관한 사항은 회사의 개인정보처리방침을 따르며, 이용자는 회사 웹사이트에서 이를 확인할 수 있습니다. ③ 회사는 개인정보 보호를 위해 최선을 다하며, 관련 법령에 따라 이용자의 개인정보를 안전하게 관리합니다.\\n제10조 (14세 미만 아동의 개인정보 보호)\\n① 회사는 14세 미만 아동의 개인정보를 수집할 경우, 반드시 법정대리인의 동의를 받아야 합니다. ② 법정대리인은 아동의 개인정보 열람, 수정, 삭제를 요청할 수 있으며, 회사는 이를 신속하게 처리합니다. ③ 14세 미만 아동의 개인정보 보호와 관련된 구체적인 사항은 개인정보처리방침에 명시되어 있습니다.\\n제4장 서비스 제공 및 이용\\n제11조 (서비스 제공)\\n회사는 회원의 이용 신청을 승인한 때부터 서비스를 개시합니다. 서비스 이용은 연중무휴 24시간을 원칙으로 합니다.\\n제12조 (서비스의 변경 및 중단)\\n회사는 서비스 제공이 어려운 경우 사전 고지 후 서비스를 변경하거나 중단할 수 있습니다.\\n제5장 정보 제공 및 광고\\n제13조 (정보 제공 및 광고)\\n① 회사는 서비스 이용 중 필요하다고 인정되는 정보 및 광고를 제공할 수 있습니다. ② 회원은 원치 않는 정보를 수신 거부할 수 있습니다.\\n제6장 게시물 관리\\n제14조 (게시물의 관리)\\n회사는 회원이 게시한 내용이 불법적이거나 약관에 위배될 경우 이를 삭제할 수 있습니다.\\n제15조 (게시물의 저작권)\\n게시물의 저작권은 회원에게 있으며, 회사는 이를 서비스 홍보 및 개선 목적으로 사용할 수 있습니다.\\n제7장 계약 해지 및 이용 제한\\n제16조 (계약 해지)\\n회원은 언제든지 계약 해지를 요청할 수 있으며, 회사는 신속하게 처리합니다.\\n제17조 (이용 제한)\\n회사는 회원이 약Line truncated
"ui.admin.api_keys.create.name_label": "Name Label",
"ui.admin.api_keys.create.name_placeholder": "Name Placeholder",
"ui.admin.api_keys.create.section_name": "Section Name",
@@ -1221,7 +1279,8 @@ const Map<String, String> enStrings = {
"ui.admin.tenants.create.breadcrumb.action": "Create",
"ui.admin.tenants.create.breadcrumb.section": "Tenants",
"ui.admin.tenants.create.form.description": "Description",
"ui.admin.tenants.create.form.domains_label": "Allowed Domains (Comma separated)",
"ui.admin.tenants.create.form.domains_label":
"Allowed Domains (Comma separated)",
"ui.admin.tenants.create.form.domains_placeholder": "example.com, example.kr",
"ui.admin.tenants.create.form.name": "Tenant name",
"ui.admin.tenants.create.form.slug": "Slug",
@@ -1397,7 +1456,8 @@ const Map<String, String> enStrings = {
"ui.dev.clients.details.endpoints.title": "Title",
"ui.dev.clients.details.redirect.callback_label": "Callback Label",
"ui.dev.clients.details.redirect.label": "Redirect URIs",
"ui.dev.clients.details.redirect.placeholder": "https://your-app.com/callback, http://localhost:3000/auth/callback",
"ui.dev.clients.details.redirect.placeholder":
"https://your-app.com/callback, http://localhost:3000/auth/callback",
"ui.dev.clients.details.redirect.save": "Save",
"ui.dev.clients.details.redirect.title": "Title",
"ui.dev.clients.details.secret.hide": "Hide",
@@ -1413,9 +1473,11 @@ const Map<String, String> enStrings = {
"ui.dev.clients.general.footer.client_id": "Client ID",
"ui.dev.clients.general.footer.created_on": "Created On",
"ui.dev.clients.general.identity.description": "Description",
"ui.dev.clients.general.identity.description_placeholder": "Description Placeholder",
"ui.dev.clients.general.identity.description_placeholder":
"Description Placeholder",
"ui.dev.clients.general.identity.logo": "App Logo URL",
"ui.dev.clients.general.identity.logo_placeholder": "https://example.com/logo.png",
"ui.dev.clients.general.identity.logo_placeholder":
"https://example.com/logo.png",
"ui.dev.clients.general.identity.logo_preview": "Logo Preview",
"ui.dev.clients.general.identity.name": "Name",
"ui.dev.clients.general.identity.name_placeholder": "My Awesome Application",
@@ -1424,7 +1486,8 @@ const Map<String, String> enStrings = {
"ui.dev.clients.general.redirect.placeholder": "Placeholder",
"ui.dev.clients.general.save": "Settings Save",
"ui.dev.clients.general.scopes.add": "Scope Add",
"ui.dev.clients.general.scopes.description_placeholder": "Description Placeholder",
"ui.dev.clients.general.scopes.description_placeholder":
"Description Placeholder",
"ui.dev.clients.general.scopes.name_placeholder": "e.g. profile",
"ui.dev.clients.general.scopes.table.description": "Description",
"ui.dev.clients.general.scopes.table.mandatory": "Mandatory",
+22 -16
View File
@@ -131,9 +131,11 @@ final _router = GoRouter(
GoRoute(
path: 'signin',
builder: (context, state) {
final loginChallenge = state.uri.queryParameters['login_challenge'];
final redirectUrl = state.uri.queryParameters['redirect_uri'] ??
state.uri.queryParameters['redirect_url'];
final loginChallenge =
state.uri.queryParameters['login_challenge'];
final redirectUrl =
state.uri.queryParameters['redirect_uri'] ??
state.uri.queryParameters['redirect_url'];
return LoginScreen(
key: state.pageKey,
loginChallenge: loginChallenge,
@@ -145,9 +147,11 @@ final _router = GoRouter(
path: 'login',
builder: (context, state) {
// IMPORTANT: Match signin logic to handle OIDC challenges
final loginChallenge = state.uri.queryParameters['login_challenge'];
final redirectUrl = state.uri.queryParameters['redirect_uri'] ??
state.uri.queryParameters['redirect_url'];
final loginChallenge =
state.uri.queryParameters['login_challenge'];
final redirectUrl =
state.uri.queryParameters['redirect_uri'] ??
state.uri.queryParameters['redirect_url'];
return LoginScreen(
key: state.pageKey,
loginChallenge: loginChallenge,
@@ -158,10 +162,13 @@ final _router = GoRouter(
GoRoute(
path: 'consent',
builder: (BuildContext context, GoRouterState state) {
final consentChallenge = state.uri.queryParameters['consent_challenge'];
final consentChallenge =
state.uri.queryParameters['consent_challenge'];
if (consentChallenge == null) {
return const Scaffold(
body: Center(child: Text('Error: Consent challenge is missing.')),
body: Center(
child: Text('Error: Consent challenge is missing.'),
),
);
}
return ConsentScreen(consentChallenge: consentChallenge);
@@ -231,15 +238,13 @@ final _router = GoRouter(
),
GoRoute(
path: 'approve',
builder: (context, state) => ApproveQrScreen(
pendingRef: state.uri.queryParameters['ref'],
),
builder: (context, state) =>
ApproveQrScreen(pendingRef: state.uri.queryParameters['ref']),
),
GoRoute(
path: 'ql/:ref',
builder: (context, state) => ApproveQrScreen(
pendingRef: state.pathParameters['ref'],
),
builder: (context, state) =>
ApproveQrScreen(pendingRef: state.pathParameters['ref']),
),
GoRoute(
path: 'scan',
@@ -258,14 +263,15 @@ final _router = GoRouter(
final uri = state.uri;
final requestedLocale = extractLocaleFromPath(uri);
final preferredLocale = resolvePreferredLocaleCode();
if (requestedLocale == null) {
final localizedPath = buildLocalizedPath(preferredLocale, uri);
return localizedPath;
}
final token = AuthTokenStore.getToken();
final isLoggedIn = (token != null && token.isNotEmpty) || AuthTokenStore.usesCookie();
final isLoggedIn =
(token != null && token.isNotEmpty) || AuthTokenStore.usesCookie();
final path = stripLocalePath(uri);
// Precise public path detection