1
0
forked from baron/baron-sso

린트 적용

This commit is contained in:
2026-02-23 16:18:01 +09:00
parent 04938d7cd9
commit 68becb43bc
36 changed files with 1240 additions and 1057 deletions

View File

@@ -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) => ( ).map((role) => (
<button <button
key={role} key={role}
@@ -120,8 +125,12 @@ const RoleSwitcher: FC = () => {
alignItems: "center", alignItems: "center",
}} }}
> >
<span>{roleLabels[role] ?? role.toUpperCase().replace("_", " ")}</span> <span>
{currentRole === role && <span style={{ marginLeft: "8px" }}></span>} {roleLabels[role] ?? role.toUpperCase().replace("_", " ")}
</span>
{currentRole === role && (
<span style={{ marginLeft: "8px" }}></span>
)}
</button> </button>
))} ))}
</div> </div>

View File

@@ -26,7 +26,8 @@ const badgeVariants = cva(
); );
export interface BadgeProps export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, extends
React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {} VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) { function Badge({ className, variant, ...props }: BadgeProps) {

View File

@@ -6,7 +6,9 @@ import { Button } from "./button";
describe("Button Component", () => { describe("Button Component", () => {
it("renders correctly with children", () => { it("renders correctly with children", () => {
render(<Button>Click me</Button>); 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", () => { it("applies variant classes correctly", () => {

View File

@@ -34,7 +34,8 @@ const buttonVariants = cva(
); );
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
asChild?: boolean; asChild?: boolean;
} }

View File

@@ -1,8 +1,7 @@
import * as React from "react"; import * as React from "react";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
export interface InputProps export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>( const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => { ({ className, type, ...props }, ref) => {

View File

@@ -1,8 +1,7 @@
import * as React from "react"; import * as React from "react";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
export interface TextareaProps export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => { ({ className, ...props }, ref) => {

View File

@@ -1,6 +1,13 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios"; 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 { useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -64,11 +71,16 @@ export function TenantAdminsTab() {
mutationFn: (userId: string) => addTenantAdmin(tenantId, userId), mutationFn: (userId: string) => addTenantAdmin(tenantId, userId),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenant-admins", tenantId] }); 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(""); setSearchTerm("");
}, },
onError: (err: AxiosError<{ error?: string }>) => { 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), mutationFn: (userId: string) => removeTenantAdmin(tenantId, userId),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenant-admins", tenantId] }); 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 }>) => { 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) => { 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); removeMutation.mutate(userId);
} }
}; };
@@ -106,14 +131,20 @@ export function TenantAdminsTab() {
{t("ui.admin.tenants.admins.title", "테넌트 관리자")} {t("ui.admin.tenants.admins.title", "테넌트 관리자")}
</CardTitle> </CardTitle>
<CardDescription className="text-muted-foreground"> <CardDescription className="text-muted-foreground">
{t("msg.admin.tenants.admins.subtitle", "이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다.")} {t(
"msg.admin.tenants.admins.subtitle",
"이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다.",
)}
</CardDescription> </CardDescription>
</div> </div>
<Dialog open={isDialogOpen} onOpenChange={(open) => { <Dialog
open={isDialogOpen}
onOpenChange={(open) => {
setIsAddDialogOpen(open); setIsAddDialogOpen(open);
if (!open) setSearchTerm(""); if (!open) setSearchTerm("");
}}> }}
>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="bg-primary text-primary-foreground hover:bg-primary/90"> <Button className="bg-primary text-primary-foreground hover:bg-primary/90">
<UserPlus className="mr-2 h-4 w-4" /> <UserPlus className="mr-2 h-4 w-4" />
@@ -126,7 +157,10 @@ export function TenantAdminsTab() {
{t("ui.admin.tenants.admins.dialog_title", "새 관리자 추가")} {t("ui.admin.tenants.admins.dialog_title", "새 관리자 추가")}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{t("ui.admin.tenants.admins.dialog_description", "이름 또는 이메일로 사용자를 검색하세요.")} {t(
"ui.admin.tenants.admins.dialog_description",
"이름 또는 이메일로 사용자를 검색하세요.",
)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -134,7 +168,10 @@ export function TenantAdminsTab() {
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <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" className="pl-10 h-11"
autoFocus autoFocus
value={searchTerm} value={searchTerm}
@@ -146,7 +183,12 @@ export function TenantAdminsTab() {
{searchTerm.length < 2 ? ( {searchTerm.length < 2 ? (
<div className="p-10 text-center text-muted-foreground flex flex-col items-center gap-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" /> <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> </div>
) : usersQuery.isLoading ? ( ) : usersQuery.isLoading ? (
<div className="p-10 text-center"> <div className="p-10 text-center">
@@ -154,21 +196,33 @@ export function TenantAdminsTab() {
</div> </div>
) : searchResults.length === 0 ? ( ) : searchResults.length === 0 ? (
<div className="p-10 text-center text-muted-foreground"> <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>
) : ( ) : (
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{searchResults.map((user) => { {searchResults.map((user) => {
const isAlreadyAdmin = currentAdmins.some((a) => a.id === user.id); const isAlreadyAdmin = currentAdmins.some(
(a) => a.id === user.id,
);
return ( 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="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"> <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)} {user.name.charAt(0)}
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-sm font-medium">{user.name}</span> <span className="text-sm font-medium">
<span className="text-xs text-muted-foreground">{user.email}</span> {user.name}
</span>
<span className="text-xs text-muted-foreground">
{user.email}
</span>
</div> </div>
</div> </div>
<Button <Button
@@ -178,9 +232,20 @@ export function TenantAdminsTab() {
onClick={() => handleAddAdmin(user.id)} onClick={() => handleAddAdmin(user.id)}
> >
{isAlreadyAdmin ? ( {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> </Button>
</div> </div>
@@ -219,16 +284,27 @@ export function TenantAdminsTab() {
</TableRow> </TableRow>
) : currentAdmins.length === 0 ? ( ) : currentAdmins.length === 0 ? (
<TableRow> <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"> <div className="flex flex-col items-center gap-2">
<Users className="h-8 w-8 opacity-20" /> <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> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
currentAdmins.map((admin) => ( 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"> <TableCell className="font-medium">
<div className="flex items-center gap-3"> <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"> <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" variant="ghost"
size="icon" size="icon"
className="opacity-0 group-hover:opacity-100 text-destructive hover:text-destructive hover:bg-destructive/10 transition-all" 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} 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" /> <Trash2 className="h-4 w-4" />
</Button> </Button>

View File

@@ -117,10 +117,27 @@ function TenantCreatePage() {
value={type} value={type}
onChange={(e) => setType(e.target.value)} onChange={(e) => setType(e.target.value)}
> >
<option value="COMPANY">{t("domain.tenant_type.company", "COMPANY (일반 기업)")}</option> <option value="COMPANY">
<option value="COMPANY_GROUP">{t("domain.tenant_type.company_group", "COMPANY_GROUP (그룹사/지주사)")}</option> {t("domain.tenant_type.company", "COMPANY (일반 기업)")}
<option value="USER_GROUP">{t("domain.tenant_type.user_group", "USER_GROUP (내부 부서/팀)")}</option> </option>
<option value="PERSONAL">{t("domain.tenant_type.personal", "PERSONAL (개인 워크스페이스)")}</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> </select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">

View File

@@ -25,21 +25,32 @@ function TenantDetailPage() {
<header className="flex flex-wrap items-start justify-between gap-4"> <header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]"> <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} /> <ArrowLeft size={14} />
{t("ui.admin.tenants.detail.breadcrumb_list", "테넌트 목록")} {t("ui.admin.tenants.detail.breadcrumb_list", "테넌트 목록")}
</Link> </Link>
<span>/</span> <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> </div>
<h2 className="text-3xl font-semibold"> <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> </h2>
<p className="text-sm text-[var(--color-muted)]"> <p className="text-sm text-[var(--color-muted)]">
{t("ui.admin.tenants.detail.header_subtitle", "테넌트 정보를 수정하거나 연동 설정을 관리합니다.")} {t(
"ui.admin.tenants.detail.header_subtitle",
"테넌트 정보를 수정하거나 연동 설정을 관리합니다.",
)}
</p> </p>
</div> </div>
<Badge variant="muted">{t("ui.common.admin_only", "관리자 전용")}</Badge> <Badge variant="muted">
{t("ui.common.admin_only", "관리자 전용")}
</Badge>
</header> </header>
{/* Tabs */} {/* Tabs */}

View File

@@ -43,9 +43,15 @@ import {
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
import { toast } from "sonner"; 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 nodes: UserGroupNode[] = [];
const childrenOf = new Map<string, UserGroupNode[]>(); const childrenOf = new Map<string, UserGroupNode[]>();
@@ -56,7 +62,10 @@ function buildGroupTree(groups: GroupSummary[], parentId: string | null = null):
// Second pass: Populate children // Second pass: Populate children
groups.forEach((group) => { 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) { if (group.parentId === parentId) {
nodes.push(node); nodes.push(node);
} else { } else {
@@ -73,7 +82,7 @@ function buildGroupTree(groups: GroupSummary[], parentId: string | null = null):
// Sort children for consistent rendering (optional, but good for UI) // Sort children for consistent rendering (optional, but good for UI)
nodes.sort((a, b) => a.name.localeCompare(b.name)); 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)); node.children.sort((a, b) => a.name.localeCompare(b.name));
}); });
@@ -130,11 +139,16 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
<ChevronRight size={16} /> <ChevronRight size={16} />
)} )}
</Button> </Button>
) : (level > 0 && ( ) : (
level > 0 && (
<span className="inline-block w-6 text-center"> <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> </span>
))} )
)}
<Users size={14} className="text-muted-foreground" /> <Users size={14} className="text-muted-foreground" />
<span className="font-semibold">{node.name}</span> <span className="font-semibold">{node.name}</span>
<Badge variant="secondary" className="text-[10px] font-mono"> <Badge variant="secondary" className="text-[10px] font-mono">
@@ -221,7 +235,12 @@ function TenantGroupsPage() {
parentId: newGroupParentId || undefined, parentId: newGroupParentId || undefined,
}), }),
onSuccess: () => { onSuccess: () => {
toast.success(t("msg.admin.groups.list.create_success", "그룹이 성공적으로 생성되었습니다.")); toast.success(
t(
"msg.admin.groups.list.create_success",
"그룹이 성공적으로 생성되었습니다.",
),
);
groupsQuery.refetch(); groupsQuery.refetch();
setNewGroupName(""); setNewGroupName("");
setNewGroupNameDesc(""); setNewGroupNameDesc("");
@@ -229,21 +248,27 @@ function TenantGroupsPage() {
setNewGroupParentId(null); setNewGroupParentId(null);
}, },
onError: (error: AxiosError<{ error?: string }>) => { 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({ const deleteMutation = useMutation({
mutationFn: (id: string) => deleteGroup(tenantId, id), mutationFn: (id: string) => deleteGroup(tenantId, id),
onSuccess: () => { onSuccess: () => {
toast.success(t("msg.admin.groups.list.delete_success", "그룹이 삭제되었습니다.")); toast.success(
t("msg.admin.groups.list.delete_success", "그룹이 삭제되었습니다."),
);
groupsQuery.refetch(); groupsQuery.refetch();
setSelectedGroupId(null); setSelectedGroupId(null);
}, },
onError: (error: AxiosError<{ error?: string }>) => { 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 }) => mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
addGroupMember(tenantId, groupId, userId), addGroupMember(tenantId, groupId, userId),
onSuccess: () => { onSuccess: () => {
toast.success(t("msg.admin.groups.members.add_success", "멤버가 추가되었습니다.")); toast.success(
t("msg.admin.groups.members.add_success", "멤버가 추가되었습니다."),
);
groupsQuery.refetch(); groupsQuery.refetch();
}, },
onError: (error: AxiosError<{ error?: string }>) => { 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 }) => mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
removeGroupMember(tenantId, groupId, userId), removeGroupMember(tenantId, groupId, userId),
onSuccess: () => { onSuccess: () => {
toast.success(t("msg.admin.groups.members.remove_success", "멤버가 제거되었습니다.")); toast.success(
t("msg.admin.groups.members.remove_success", "멤버가 제거되었습니다."),
);
groupsQuery.refetch(); groupsQuery.refetch();
}, },
onError: (error: AxiosError<{ error?: string }>) => { 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) => { const handleAddSubGroup = (parentId: string) => {
setNewGroupParentId(parentId); setNewGroupParentId(parentId);
@@ -304,7 +339,10 @@ function TenantGroupsPage() {
{t("ui.admin.groups.create.title", "새 그룹 생성")} {t("ui.admin.groups.create.title", "새 그룹 생성")}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{t("ui.admin.groups.create.description", "새로운 사용자 그룹을 생성하고 계층 구조를 설정합니다.")} {t(
"ui.admin.groups.create.description",
"새로운 사용자 그룹을 생성하고 계층 구조를 설정합니다.",
)}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
@@ -425,8 +463,14 @@ function TenantGroupsPage() {
)} )}
{!groupsQuery.isLoading && groupTree.length === 0 && ( {!groupsQuery.isLoading && groupTree.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={3} className="text-center py-8 text-muted-foreground"> <TableCell
{t("msg.admin.groups.list.empty", "아직 등록된 그룹이 없습니다.")} colSpan={3}
className="text-center py-8 text-muted-foreground"
>
{t(
"msg.admin.groups.list.empty",
"아직 등록된 그룹이 없습니다.",
)}
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
@@ -438,7 +482,14 @@ function TenantGroupsPage() {
onSelect={setSelectedGroupId} onSelect={setSelectedGroupId}
selectedGroupId={selectedGroupId} selectedGroupId={selectedGroupId}
onDelete={(id) => { onDelete={(id) => {
if (window.confirm(t("msg.admin.groups.list.delete_confirm", "그룹을 삭제하시겠습니까?"))) { if (
window.confirm(
t(
"msg.admin.groups.list.delete_confirm",
"그룹을 삭제하시겠습니까?",
),
)
) {
deleteMutation.mutate(id); deleteMutation.mutate(id);
} }
}} }}
@@ -464,12 +515,19 @@ function TenantGroupsPage() {
})} })}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{t("ui.admin.groups.detail.members_subtitle", "그룹에 속한 멤버들을 확인하고 관리합니다.")} {t(
"ui.admin.groups.detail.members_subtitle",
"그룹에 속한 멤버들을 확인하고 관리합니다.",
)}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex justify-end mb-4"> <div className="flex justify-end mb-4">
<Button size="sm" onClick={() => handleAddMember(currentGroup.id)} disabled={addMemberMutation.isPending}> <Button
size="sm"
onClick={() => handleAddMember(currentGroup.id)}
disabled={addMemberMutation.isPending}
>
<UserPlus size={14} className="mr-1" /> <UserPlus size={14} className="mr-1" />
{t("ui.common.add", "멤버 추가")} {t("ui.common.add", "멤버 추가")}
</Button> </Button>

View File

@@ -1,12 +1,6 @@
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios"; import type { AxiosError } from "axios";
import { import { CornerDownRight, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
CornerDownRight,
Pencil,
Plus,
RefreshCw,
Trash2,
} from "lucide-react";
import React from "react"; import React from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { Badge } from "../../../components/ui/badge"; import { Badge } from "../../../components/ui/badge";
@@ -72,7 +66,9 @@ const TenantRow: React.FC<{
<TableRow key={tenant.id}> <TableRow key={tenant.id}>
<TableCell style={{ paddingLeft: `${1 + level * 1.5}rem` }}> <TableCell style={{ paddingLeft: `${1 + level * 1.5}rem` }}>
<div className="flex items-center gap-2"> <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> <span className="font-semibold">{tenant.name}</span>
</div> </div>
</TableCell> </TableCell>
@@ -267,7 +263,10 @@ function TenantListPage() {
)} )}
{!query.isLoading && tenantTree.length === 0 && ( {!query.isLoading && tenantTree.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground"> <TableCell
colSpan={6}
className="text-center py-8 text-muted-foreground"
>
{t( {t(
"msg.admin.tenants.empty", "msg.admin.tenants.empty",
"아직 등록된 테넌트가 없습니다.", "아직 등록된 테넌트가 없습니다.",
@@ -293,4 +292,3 @@ function TenantListPage() {
} }
export default TenantListPage; export default TenantListPage;

View File

@@ -29,7 +29,9 @@ export function TenantProfilePage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
if (!tenantId) { 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({ const tenantQuery = useQuery({
@@ -74,8 +76,11 @@ export function TenantProfilePage() {
toast.success(t("msg.info.saved_success", "저장되었습니다.")); toast.success(t("msg.info.saved_success", "저장되었습니다."));
}, },
onError: (err: AxiosError<{ error?: string }>) => { 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({ const approveMutation = useMutation({
@@ -83,18 +88,25 @@ export function TenantProfilePage() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenants"] }); queryClient.invalidateQueries({ queryKey: ["tenants"] });
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] }); 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 }>) => { 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({ const deleteMutation = useMutation({
mutationFn: () => deleteTenant(tenantId), mutationFn: () => deleteTenant(tenantId),
onSuccess: () => { onSuccess: () => {
navigate("/tenants"); 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; ?.response?.data?.error;
const handleDelete = () => { 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(); deleteMutation.mutate();
} }
}; };
const handleApprove = () => { const handleApprove = () => {
if (window.confirm(t("msg.admin.tenants.approve_confirm", "이 테넌트를 승인하시겠습니까?"))) { if (
window.confirm(
t("msg.admin.tenants.approve_confirm", "이 테넌트를 승인하시겠습니까?"),
)
) {
approveMutation.mutate(); approveMutation.mutate();
} }
}; };
@@ -119,9 +141,14 @@ export function TenantProfilePage() {
<> <>
<Card className="bg-[var(--color-panel)] mt-6"> <Card className="bg-[var(--color-panel)] mt-6">
<CardHeader> <CardHeader>
<CardTitle>{t("ui.admin.tenants.profile.title", "테넌트 프로필")}</CardTitle> <CardTitle>
{t("ui.admin.tenants.profile.title", "테넌트 프로필")}
</CardTitle>
<CardDescription> <CardDescription>
{t("ui.admin.tenants.profile.subtitle", "슬러그 및 상태 변경은 즉시 적용됩니다.")} {t(
"ui.admin.tenants.profile.subtitle",
"슬러그 및 상태 변경은 즉시 적용됩니다.",
)}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
@@ -132,30 +159,54 @@ export function TenantProfilePage() {
)} )}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-semibold"> <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> </Label>
<Input value={name} onChange={(e) => setName(e.target.value)} /> <Input value={name} onChange={(e) => setName(e.target.value)} />
</div> </div>
<div className="space-y-2"> <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 <select
id="type" 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" 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} value={type}
onChange={(e) => setType(e.target.value)} onChange={(e) => setType(e.target.value)}
> >
<option value="COMPANY">{t("domain.tenant_type.company", "COMPANY (일반 기업)")}</option> <option value="COMPANY">
<option value="COMPANY_GROUP">{t("domain.tenant_type.company_group", "COMPANY_GROUP (그룹사/지주사)")}</option> {t("domain.tenant_type.company", "COMPANY (일반 기업)")}
<option value="USER_GROUP">{t("domain.tenant_type.user_group", "USER_GROUP (내부 부서/팀)")}</option> </option>
<option value="PERSONAL">{t("domain.tenant_type.personal", "PERSONAL (개인 워크스페이스)")}</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> </select>
</div> </div>
<div className="space-y-2"> <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)} /> <Input value={slug} onChange={(e) => setSlug(e.target.value)} />
</div> </div>
<div className="space-y-2"> <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 <Textarea
rows={3} rows={3}
value={description} value={description}
@@ -164,7 +215,10 @@ export function TenantProfilePage() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-semibold"> <Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.allowed_domains", "허용된 도메인 (콤마로 구분)")} {t(
"ui.admin.tenants.profile.allowed_domains",
"허용된 도메인 (콤마로 구분)",
)}
</Label> </Label>
<Input <Input
value={domains} value={domains}
@@ -172,11 +226,16 @@ export function TenantProfilePage() {
placeholder="example.com, example.kr" placeholder="example.com, example.kr"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t("ui.admin.tenants.profile.allowed_domains_help", "이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.")} {t(
"ui.admin.tenants.profile.allowed_domains_help",
"이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.",
)}
</p> </p>
</div> </div>
<div className="space-y-2"> <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"> <div className="flex gap-3">
<Button <Button
type="button" type="button"

View File

@@ -81,10 +81,18 @@ export function TenantSchemaPage() {
}), }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] }); 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 }>) => { 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"> <option value="text">
{t("ui.admin.tenants.schema.field.type_text", "텍스트 (Text)")} {t(
"ui.admin.tenants.schema.field.type_text",
"텍스트 (Text)",
)}
</option> </option>
<option value="number"> <option value="number">
{t("ui.admin.tenants.schema.field.type_number", "숫자 (Number)")} {t(
"ui.admin.tenants.schema.field.type_number",
"숫자 (Number)",
)}
</option> </option>
<option value="boolean"> <option value="boolean">
{t("ui.admin.tenants.schema.field.type_boolean", "불리언 (Boolean)")} {t(
"ui.admin.tenants.schema.field.type_boolean",
"불리언 (Boolean)",
)}
</option> </option>
</select> </select>
</div> </div>

View File

@@ -65,12 +65,12 @@ function buildGroupTree(groups: GroupSummary[]): UserGroupNode[] {
const sortNodes = (nodes: UserGroupNode[]) => { const sortNodes = (nodes: UserGroupNode[]) => {
nodes.sort((a, b) => a.name.localeCompare(b.name)); nodes.sort((a, b) => a.name.localeCompare(b.name));
nodes.forEach(node => sortNodes(node.children)); nodes.forEach((node) => sortNodes(node.children));
}; };
sortNodes(rootNodes); sortNodes(rootNodes);
return rootNodes; return rootNodes;
} }
interface UserGroupTreeNodeProps { interface UserGroupTreeNodeProps {
node: UserGroupNode; node: UserGroupNode;
@@ -113,7 +113,11 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
setIsExpanded(!isExpanded); setIsExpanded(!isExpanded);
}} }}
> >
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />} {isExpanded ? (
<ChevronDown size={16} />
) : (
<ChevronRight size={16} />
)}
</Button> </Button>
)} )}
{!hasChildren && <div className="w-6" />} {!hasChildren && <div className="w-6" />}
@@ -151,7 +155,9 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
</Button> </Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
{isExpanded && hasChildren && node.children.map((child) => ( {isExpanded &&
hasChildren &&
node.children.map((child) => (
<UserGroupTreeNode <UserGroupTreeNode
key={child.id} key={child.id}
node={child} node={child}
@@ -166,7 +172,6 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
); );
}; };
export function TenantUserGroupsTab() { export function TenantUserGroupsTab() {
const params = useParams<{ tenantId: string }>(); const params = useParams<{ tenantId: string }>();
const tenantId = params.tenantId ?? ""; const tenantId = params.tenantId ?? "";
@@ -193,7 +198,12 @@ export function TenantUserGroupsTab() {
parentId: newGroupParentId || undefined, parentId: newGroupParentId || undefined,
}), }),
onSuccess: () => { onSuccess: () => {
toast.success(t("msg.admin.groups.list.create_success", "그룹이 성공적으로 생성되었습니다.")); toast.success(
t(
"msg.admin.groups.list.create_success",
"그룹이 성공적으로 생성되었습니다.",
),
);
groupsQuery.refetch(); groupsQuery.refetch();
setNewGroupName(""); setNewGroupName("");
setNewGroupNameDesc(""); setNewGroupNameDesc("");
@@ -201,46 +211,63 @@ export function TenantUserGroupsTab() {
setNewGroupParentId(null); setNewGroupParentId(null);
}, },
onError: (error: AxiosError<{ error?: string }>) => { 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({ const deleteMutation = useMutation({
mutationFn: (id: string) => deleteGroup(tenantId, id), mutationFn: (id: string) => deleteGroup(tenantId, id),
onSuccess: () => { onSuccess: () => {
toast.success(t("msg.admin.groups.list.delete_success", "그룹이 삭제되었습니다.")); toast.success(
t("msg.admin.groups.list.delete_success", "그룹이 삭제되었습니다."),
);
groupsQuery.refetch(); groupsQuery.refetch();
if (selectedGroupId && selectedGroupId === (deleteMutation.variables as any)) { if (
selectedGroupId &&
selectedGroupId === (deleteMutation.variables as any)
) {
setSelectedGroupId(null); setSelectedGroupId(null);
} }
}, },
onError: (error: AxiosError<{ error?: string }>) => { 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 addMemberMutation = useMutation({ const addMemberMutation = useMutation({
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) => mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
addGroupMember(tenantId, groupId, userId), addGroupMember(tenantId, groupId, userId),
onSuccess: () => { onSuccess: () => {
toast.success(t("msg.admin.groups.members.add_success", "멤버가 추가되었습니다.")); toast.success(
t("msg.admin.groups.members.add_success", "멤버가 추가되었습니다."),
);
groupsQuery.refetch(); groupsQuery.refetch();
}, },
onError: (error: AxiosError<{ error?: string }>) => { 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 removeMemberMutation = useMutation({ const removeMemberMutation = useMutation({
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) => mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
removeGroupMember(tenantId, groupId, userId), removeGroupMember(tenantId, groupId, userId),
onSuccess: () => { onSuccess: () => {
toast.success(t("msg.admin.groups.members.remove_success", "멤버가 제거되었습니다.")); toast.success(
t("msg.admin.groups.members.remove_success", "멤버가 제거되었습니다."),
);
groupsQuery.refetch(); groupsQuery.refetch();
}, },
onError: (error: AxiosError<{ error?: string }>) => { 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) : []; const groupTree = groupsQuery.data ? buildGroupTree(groupsQuery.data) : [];
@@ -250,13 +277,26 @@ export function TenantUserGroupsTab() {
}; };
const handleDeleteGroup = (groupId: string, groupName: string) => { const handleDeleteGroup = (groupId: string, groupName: string) => {
if (window.confirm(t("msg.admin.groups.list.delete_confirm", `그룹 "{{name}}"을(를) 삭제하시겠습니까?`, { name: groupName }))) { if (
window.confirm(
t(
"msg.admin.groups.list.delete_confirm",
`그룹 "{{name}}"을(를) 삭제하시겠습니까?`,
{ name: groupName },
),
)
) {
deleteMutation.mutate(groupId); deleteMutation.mutate(groupId);
} }
}; };
const handleAddMember = (groupId: string) => { const handleAddMember = (groupId: string) => {
const userId = window.prompt(t("msg.admin.groups.prompt.user_id", "추가할 사용자의 UUID를 입력하세요:")); const userId = window.prompt(
t(
"msg.admin.groups.prompt.user_id",
"추가할 사용자의 UUID를 입력하세요:",
),
);
if (userId) { if (userId) {
addMemberMutation.mutate({ groupId, userId }); addMemberMutation.mutate({ groupId, userId });
} }
@@ -270,35 +310,66 @@ export function TenantUserGroupsTab() {
<Card className="bg-[var(--color-panel)] md:col-span-1 border-primary/20"> <Card className="bg-[var(--color-panel)] md:col-span-1 border-primary/20">
<CardHeader> <CardHeader>
<CardTitle className="text-lg flex items-center gap-2"> <CardTitle className="text-lg flex items-center gap-2">
<Plus size={18} /> {t("ui.admin.groups.create.title", "새 그룹 생성")} <Plus size={18} />{" "}
{t("ui.admin.groups.create.title", "새 그룹 생성")}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="name">{t("ui.admin.groups.form.name_label", "그룹 이름")}</Label> <Label htmlFor="name">
<Input id="name" value={newGroupName} onChange={(e) => setNewGroupName(e.target.value)} /> {t("ui.admin.groups.form.name_label", "그룹 이름")}
</Label>
<Input
id="name"
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
/>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="unitType">{t("ui.admin.groups.form.unit_level_label", "조직 단위 레벨")}</Label> <Label htmlFor="unitType">
<Input id="unitType" value={newGroupUnitType} onChange={(e) => setNewGroupUnitType(e.target.value)} /> {t("ui.admin.groups.form.unit_level_label", "조직 단위 레벨")}
</Label>
<Input
id="unitType"
value={newGroupUnitType}
onChange={(e) => setNewGroupUnitType(e.target.value)}
/>
</div> </div>
<div className="space-y-1"> <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 <select
id="parentId" id="parentId"
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm" className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
value={newGroupParentId || ""} value={newGroupParentId || ""}
onChange={(e) => setNewGroupParentId(e.target.value || null)} onChange={(e) => setNewGroupParentId(e.target.value || null)}
> >
<option value="">{t("ui.admin.groups.form.parent_none", "없음 (최상위)")}</option> <option value="">
{groupsQuery.data?.map(group => <option key={group.id} value={group.id}>{group.name}</option>)} {t("ui.admin.groups.form.parent_none", "없음 (최상위)")}
</option>
{groupsQuery.data?.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</select> </select>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="desc">{t("ui.admin.groups.form.desc_label", "설명")}</Label> <Label htmlFor="desc">
<Input id="desc" value={newGroupDesc} onChange={(e) => setNewGroupNameDesc(e.target.value)} /> {t("ui.admin.groups.form.desc_label", "설명")}
</Label>
<Input
id="desc"
value={newGroupDesc}
onChange={(e) => setNewGroupNameDesc(e.target.value)}
/>
</div> </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", "생성하기")} {t("ui.admin.groups.form.submit", "생성하기")}
</Button> </Button>
</CardContent> </CardContent>
@@ -307,24 +378,61 @@ export function TenantUserGroupsTab() {
<Card className="bg-[var(--color-panel)] md:col-span-2"> <Card className="bg-[var(--color-panel)] md:col-span-2">
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<div> <div>
<CardTitle>{t("ui.admin.groups.list.title", "User Groups")}</CardTitle> <CardTitle>
<CardDescription>{t("msg.admin.groups.list.subtitle", "이 테넌트에 정의된 사용자 그룹 목록입니다.")}</CardDescription> {t("ui.admin.groups.list.title", "User Groups")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.groups.list.subtitle",
"이 테넌트에 정의된 사용자 그룹 목록입니다.",
)}
</CardDescription>
</div> </div>
<Button variant="ghost" size="sm" onClick={() => groupsQuery.refetch()}><RefreshCw size={14} /></Button> <Button
variant="ghost"
size="sm"
onClick={() => groupsQuery.refetch()}
>
<RefreshCw size={14} />
</Button>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>{t("ui.admin.groups.table.name", "NAME")}</TableHead> <TableHead>
<TableHead className="text-center">{t("ui.admin.groups.table.members", "MEMBERS")}</TableHead> {t("ui.admin.groups.table.name", "NAME")}
<TableHead className="text-right">{t("ui.admin.groups.table.actions", "ACTIONS")}</TableHead> </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> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{groupsQuery.isLoading && <TableRow><TableCell colSpan={3}>{t("msg.admin.groups.list.loading", "로딩 중...")}</TableCell></TableRow>} {groupsQuery.isLoading && (
{!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>} <TableRow>
{groupTree.map(node => ( <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 <UserGroupTreeNode
key={node.id} key={node.id}
node={node} node={node}
@@ -345,31 +453,66 @@ export function TenantUserGroupsTab() {
<Card className="bg-[var(--color-panel)] border-t-4 border-t-primary"> <Card className="bg-[var(--color-panel)] border-t-4 border-t-primary">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Shield size={18} className="text-primary" /> {t("msg.admin.groups.members.title", "[{{name}}] 멤버 관리", { name: currentGroup.name })} <Shield size={18} className="text-primary" />{" "}
{t("msg.admin.groups.members.title", "[{{name}}] 멤버 관리", {
name: currentGroup.name,
})}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex justify-end mb-4"> <div className="flex justify-end mb-4">
<Button size="sm" onClick={() => handleAddMember(currentGroup.id)} disabled={addMemberMutation.isPending}> <Button
<UserPlus size={14} className="mr-1" /> {t("ui.common.add", "멤버 추가")} size="sm"
onClick={() => handleAddMember(currentGroup.id)}
disabled={addMemberMutation.isPending}
>
<UserPlus size={14} className="mr-1" />{" "}
{t("ui.common.add", "멤버 추가")}
</Button> </Button>
</div> </div>
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>{t("ui.admin.groups.members.table.name", "이름")}</TableHead> <TableHead>
<TableHead>{t("ui.admin.groups.members.table.email", "이메일")}</TableHead> {t("ui.admin.groups.members.table.name", "이")}
<TableHead className="text-right">{t("ui.admin.groups.members.table.remove", "제거")}</TableHead> </TableHead>
<TableHead>
{t("ui.admin.groups.members.table.email", "이메일")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.groups.members.table.remove", "제거")}
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <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?.length === 0 && (
{currentGroup.members?.map(user => ( <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}> <TableRow key={user.id}>
<TableCell className="font-medium">{user.name}</TableCell> <TableCell className="font-medium">{user.name}</TableCell>
<TableCell className="text-muted-foreground">{user.email}</TableCell> <TableCell className="text-muted-foreground">
{user.email}
</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => removeMemberMutation.mutate({ groupId: currentGroup.id, userId: user.id })} disabled={removeMemberMutation.isPending}> <Button
variant="ghost"
size="sm"
onClick={() =>
removeMemberMutation.mutate({
groupId: currentGroup.id,
userId: user.id,
})
}
disabled={removeMemberMutation.isPending}
>
<UserMinus size={14} className="text-destructive" /> <UserMinus size={14} className="text-destructive" />
</Button> </Button>
</TableCell> </TableCell>
@@ -382,6 +525,6 @@ export function TenantUserGroupsTab() {
)} )}
</div> </div>
); );
} }
export default TenantUserGroupsTab; export default TenantUserGroupsTab;

View File

@@ -101,10 +101,14 @@ export function UserGroupDetailPage() {
queryClient.invalidateQueries({ queryKey: ["user-group-detail", id] }); queryClient.invalidateQueries({ queryKey: ["user-group-detail", id] });
setIsAddMemberOpen(false); setIsAddMemberOpen(false);
setSelectedUserId(""); setSelectedUserId("");
toast.success(t("msg.admin.groups.members.add_success", "구성원이 추가되었습니다.")); toast.success(
t("msg.admin.groups.members.add_success", "구성원이 추가되었습니다."),
);
}, },
onError: (error: any) => { 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), mutationFn: (userId: string) => removeGroupMember(tenantId!, id!, userId),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user-group-detail", id] }); 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: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user-group-roles", id] }); queryClient.invalidateQueries({ queryKey: ["user-group-roles", id] });
setIsAddRoleOpen(false); setIsAddRoleOpen(false);
toast.success(t("msg.admin.groups.roles.assign_success", "역할이 할당되었습니다.")); toast.success(
t("msg.admin.groups.roles.assign_success", "역할이 할당되었습니다."),
);
}, },
onError: (error: any) => { 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), removeGroupRole(tenantId!, id!, role.targetTenantId, role.relation),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user-group-roles", id] }); 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`} to={`/tenants/${tenantId}/organization`}
className="text-primary hover:underline text-sm" className="text-primary hover:underline text-sm"
> >
{t("ui.admin.groups.detail.breadcrumb_org", "조직 관리 목록으로 돌아가기")} {t(
"ui.admin.groups.detail.breadcrumb_org",
"조직 관리 목록으로 돌아가기",
)}
</Link> </Link>
</div> </div>
</div> </div>
@@ -196,7 +214,9 @@ export function UserGroupDetailPage() {
{t("ui.admin.groups.detail.breadcrumb_org", "조직 관리")} {t("ui.admin.groups.detail.breadcrumb_org", "조직 관리")}
</Link> </Link>
<span>/</span> <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>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg"> <div className="p-2 bg-primary/10 rounded-lg">
@@ -210,12 +230,17 @@ export function UserGroupDetailPage() {
)} )}
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{currentGroup.description || t("msg.common.no_description", "설명이 없습니다.")} {currentGroup.description ||
t("msg.common.no_description", "설명이 없습니다.")}
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Badge variant="outline" className="font-normal">{t("ui.admin.groups.detail.breadcrumb_unit", "조직 단위")}</Badge> <Badge variant="outline" className="font-normal">
<Badge variant="muted" className="font-normal">ID: {id?.split("-")[0]}...</Badge> {t("ui.admin.groups.detail.breadcrumb_unit", "조직 단위")}
</Badge>
<Badge variant="muted" className="font-normal">
ID: {id?.split("-")[0]}...
</Badge>
</div> </div>
</header> </header>
@@ -224,8 +249,15 @@ export function UserGroupDetailPage() {
<Card className="border-none shadow-sm bg-[var(--color-panel)]"> <Card className="border-none shadow-sm bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<div> <div>
<CardTitle>{t("ui.admin.groups.detail.members_title", "구성원 관리")}</CardTitle> <CardTitle>
<CardDescription>{t("ui.admin.groups.detail.members_subtitle", "이 조직에 소속된 사용자를 관리합니다.")}</CardDescription> {t("ui.admin.groups.detail.members_title", "구성원 관리")}
</CardTitle>
<CardDescription>
{t(
"ui.admin.groups.detail.members_subtitle",
"이 조직에 소속된 사용자를 관리합니다.",
)}
</CardDescription>
</div> </div>
<Dialog open={isAddMemberOpen} onOpenChange={setIsAddMemberOpen}> <Dialog open={isAddMemberOpen} onOpenChange={setIsAddMemberOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
@@ -236,16 +268,24 @@ export function UserGroupDetailPage() {
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{t("ui.admin.groups.detail.members_title", "구성원 추가")}</DialogTitle> <DialogTitle>
{t("ui.admin.groups.detail.members_title", "구성원 추가")}
</DialogTitle>
<DialogDescription> <DialogDescription>
{t("ui.admin.groups.detail.members_subtitle", "사용자를 검색하여 조직 구성원으로 추가합니다.")} {t(
"ui.admin.groups.detail.members_subtitle",
"사용자를 검색하여 조직 구성원으로 추가합니다.",
)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>{t("ui.common.search", "사용자 검색")}</Label> <Label>{t("ui.common.search", "사용자 검색")}</Label>
<Input <Input
placeholder={t("ui.admin.users.list.search_placeholder", "이메일 또는 이름으로 검색...")} placeholder={t(
"ui.admin.users.list.search_placeholder",
"이메일 또는 이름으로 검색...",
)}
value={searchUser} value={searchUser}
onChange={(e) => setSearchUser(e.target.value)} onChange={(e) => setSearchUser(e.target.value)}
/> />
@@ -257,7 +297,12 @@ export function UserGroupDetailPage() {
onValueChange={setSelectedUserId} onValueChange={setSelectedUserId}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder={t("ui.common.select_placeholder", "사용자를 선택하세요")} /> <SelectValue
placeholder={t(
"ui.common.select_placeholder",
"사용자를 선택하세요",
)}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{userList?.items.map((user) => ( {userList?.items.map((user) => (
@@ -291,30 +336,43 @@ export function UserGroupDetailPage() {
<Table> <Table>
<TableHeader className="bg-muted/30"> <TableHeader className="bg-muted/30">
<TableRow> <TableRow>
<TableHead className="font-bold">{t("ui.admin.users.list.table.name_email", "사용자")}</TableHead> <TableHead className="font-bold">
<TableHead className="text-right font-bold">{t("ui.admin.groups.table.actions", "액션")}</TableHead> {t("ui.admin.users.list.table.name_email", "사용자")}
</TableHead>
<TableHead className="text-right font-bold">
{t("ui.admin.groups.table.actions", "액션")}
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{!currentGroup.members || currentGroup.members.length === 0 ? ( {!currentGroup.members ||
currentGroup.members.length === 0 ? (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={2} colSpan={2}
className="text-center py-8 text-muted-foreground" className="text-center py-8 text-muted-foreground"
> >
{t("msg.admin.groups.members.empty", "구성원이 없습니다.")} {t(
"msg.admin.groups.members.empty",
"구성원이 없습니다.",
)}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
currentGroup.members.map((member) => ( 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> <TableCell>
<div className="flex items-center gap-3"> <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"> <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)} {member.name.charAt(0)}
</div> </div>
<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"> <p className="text-xs text-muted-foreground">
{member.email} {member.email}
</p> </p>
@@ -327,7 +385,15 @@ export function UserGroupDetailPage() {
size="icon" size="icon"
className="text-destructive hover:bg-destructive/10" className="text-destructive hover:bg-destructive/10"
onClick={() => { 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); removeMemberMutation.mutate(member.id);
} }
}} }}
@@ -348,9 +414,14 @@ export function UserGroupDetailPage() {
<Card className="border-none shadow-sm bg-[var(--color-panel)]"> <Card className="border-none shadow-sm bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<div> <div>
<CardTitle>{t("ui.admin.groups.detail.permissions_title", "권한 관리")}</CardTitle> <CardTitle>
{t("ui.admin.groups.detail.permissions_title", "권한 관리")}
</CardTitle>
<CardDescription> <CardDescription>
{t("ui.admin.groups.detail.permissions_subtitle", "이 조직이 다른 테넌트에 가지는 역할을 정의합니다.")} {t(
"ui.admin.groups.detail.permissions_subtitle",
"이 조직이 다른 테넌트에 가지는 역할을 정의합니다.",
)}
</CardDescription> </CardDescription>
</div> </div>
<Dialog open={isAddRoleOpen} onOpenChange={setIsAddRoleOpen}> <Dialog open={isAddRoleOpen} onOpenChange={setIsAddRoleOpen}>
@@ -362,20 +433,35 @@ export function UserGroupDetailPage() {
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{t("ui.admin.groups.detail.permissions_title", "테넌트 역할 할당")}</DialogTitle> <DialogTitle>
{t(
"ui.admin.groups.detail.permissions_title",
"테넌트 역할 할당",
)}
</DialogTitle>
<DialogDescription> <DialogDescription>
{t("msg.admin.groups.roles.description", "이 조직의 구성원들이 대상 테넌트에서 상속받을 역할을 선택하세요.")} {t(
"msg.admin.groups.roles.description",
"이 조직의 구성원들이 대상 테넌트에서 상속받을 역할을 선택하세요.",
)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>{t("ui.admin.users.detail.form.tenant", "대상 테넌트")}</Label> <Label>
{t("ui.admin.users.detail.form.tenant", "대상 테넌트")}
</Label>
<Select <Select
value={selectedTargetTenantId} value={selectedTargetTenantId}
onValueChange={setSelectedTargetTenantId} onValueChange={setSelectedTargetTenantId}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder={t("ui.admin.tenants.list.select_placeholder", "테넌트를 선택하세요")} /> <SelectValue
placeholder={t(
"ui.admin.tenants.list.select_placeholder",
"테넌트를 선택하세요",
)}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{tenantList?.items.map((t) => ( {tenantList?.items.map((t) => (
@@ -387,7 +473,9 @@ export function UserGroupDetailPage() {
</Select> </Select>
</div> </div>
<div className="space-y-2"> <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 <Select
value={selectedRelation} value={selectedRelation}
onValueChange={setSelectedRelation} onValueChange={setSelectedRelation}
@@ -431,9 +519,15 @@ export function UserGroupDetailPage() {
<Table> <Table>
<TableHeader className="bg-muted/30"> <TableHeader className="bg-muted/30">
<TableRow> <TableRow>
<TableHead className="font-bold">{t("ui.admin.users.detail.form.tenant", "대상 테넌트")}</TableHead> <TableHead className="font-bold">
<TableHead className="font-bold">{t("ui.admin.users.detail.form.role", "역할")}</TableHead> {t("ui.admin.users.detail.form.tenant", "대상 테넌트")}
<TableHead className="text-right font-bold">{t("ui.admin.groups.table.actions", "액션")}</TableHead> </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> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -449,19 +543,28 @@ export function UserGroupDetailPage() {
colSpan={3} colSpan={3}
className="text-center py-8 text-muted-foreground" className="text-center py-8 text-muted-foreground"
> >
{t("msg.admin.groups.roles.empty", "할당된 역할이 없습니다.")} {t(
"msg.admin.groups.roles.empty",
"할당된 역할이 없습니다.",
)}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
groupRoles.map((role, idx) => ( 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> <TableCell>
<div className="font-medium text-sm"> <div className="font-medium text-sm">
{role.tenantName || role.tenantId} {role.tenantName || role.tenantId}
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant="outline" className="capitalize font-normal"> <Badge
variant="outline"
className="capitalize font-normal"
>
{role.relation} {role.relation}
</Badge> </Badge>
</TableCell> </TableCell>
@@ -471,7 +574,11 @@ export function UserGroupDetailPage() {
size="icon" size="icon"
className="text-destructive hover:bg-destructive/10" className="text-destructive hover:bg-destructive/10"
onClick={() => { onClick={() => {
if (confirm(t("msg.admin.groups.roles.remove_confirm"))) { if (
confirm(
t("msg.admin.groups.roles.remove_confirm"),
)
) {
removeRoleMutation.mutate({ removeRoleMutation.mutate({
targetTenantId: role.tenantId, targetTenantId: role.tenantId,
relation: role.relation, relation: role.relation,

View File

@@ -200,7 +200,10 @@ function UserListPage() {
)} )}
</TableHead> </TableHead>
<TableHead> <TableHead>
{t("ui.admin.users.list.table.position_job", "POSITION / JOB")} {t(
"ui.admin.users.list.table.position_job",
"POSITION / JOB",
)}
</TableHead> </TableHead>
<TableHead> <TableHead>
{t("ui.admin.users.list.table.created", "CREATED")} {t("ui.admin.users.list.table.created", "CREATED")}
@@ -277,7 +280,9 @@ function UserListPage() {
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex flex-col text-sm"> <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"> <span className="text-xs text-muted-foreground">
{user.jobTitle || "-"} {user.jobTitle || "-"}
</span> </span>

View File

@@ -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" 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", "언어")} aria-label={t("ui.common.language", "언어")}
> >
<option value="ko"> <option value="ko">{t("ui.common.language_ko", "한국어")}</option>
{t("ui.common.language_ko", "한국어")} <option value="en">{t("ui.common.language_en", "English")}</option>
</option>
<option value="en">
{t("ui.common.language_en", "English")}
</option>
</select> </select>
); );
} }

View File

@@ -63,7 +63,8 @@ function LoginPage() {
</Button> </Button>
<p className="mt-6 text-xs text-center text-muted-foreground leading-relaxed"> <p className="mt-6 text-xs text-center text-muted-foreground leading-relaxed">
.<br /> .
<br />
. .
</p> </p>
</CardContent> </CardContent>
@@ -76,7 +77,8 @@ function LoginPage() {
</div> </div>
<p className="px-8 text-center text-sm text-muted-foreground"> <p className="px-8 text-center text-sm text-muted-foreground">
<br />
<br />
. .
</p> </p>
</div> </div>

View File

@@ -208,11 +208,7 @@ function ClientsPage() {
<div className="mt-1 flex items-baseline gap-2"> <div className="mt-1 flex items-baseline gap-2">
<span className="text-3xl font-bold">{item.value}</span> <span className="text-3xl font-bold">{item.value}</span>
<Badge <Badge
variant={ variant={item.tone === "up" ? "success" : "muted"}
item.tone === "up"
? "success"
: "muted"
}
className={cn( className={cn(
"px-2", "px-2",
item.tone === "stable" && "bg-muted/40 text-foreground", item.tone === "stable" && "bg-muted/40 text-foreground",

View File

@@ -34,16 +34,12 @@ class _LocaleGateState extends State<LocaleGate> {
Future<void> _applyLocale() async { Future<void> _applyLocale() async {
final normalized = normalizeLocaleCode(widget.localeCode); final normalized = normalizeLocaleCode(widget.localeCode);
LocaleStorage.write(normalized); LocaleStorage.write(normalized);
webWindow.setTitle( webWindow.setTitle(tr('ui.userfront.app_title'));
tr('ui.userfront.app_title'),
);
if (context.locale.languageCode == normalized) { if (context.locale.languageCode == normalized) {
return; return;
} }
await context.setLocale(Locale(normalized)); await context.setLocale(Locale(normalized));
webWindow.setTitle( webWindow.setTitle(tr('ui.userfront.app_title'));
tr('ui.userfront.app_title'),
);
} }
@override @override

View File

@@ -299,10 +299,7 @@ class AuthProxyService {
} else { } else {
final errorBody = jsonDecode(response.body); final errorBody = jsonDecode(response.body);
throw Exception( throw Exception(
errorBody['error'] ?? errorBody['error'] ?? tr('err.userfront.auth_proxy.consent_fetch'),
tr(
'err.userfront.auth_proxy.consent_fetch',
),
); );
} }
} finally { } finally {
@@ -333,10 +330,7 @@ class AuthProxyService {
} else { } else {
final errorBody = jsonDecode(response.body); final errorBody = jsonDecode(response.body);
throw Exception( throw Exception(
errorBody['error'] ?? errorBody['error'] ?? tr('err.userfront.auth_proxy.consent_accept'),
tr(
'err.userfront.auth_proxy.consent_accept',
),
); );
} }
} finally { } finally {
@@ -363,10 +357,7 @@ class AuthProxyService {
} else { } else {
final errorBody = jsonDecode(response.body); final errorBody = jsonDecode(response.body);
throw Exception( throw Exception(
errorBody['error'] ?? errorBody['error'] ?? tr('err.userfront.auth_proxy.consent_reject'),
tr(
'err.userfront.auth_proxy.consent_reject',
),
); );
} }
} finally { } finally {
@@ -810,7 +801,7 @@ class AuthProxyService {
body: jsonEncode({ body: jsonEncode({
'level': level, 'level': level,
'message': message, 'message': message,
if (data != null) 'data': data, 'data': ?data,
}), }),
); );
_recordClientLogSuccess(); _recordClientLogSuccess();
@@ -934,7 +925,7 @@ class AuthProxyService {
'name': name, 'name': name,
'phone': phone, 'phone': phone,
'affiliationType': affiliationType, 'affiliationType': affiliationType,
if (companyCode != null) 'companyCode': companyCode, 'companyCode': ?companyCode,
'department': department, 'department': department,
'termsAccepted': termsAccepted, 'termsAccepted': termsAccepted,
}), }),

View File

@@ -15,10 +15,7 @@ class LanguageSelector extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final current = context.locale.languageCode; final current = context.locale.languageCode;
final items = [ final items = [
DropdownMenuItem( DropdownMenuItem(value: 'ko', child: Text(tr('ui.common.language_ko'))),
value: 'ko',
child: Text(tr('ui.common.language_ko')),
),
DropdownMenuItem( DropdownMenuItem(
value: 'en', value: 'en',
child: Text(tr('ui.common.language_en', fallback: 'English')), child: Text(tr('ui.common.language_en', fallback: 'English')),

View File

@@ -39,9 +39,7 @@ class ErrorScreen extends StatelessWidget {
'msg.userfront.error.title_with_code', 'msg.userfront.error.title_with_code',
params: {'code': normalizedCode}, params: {'code': normalizedCode},
) )
: tr( : tr('msg.userfront.error.title_generic'));
'msg.userfront.error.title_generic',
));
final detail = isProd final detail = isProd
? (isInternalWhitelisted ? (isInternalWhitelisted
? tr( ? tr(
@@ -51,23 +49,16 @@ class ErrorScreen extends StatelessWidget {
: (isOryBypass : (isOryBypass
? tr( ? tr(
'msg.userfront.error.ory.$normalizedCode', 'msg.userfront.error.ory.$normalizedCode',
fallback: fallback: (description?.isNotEmpty == true)
(description?.isNotEmpty == true)
? description ? description
: tr('msg.userfront.error.detail_request'), : tr('msg.userfront.error.detail_request'),
) )
: tr( : tr('msg.userfront.error.detail_contact')))
'msg.userfront.error.detail_contact',
)))
: ((description?.isNotEmpty == true) : ((description?.isNotEmpty == true)
? description! ? description!
: (hasCode : (hasCode
? tr( ? tr('msg.userfront.error.detail_generic')
'msg.userfront.error.detail_generic', : tr('msg.userfront.error.detail_request')));
)
: tr(
'msg.userfront.error.detail_request',
)));
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF7F8FA), backgroundColor: const Color(0xFFF7F8FA),
@@ -104,10 +95,7 @@ class ErrorScreen extends StatelessWidget {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
tr( tr('msg.userfront.error.type', params: {'type': errorType}),
'msg.userfront.error.type',
params: {'type': errorType},
),
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: const Color(0xFF6B7280), color: const Color(0xFF6B7280),
), ),
@@ -115,10 +103,7 @@ class ErrorScreen extends StatelessWidget {
if (errorId != null && errorId!.isNotEmpty) ...[ if (errorId != null && errorId!.isNotEmpty) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
tr( tr('msg.userfront.error.id', params: {'id': errorId!}),
'msg.userfront.error.id',
params: {'id': errorId!},
),
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: const Color(0xFF6B7280), color: const Color(0xFF6B7280),
), ),
@@ -142,11 +127,7 @@ class ErrorScreen extends StatelessWidget {
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
), ),
child: Text( child: Text(tr('ui.userfront.error.go_login')),
tr(
'ui.userfront.error.go_login',
),
),
), ),
OutlinedButton( OutlinedButton(
onPressed: () => context.go('/'), onPressed: () => context.go('/'),
@@ -161,9 +142,7 @@ class ErrorScreen extends StatelessWidget {
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
), ),
child: Text( child: Text(tr('ui.userfront.error.go_home')),
tr('ui.userfront.error.go_home'),
),
), ),
], ],
), ),

View File

@@ -25,11 +25,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
Future<void> _handlePasswordReset() async { Future<void> _handlePasswordReset() async {
final input = _loginIdController.text.trim(); final input = _loginIdController.text.trim();
if (input.isEmpty) { if (input.isEmpty) {
_showError( _showError(tr('msg.userfront.forgot.input_required'));
tr(
'msg.userfront.forgot.input_required',
),
);
return; return;
} }
@@ -52,11 +48,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(tr('msg.userfront.forgot.sent')),
tr(
'msg.userfront.forgot.sent',
),
),
backgroundColor: Colors.green, backgroundColor: Colors.green,
), ),
); );
@@ -65,10 +57,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
_showError( _showError(
tr( tr('msg.userfront.forgot.error', params: {'error': e.toString()}),
'msg.userfront.forgot.error',
params: {'error': e.toString()},
),
); );
} }
} finally { } finally {
@@ -133,9 +122,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
tr( tr('msg.userfront.forgot.dry_send'),
'msg.userfront.forgot.dry_send',
),
style: const TextStyle( style: const TextStyle(
color: Color(0xFF8A6D3B), color: Color(0xFF8A6D3B),
fontSize: 12, fontSize: 12,
@@ -148,9 +135,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
], ],
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
tr( tr('msg.userfront.forgot.description'),
'msg.userfront.forgot.description',
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey), style: const TextStyle(color: Colors.grey),
), ),
@@ -158,9 +143,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
TextField( TextField(
controller: _loginIdController, controller: _loginIdController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: tr( labelText: tr('ui.userfront.forgot.input_label'),
'ui.userfront.forgot.input_label',
),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.person_outline), prefixIcon: const Icon(Icons.person_outline),
), ),
@@ -181,9 +164,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
color: Colors.white, color: Colors.white,
), ),
) )
: Text( : Text(tr('ui.userfront.forgot.submit')),
tr('ui.userfront.forgot.submit'),
),
), ),
], ],
), ),

View File

@@ -900,8 +900,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
_onLoginSuccess(jwt, provider: provider, redirectTo: redirectTo); _onLoginSuccess(jwt, provider: provider, redirectTo: redirectTo);
} else if (redirectTo != null && redirectTo.isNotEmpty) { } else if (redirectTo != null && redirectTo.isNotEmpty) {
webWindow.redirectTo(redirectTo); webWindow.redirectTo(redirectTo);
} else { } else {}
}
} catch (e) { } catch (e) {
if (e.toString().contains("User not registered")) { if (e.toString().contains("User not registered")) {
_showUnregisteredDialog(); _showUnregisteredDialog();
@@ -1124,8 +1123,11 @@ 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 { try {
if (!mounted) { if (!mounted) {
return; return;
@@ -1160,14 +1162,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (nextRedirectTo != null && nextRedirectTo.isNotEmpty) { if (nextRedirectTo != null && nextRedirectTo.isNotEmpty) {
webWindow.redirectTo(nextRedirectTo); // Removed await webWindow.redirectTo(nextRedirectTo); // Removed await
return; return;
} else { } else {}
}
} catch (e) { } catch (e) {
_showError( _showError(tr('msg.userfront.login.oidc_failed'));
tr(
'msg.userfront.login.oidc_failed',
),
);
return; return;
} }
} }
@@ -1188,7 +1185,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final uri = Uri.base; final uri = Uri.base;
final redirectParam = final redirectParam =
uri.queryParameters['redirect_uri'] ?? uri.queryParameters['redirect_url']; uri.queryParameters['redirect_uri'] ??
uri.queryParameters['redirect_url'];
final hasRedirectParam = final hasRedirectParam =
redirectParam != null && redirectParam.isNotEmpty; redirectParam != null && redirectParam.isNotEmpty;

View File

@@ -26,9 +26,7 @@ class LoginSuccessScreen extends StatelessWidget {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
tr( tr('msg.userfront.login_success.subtitle'),
'msg.userfront.login_success.subtitle',
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey, fontSize: 16), style: const TextStyle(color: Colors.grey, fontSize: 16),
), ),
@@ -40,11 +38,7 @@ class LoginSuccessScreen extends StatelessWidget {
context.push('/scan'); context.push('/scan');
}, },
icon: const Icon(Icons.camera_alt, size: 28), icon: const Icon(Icons.camera_alt, size: 28),
label: Text( label: Text(tr('ui.userfront.login_success.qr')),
tr(
'ui.userfront.login_success.qr',
),
),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(80), // 버튼 높이를 더 크게 minimumSize: const Size.fromHeight(80), // 버튼 높이를 더 크게
backgroundColor: Colors.blue.shade700, backgroundColor: Colors.blue.shade700,
@@ -63,9 +57,7 @@ class LoginSuccessScreen extends StatelessWidget {
context.go('/'); context.go('/');
}, },
child: Text( child: Text(
tr( tr('ui.userfront.login_success.later'),
'ui.userfront.login_success.later',
),
style: const TextStyle(color: Colors.grey), style: const TextStyle(color: Colors.grey),
), ),
), ),

View File

@@ -21,7 +21,9 @@ class _QRScanScreenState extends State<QRScanScreen> {
), ),
), ),
body: const Center( 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.',
),
), ),
); );
} }

View File

@@ -69,11 +69,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
if (_formKey.currentState?.validate() != true) return; if (_formKey.currentState?.validate() != true) return;
if ((_loginId == null || _loginId!.isEmpty) && if ((_loginId == null || _loginId!.isEmpty) &&
(_token == null || _token!.isEmpty)) { (_token == null || _token!.isEmpty)) {
_showError( _showError(tr('msg.userfront.reset.invalid_link'));
tr(
'msg.userfront.reset.invalid_link',
),
);
return; return;
} }
@@ -89,11 +85,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(tr('msg.userfront.reset.success')),
tr(
'msg.userfront.reset.success',
),
),
backgroundColor: Colors.green, backgroundColor: Colors.green,
), ),
); );
@@ -123,9 +115,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
String _buildPolicyDescription() { String _buildPolicyDescription() {
if (_isPolicyLoading) { if (_isPolicyLoading) {
return tr( return tr('msg.userfront.reset.policy_loading');
'msg.userfront.reset.policy_loading',
);
} }
final minLength = (_policy?['minLength'] as int?) ?? 12; final minLength = (_policy?['minLength'] as int?) ?? 12;
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0; final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
@@ -149,22 +139,16 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
); );
} }
if (requiresLower) { if (requiresLower) {
parts.add( parts.add(tr('msg.userfront.reset.policy.lowercase'));
tr('msg.userfront.reset.policy.lowercase'),
);
} }
if (requiresUpper) { if (requiresUpper) {
parts.add( parts.add(tr('msg.userfront.reset.policy.uppercase'));
tr('msg.userfront.reset.policy.uppercase'),
);
} }
if (requiresNumber) { if (requiresNumber) {
parts.add(tr('msg.userfront.reset.policy.number')); parts.add(tr('msg.userfront.reset.policy.number'));
} }
if (requiresSymbol) { if (requiresSymbol) {
parts.add( parts.add(tr('msg.userfront.reset.policy.symbol'));
tr('msg.userfront.reset.policy.symbol'),
);
} }
return parts.join(", "); return parts.join(", ");
@@ -192,9 +176,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Text(
tr( tr('ui.userfront.reset.subtitle'),
'ui.userfront.reset.subtitle',
),
style: TextStyle( style: TextStyle(
fontSize: 28, fontSize: 28,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -212,9 +194,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
controller: _passwordController, controller: _passwordController,
obscureText: _isPasswordObscured, obscureText: _isPasswordObscured,
decoration: InputDecoration( decoration: InputDecoration(
labelText: tr( labelText: tr('ui.userfront.reset.new_password'),
'ui.userfront.reset.new_password',
),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock_outline), prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton( suffixIcon: IconButton(
@@ -265,25 +245,17 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
} }
if ((_policy?['lowercase'] ?? true) && !hasLower) { if ((_policy?['lowercase'] ?? true) && !hasLower) {
return tr( return tr('msg.userfront.reset.error.lowercase');
'msg.userfront.reset.error.lowercase',
);
} }
if ((_policy?['uppercase'] ?? false) && !hasUpper) { if ((_policy?['uppercase'] ?? false) && !hasUpper) {
return tr( return tr('msg.userfront.reset.error.uppercase');
'msg.userfront.reset.error.uppercase',
);
} }
if ((_policy?['number'] ?? true) && !hasNumber) { if ((_policy?['number'] ?? true) && !hasNumber) {
return tr( return tr('msg.userfront.reset.error.number');
'msg.userfront.reset.error.number',
);
} }
if ((_policy?['nonAlphanumeric'] ?? true) && if ((_policy?['nonAlphanumeric'] ?? true) &&
!hasSymbol) { !hasSymbol) {
return tr( return tr('msg.userfront.reset.error.symbol');
'msg.userfront.reset.error.symbol',
);
} }
return null; return null;
}, },
@@ -293,9 +265,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
controller: _confirmPasswordController, controller: _confirmPasswordController,
obscureText: _isConfirmPasswordObscured, obscureText: _isConfirmPasswordObscured,
decoration: InputDecoration( decoration: InputDecoration(
labelText: tr( labelText: tr('ui.userfront.reset.confirm_password'),
'ui.userfront.reset.confirm_password',
),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock_outline), prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton( suffixIcon: IconButton(
@@ -314,9 +284,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
), ),
validator: (value) { validator: (value) {
if (value != _passwordController.text) { if (value != _passwordController.text) {
return tr( return tr('msg.userfront.reset.error.mismatch');
'msg.userfront.reset.error.mismatch',
);
} }
return null; return null;
}, },
@@ -336,11 +304,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
color: Colors.white, color: Colors.white,
), ),
) )
: Text( : Text(tr('ui.userfront.reset.submit')),
tr(
'ui.userfront.reset.submit',
),
),
), ),
], ],
), ),
@@ -364,9 +328,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
tr( tr('msg.userfront.reset.invalid_body'),
'msg.userfront.reset.invalid_body',
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
], ],

View File

@@ -164,11 +164,7 @@ class _SignupScreenState extends State<SignupScreen> {
final email = _emailController.text.trim(); final email = _emailController.text.trim();
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$'); final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
if (!emailRegex.hasMatch(email)) { if (!emailRegex.hasMatch(email)) {
setState( setState(() => _emailError = tr('msg.userfront.signup.email.invalid'));
() => _emailError = tr(
'msg.userfront.signup.email.invalid',
),
);
return; return;
} }
setState(() { setState(() {
@@ -179,9 +175,7 @@ class _SignupScreenState extends State<SignupScreen> {
final available = await AuthProxyService.checkEmailAvailability(email); final available = await AuthProxyService.checkEmailAvailability(email);
if (!available) { if (!available) {
setState( setState(
() => _emailError = tr( () => _emailError = tr('msg.userfront.signup.email.duplicate'),
'msg.userfront.signup.email.duplicate',
),
); );
return; return;
} }
@@ -217,9 +211,7 @@ class _SignupScreenState extends State<SignupScreen> {
}); });
} else { } else {
setState( setState(
() => _emailError = tr( () => _emailError = tr('msg.userfront.signup.email.code_mismatch'),
'msg.userfront.signup.email.code_mismatch',
),
); );
} }
} catch (e) { } catch (e) {
@@ -272,9 +264,7 @@ class _SignupScreenState extends State<SignupScreen> {
}); });
} else { } else {
setState( setState(
() => _phoneError = tr( () => _phoneError = tr('msg.userfront.signup.phone.code_mismatch'),
'msg.userfront.signup.phone.code_mismatch',
),
); );
} }
} catch (e) { } catch (e) {
@@ -329,17 +319,11 @@ class _SignupScreenState extends State<SignupScreen> {
'msg.userfront.signup.password.lowercase_required', 'msg.userfront.signup.password.lowercase_required',
); );
} else if (eStr.contains('digit') || eStr.contains('number')) { } else if (eStr.contains('digit') || eStr.contains('number')) {
_passwordError = tr( _passwordError = tr('msg.userfront.signup.password.number_required');
'msg.userfront.signup.password.number_required',
);
} else if (eStr.contains('symbol') || eStr.contains('special')) { } else if (eStr.contains('symbol') || eStr.contains('special')) {
_passwordError = tr( _passwordError = tr('msg.userfront.signup.password.symbol_required');
'msg.userfront.signup.password.symbol_required',
);
} else if (eStr.contains('length') || eStr.contains('12 characters')) { } else if (eStr.contains('length') || eStr.contains('12 characters')) {
_passwordError = tr( _passwordError = tr('msg.userfront.signup.password.length_required');
'msg.userfront.signup.password.length_required',
);
} else { } else {
_passwordError = tr( _passwordError = tr(
'msg.userfront.signup.failed', 'msg.userfront.signup.failed',
@@ -357,18 +341,12 @@ class _SignupScreenState extends State<SignupScreen> {
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: Text( title: Text(tr('msg.userfront.signup.success.title')),
tr('msg.userfront.signup.success.title'), content: Text(tr('msg.userfront.signup.success.body')),
),
content: Text(
tr('msg.userfront.signup.success.body'),
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => context.go('/signin'), onPressed: () => context.go('/signin'),
child: Text( child: Text(tr('ui.userfront.signup.success.action')),
tr('ui.userfront.signup.success.action'),
),
), ),
], ],
), ),
@@ -382,25 +360,13 @@ class _SignupScreenState extends State<SignupScreen> {
padding: const EdgeInsets.symmetric(vertical: 20), padding: const EdgeInsets.symmetric(vertical: 20),
child: Row( child: Row(
children: [ children: [
_stepCircle( _stepCircle(1, tr('ui.userfront.signup.steps.agreement')),
1,
tr('ui.userfront.signup.steps.agreement'),
),
_stepLine(1), _stepLine(1),
_stepCircle( _stepCircle(2, tr('ui.userfront.signup.steps.verify')),
2,
tr('ui.userfront.signup.steps.verify'),
),
_stepLine(2), _stepLine(2),
_stepCircle( _stepCircle(3, tr('ui.userfront.signup.steps.profile')),
3,
tr('ui.userfront.signup.steps.profile'),
),
_stepLine(3), _stepLine(3),
_stepCircle( _stepCircle(4, tr('ui.userfront.signup.steps.password')),
4,
tr('ui.userfront.signup.steps.password'),
),
], ],
), ),
); );
@@ -454,9 +420,7 @@ class _SignupScreenState extends State<SignupScreen> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Text(
tr( tr('msg.userfront.signup.agreement.title'),
'msg.userfront.signup.agreement.title',
),
style: const TextStyle( style: const TextStyle(
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -489,18 +453,14 @@ class _SignupScreenState extends State<SignupScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_agreementSection( _agreementSection(
title: tr( title: tr('ui.userfront.signup.agreement.tos_title'),
'ui.userfront.signup.agreement.tos_title',
),
content: _tosText, content: _tosText,
value: _termsAccepted, value: _termsAccepted,
onChanged: (val) => setState(() => _termsAccepted = val!), onChanged: (val) => setState(() => _termsAccepted = val!),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_agreementSection( _agreementSection(
title: tr( title: tr('ui.userfront.signup.agreement.privacy_title'),
'ui.userfront.signup.agreement.privacy_title',
),
content: _privacyText, content: _privacyText,
value: _privacyAccepted, value: _privacyAccepted,
onChanged: (val) => setState(() => _privacyAccepted = val!), onChanged: (val) => setState(() => _privacyAccepted = val!),
@@ -745,9 +705,7 @@ class _SignupScreenState extends State<SignupScreen> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Text(
tr( tr('msg.userfront.signup.auth.title'),
'msg.userfront.signup.auth.title',
),
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -764,9 +722,7 @@ class _SignupScreenState extends State<SignupScreen> {
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
tr( tr('msg.userfront.signup.auth.affiliate_notice'),
'msg.userfront.signup.auth.affiliate_notice',
),
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
color: Colors.blue, color: Colors.blue,
@@ -790,9 +746,7 @@ class _SignupScreenState extends State<SignupScreen> {
controller: _emailController, controller: _emailController,
onChanged: _checkEmailAffiliation, // 도메인 실시간 체크 onChanged: _checkEmailAffiliation, // 도메인 실시간 체크
decoration: InputDecoration( decoration: InputDecoration(
labelText: tr( labelText: tr('ui.userfront.signup.auth.email.label'),
'ui.userfront.signup.auth.email.label',
),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
errorText: _emailError, errorText: _emailError,
hintText: 'example@hanmaceng.co.kr', hintText: 'example@hanmaceng.co.kr',
@@ -815,9 +769,7 @@ class _SignupScreenState extends State<SignupScreen> {
child: Text( child: Text(
_emailSeconds > 0 _emailSeconds > 0
? tr('ui.common.resend') ? tr('ui.common.resend')
: tr( : tr('ui.userfront.signup.auth.request_code'),
'ui.userfront.signup.auth.request_code',
),
), ),
), ),
), ),
@@ -828,9 +780,7 @@ class _SignupScreenState extends State<SignupScreen> {
TextFormField( TextFormField(
controller: _emailCodeController, controller: _emailCodeController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: tr( labelText: tr('ui.userfront.signup.auth.code_label'),
'ui.userfront.signup.auth.code_label',
),
suffixText: _formatTime(_emailSeconds), suffixText: _formatTime(_emailSeconds),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
@@ -848,9 +798,7 @@ class _SignupScreenState extends State<SignupScreen> {
Padding( Padding(
padding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
child: Text( child: Text(
tr( tr('msg.userfront.signup.email.verified'),
'msg.userfront.signup.email.verified',
),
style: const TextStyle( style: const TextStyle(
color: Colors.green, color: Colors.green,
fontSize: 13, fontSize: 13,
@@ -870,9 +818,7 @@ class _SignupScreenState extends State<SignupScreen> {
child: TextFormField( child: TextFormField(
controller: _phoneController, controller: _phoneController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: tr( labelText: tr('ui.userfront.signup.phone.label'),
'ui.userfront.signup.phone.label',
),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
errorText: _phoneError, errorText: _phoneError,
), ),
@@ -895,9 +841,7 @@ class _SignupScreenState extends State<SignupScreen> {
child: Text( child: Text(
_phoneSeconds > 0 _phoneSeconds > 0
? tr('ui.common.resend') ? tr('ui.common.resend')
: tr( : tr('ui.userfront.signup.auth.request_code'),
'ui.userfront.signup.auth.request_code',
),
), ),
), ),
), ),
@@ -908,9 +852,7 @@ class _SignupScreenState extends State<SignupScreen> {
TextFormField( TextFormField(
controller: _phoneCodeController, controller: _phoneCodeController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: tr( labelText: tr('ui.userfront.signup.auth.code_label'),
'ui.userfront.signup.auth.code_label',
),
suffixText: _formatTime(_phoneSeconds), suffixText: _formatTime(_phoneSeconds),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
@@ -928,9 +870,7 @@ class _SignupScreenState extends State<SignupScreen> {
Padding( Padding(
padding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
child: Text( child: Text(
tr( tr('msg.userfront.signup.phone.verified'),
'msg.userfront.signup.phone.verified',
),
style: const TextStyle( style: const TextStyle(
color: Colors.green, color: Colors.green,
fontSize: 13, fontSize: 13,
@@ -947,9 +887,7 @@ class _SignupScreenState extends State<SignupScreen> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Text(
tr( tr('msg.userfront.signup.profile.title'),
'msg.userfront.signup.profile.title',
),
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
@@ -971,28 +909,20 @@ class _SignupScreenState extends State<SignupScreen> {
key: ValueKey(_affiliationType), key: ValueKey(_affiliationType),
initialValue: _affiliationType, initialValue: _affiliationType,
decoration: InputDecoration( decoration: InputDecoration(
labelText: tr( labelText: tr('ui.userfront.signup.profile.affiliation_type'),
'ui.userfront.signup.profile.affiliation_type',
),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
helperText: _isAffiliateEmail helperText: _isAffiliateEmail
? tr( ? tr('msg.userfront.signup.profile.affiliate_hint')
'msg.userfront.signup.profile.affiliate_hint',
)
: null, : null,
), ),
items: [ items: [
DropdownMenuItem( DropdownMenuItem(
value: 'GENERAL', value: 'GENERAL',
child: Text( child: Text(tr('domain.affiliation.general')),
tr('domain.affiliation.general'),
),
), ),
DropdownMenuItem( DropdownMenuItem(
value: 'AFFILIATE', value: 'AFFILIATE',
child: Text( child: Text(tr('domain.affiliation.affiliate')),
tr('domain.affiliation.affiliate'),
),
), ),
], ],
onChanged: _isAffiliateEmail onChanged: _isAffiliateEmail
@@ -1019,9 +949,7 @@ class _SignupScreenState extends State<SignupScreen> {
key: ValueKey(_companyCode ?? 'none'), key: ValueKey(_companyCode ?? 'none'),
initialValue: _companyCode, initialValue: _companyCode,
decoration: InputDecoration( decoration: InputDecoration(
labelText: tr( labelText: tr('ui.userfront.signup.profile.company'),
'ui.userfront.signup.profile.company',
),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
items: [ items: [
@@ -1064,9 +992,7 @@ class _SignupScreenState extends State<SignupScreen> {
decoration: InputDecoration( decoration: InputDecoration(
labelText: _affiliationType == 'AFFILIATE' labelText: _affiliationType == 'AFFILIATE'
? tr('ui.userfront.signup.profile.department') ? tr('ui.userfront.signup.profile.department')
: tr( : tr('ui.userfront.signup.profile.department_optional'),
'ui.userfront.signup.profile.department_optional',
),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
), ),
@@ -1076,9 +1002,7 @@ class _SignupScreenState extends State<SignupScreen> {
String _buildPolicyDescription() { String _buildPolicyDescription() {
if (_isPolicyLoading) { if (_isPolicyLoading) {
return tr( return tr('msg.userfront.signup.policy.loading');
'msg.userfront.signup.policy.loading',
);
} }
final minLength = (_policy?['minLength'] as int?) ?? 12; final minLength = (_policy?['minLength'] as int?) ?? 12;
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0; final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
@@ -1147,9 +1071,7 @@ class _SignupScreenState extends State<SignupScreen> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Text(
tr( tr('msg.userfront.signup.password.title'),
'msg.userfront.signup.password.title',
),
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -1183,9 +1105,7 @@ class _SignupScreenState extends State<SignupScreen> {
obscureText: true, obscureText: true,
onChanged: (_) => setState(() {}), onChanged: (_) => setState(() {}),
decoration: InputDecoration( decoration: InputDecoration(
labelText: tr( labelText: tr('ui.userfront.signup.password.label'),
'ui.userfront.signup.password.label',
),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
errorText: _passwordError, errorText: _passwordError,
), ),
@@ -1211,16 +1131,12 @@ class _SignupScreenState extends State<SignupScreen> {
), ),
if (requiresUpper) if (requiresUpper)
_cryptoCheck( _cryptoCheck(
tr( tr('msg.userfront.signup.password.rule.uppercase'),
'msg.userfront.signup.password.rule.uppercase',
),
hasUpper, hasUpper,
), ),
if (requiresLower) if (requiresLower)
_cryptoCheck( _cryptoCheck(
tr( tr('msg.userfront.signup.password.rule.lowercase'),
'msg.userfront.signup.password.rule.lowercase',
),
hasLower, hasLower,
), ),
if (requiresNumber) if (requiresNumber)
@@ -1230,9 +1146,7 @@ class _SignupScreenState extends State<SignupScreen> {
), ),
if (requiresSymbol) if (requiresSymbol)
_cryptoCheck( _cryptoCheck(
tr( tr('msg.userfront.signup.password.rule.symbol'),
'msg.userfront.signup.password.rule.symbol',
),
hasSpecial, hasSpecial,
), ),
], ],
@@ -1244,16 +1158,12 @@ class _SignupScreenState extends State<SignupScreen> {
onChanged: (val) { onChanged: (val) {
setState(() { setState(() {
_confirmPasswordError = (val != _passwordController.text) _confirmPasswordError = (val != _passwordController.text)
? tr( ? tr('msg.userfront.signup.password.mismatch')
'msg.userfront.signup.password.mismatch',
)
: null; : null;
}); });
}, },
decoration: InputDecoration( decoration: InputDecoration(
labelText: tr( labelText: tr('ui.userfront.signup.password.confirm_label'),
'ui.userfront.signup.password.confirm_label',
),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
errorText: _confirmPasswordError, errorText: _confirmPasswordError,
), ),
@@ -1379,12 +1289,8 @@ class _SignupScreenState extends State<SignupScreen> {
) )
: Text( : Text(
_currentStep < 4 _currentStep < 4
? tr( ? tr('ui.userfront.signup.next_step')
'ui.userfront.signup.next_step', : tr('ui.userfront.signup.complete'),
)
: tr(
'ui.userfront.signup.complete',
),
), ),
), ),
), ),

View File

@@ -172,9 +172,7 @@ class AuthTimelineNotifier extends Notifier<AuthTimelineState> {
state = state.copyWith( state = state.copyWith(
isLoading: false, isLoading: false,
isLoadingMore: false, isLoadingMore: false,
error: tr( error: tr('msg.userfront.dashboard.timeline.load_error'),
'msg.userfront.dashboard.timeline.load_error',
),
); );
} }
} }

View File

@@ -71,9 +71,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: Text( title: Text(tr('ui.userfront.dashboard.revoke.title')),
tr('ui.userfront.dashboard.revoke.title'),
),
content: Text( content: Text(
tr( tr(
'msg.userfront.dashboard.revoke.confirm', 'msg.userfront.dashboard.revoke.confirm',
@@ -88,11 +86,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(true), onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: Colors.red), style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text( child: Text(tr('ui.userfront.dashboard.revoke.confirm_button')),
tr(
'ui.userfront.dashboard.revoke.confirm_button',
),
),
), ),
], ],
), ),
@@ -166,17 +160,13 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
tr( tr('ui.userfront.dashboard.scopes.title'),
'ui.userfront.dashboard.scopes.title',
),
style: const TextStyle(fontWeight: FontWeight.bold), style: const TextStyle(fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
if (item.scopes.isEmpty) if (item.scopes.isEmpty)
Text( Text(
tr( tr('msg.userfront.dashboard.scopes.empty'),
'msg.userfront.dashboard.scopes.empty',
),
style: const TextStyle(color: Colors.grey), style: const TextStyle(color: Colors.grey),
) )
else else
@@ -199,9 +189,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
tr( tr('ui.userfront.dashboard.status_history'),
'ui.userfront.dashboard.status_history',
),
style: const TextStyle(fontWeight: FontWeight.bold), style: const TextStyle(fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -219,9 +207,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
builder: (context) { builder: (context) {
final statusLabel = item.status == 'active' final statusLabel = item.status == 'active'
? tr('ui.common.status.active') ? tr('ui.common.status.active')
: tr( : tr('ui.userfront.dashboard.status.revoked');
'ui.userfront.dashboard.status.revoked',
);
return Text( return Text(
tr( tr(
'msg.userfront.dashboard.current_status', 'msg.userfront.dashboard.current_status',
@@ -534,12 +520,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
? log.detailMap['approved_session_id'].toString() ? log.detailMap['approved_session_id'].toString()
: log.sessionId; : log.sessionId;
final tooltipLabel = isOidc final tooltipLabel = isOidc
? tr( ? tr('ui.userfront.dashboard.approved_session.userfront')
'ui.userfront.dashboard.approved_session.userfront', : tr('ui.userfront.dashboard.approved_session.default');
)
: tr(
'ui.userfront.dashboard.approved_session.default',
);
final tooltip = approvedSessionId.isEmpty final tooltip = approvedSessionId.isEmpty
? tr( ? tr(
'msg.userfront.dashboard.approved_session.none', 'msg.userfront.dashboard.approved_session.none',
@@ -558,9 +540,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
tr( tr('msg.userfront.dashboard.session_id_copied'),
'msg.userfront.dashboard.session_id_copied',
),
), ),
), ),
); );
@@ -628,12 +608,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
? log.detailMap['approved_session_id'].toString() ? log.detailMap['approved_session_id'].toString()
: log.sessionId; : log.sessionId;
final tooltipLabel = isOidc final tooltipLabel = isOidc
? tr( ? tr('ui.userfront.dashboard.approved_session.userfront')
'ui.userfront.dashboard.approved_session.userfront', : tr('ui.userfront.dashboard.approved_session.default');
)
: tr(
'ui.userfront.dashboard.approved_session.default',
);
return InkWell( return InkWell(
onTap: approvedSessionId.isEmpty onTap: approvedSessionId.isEmpty
? null ? null
@@ -643,9 +619,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
tr( tr('msg.userfront.dashboard.session_id_copied'),
'msg.userfront.dashboard.session_id_copied',
),
), ),
), ),
); );
@@ -692,9 +666,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final label = _appLabelForLog(log); final label = _appLabelForLog(log);
final clientId = log.clientId; final clientId = log.clientId;
final tooltip = clientId.isEmpty final tooltip = clientId.isEmpty
? tr( ? tr('msg.userfront.dashboard.client_id_missing')
'msg.userfront.dashboard.client_id_missing',
)
: tr( : tr(
'msg.userfront.dashboard.client_id', 'msg.userfront.dashboard.client_id',
fallback: 'Client ID: {{id}}', fallback: 'Client ID: {{id}}',
@@ -814,21 +786,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
const SizedBox(height: 28), const SizedBox(height: 28),
], ],
_buildSectionTitle( _buildSectionTitle(
tr( tr('ui.userfront.sections.apps'),
'ui.userfront.sections.apps', tr('msg.userfront.sections.apps_subtitle'),
),
tr(
'msg.userfront.sections.apps_subtitle',
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildActivitySection(isMobile), _buildActivitySection(isMobile),
const SizedBox(height: 28), const SizedBox(height: 28),
_buildSectionTitle( _buildSectionTitle(
tr('ui.userfront.sections.audit'), tr('ui.userfront.sections.audit'),
tr( tr('msg.userfront.sections.audit_subtitle'),
'msg.userfront.sections.audit_subtitle',
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildAccessHistory(timelineState, timelineWide), _buildAccessHistory(timelineState, timelineWide),
@@ -857,10 +823,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
tr( tr('msg.userfront.greeting', params: {'name': userName}),
'msg.userfront.greeting',
params: {'name': userName},
),
style: const TextStyle( style: const TextStyle(
fontSize: 22, fontSize: 22,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -963,9 +926,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
tr( tr('msg.userfront.dashboard.activities.empty'),
'msg.userfront.dashboard.activities.empty',
),
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
color: Colors.grey[700], color: Colors.grey[700],
@@ -974,9 +935,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
tr( tr('msg.userfront.dashboard.activities.empty_detail'),
'msg.userfront.dashboard.activities.empty_detail',
),
style: TextStyle(fontSize: 12, color: Colors.grey[600]), style: TextStyle(fontSize: 12, color: Colors.grey[600]),
), ),
], ],
@@ -992,9 +951,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
tr( tr('msg.userfront.dashboard.activities.error'),
'msg.userfront.dashboard.activities.error',
),
style: TextStyle(fontSize: 12, color: Colors.grey[600]), style: TextStyle(fontSize: 12, color: Colors.grey[600]),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -1194,9 +1151,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
child: Text( child: Text(
item.status == 'active' item.status == 'active'
? tr('ui.common.status.active') ? tr('ui.common.status.active')
: tr( : tr('ui.userfront.dashboard.status.revoked'),
'ui.userfront.dashboard.status.revoked',
),
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
color: statusColor, color: statusColor,
@@ -1264,12 +1219,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
) )
: Text( : Text(
item.isRevoked item.isRevoked
? tr( ? tr('ui.userfront.dashboard.status.revoked')
'ui.userfront.dashboard.status.revoked', : tr('ui.userfront.dashboard.revoke.title'),
)
: tr(
'ui.userfront.dashboard.revoke.title',
),
style: const TextStyle(fontSize: 13), style: const TextStyle(fontSize: 13),
), ),
), ),
@@ -1303,22 +1254,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
} }
messenger.showSnackBar( messenger.showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(tr('msg.userfront.dashboard.link_open_error')),
tr(
'msg.userfront.dashboard.link_open_error',
),
),
), ),
); );
} else { } else {
if (!mounted) return; if (!mounted) return;
messenger.showSnackBar( messenger.showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(tr('msg.userfront.dashboard.link_missing')),
tr(
'msg.userfront.dashboard.link_missing',
),
),
), ),
); );
} }
@@ -1344,11 +1287,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(tr('msg.userfront.dashboard.audit_load_error')),
tr(
'msg.userfront.dashboard.audit_load_error',
),
),
const SizedBox(height: 8), const SizedBox(height: 8),
TextButton( TextButton(
onPressed: () => onPressed: () =>
@@ -1365,9 +1304,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
return _buildHistoryContainer( return _buildHistoryContainer(
child: Center( child: Center(
child: Text( child: Text(
tr( tr('msg.userfront.dashboard.audit_empty'),
'msg.userfront.dashboard.audit_empty',
),
style: TextStyle(color: Colors.grey[600]), style: TextStyle(color: Colors.grey[600]),
), ),
), ),
@@ -1416,16 +1353,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
), ),
), ),
DataColumn( DataColumn(
label: Text( label: Text(tr('ui.userfront.audit.table.date')),
tr('ui.userfront.audit.table.date'),
),
), ),
DataColumn( DataColumn(
label: Text( label: Text(tr('ui.userfront.audit.table.app')),
tr(
'ui.userfront.audit.table.app',
),
),
), ),
DataColumn( DataColumn(
label: Text( label: Text(
@@ -1433,30 +1364,16 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
), ),
), ),
DataColumn( DataColumn(
label: Text( label: Text(tr('ui.userfront.audit.table.device')),
tr(
'ui.userfront.audit.table.device',
),
),
), ),
DataColumn( DataColumn(
label: Text( label: Text(tr('ui.userfront.audit.table.auth_method')),
tr(
'ui.userfront.audit.table.auth_method',
),
),
), ),
DataColumn( DataColumn(
label: Text( label: Text(tr('ui.userfront.audit.table.result')),
tr(
'ui.userfront.audit.table.result',
),
),
), ),
DataColumn( DataColumn(
label: Text( label: Text(tr('ui.userfront.audit.table.status')),
tr('ui.userfront.audit.table.status'),
),
), ),
], ],
rows: state.items.map((log) { rows: state.items.map((log) {
@@ -1505,9 +1422,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
), ),
DataCell( DataCell(
_selectableText( _selectableText(
tr( tr('ui.userfront.audit.table.pending'),
'ui.userfront.audit.table.pending',
),
style: const TextStyle(color: Colors.grey), style: const TextStyle(color: Colors.grey),
), ),
), ),
@@ -1643,11 +1558,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text( Text(tr('msg.userfront.audit.load_more_error')),
tr(
'msg.userfront.audit.load_more_error',
),
),
TextButton( TextButton(
onPressed: () => onPressed: () =>
ref.read(authTimelineProvider.notifier).loadMore(), ref.read(authTimelineProvider.notifier).loadMore(),

View File

@@ -25,9 +25,7 @@ class ProfileRepository {
final token = await _getToken(); final token = await _getToken();
final useCookie = AuthTokenStore.usesCookie(); final useCookie = AuthTokenStore.usesCookie();
if (token == null && !useCookie) { if (token == null && !useCookie) {
throw Exception( throw Exception(tr('err.userfront.session.missing'));
tr('err.userfront.session.missing'),
);
} }
final url = Uri.parse('$_baseUrl/api/v1/user/me'); final url = Uri.parse('$_baseUrl/api/v1/user/me');
@@ -59,9 +57,7 @@ class ProfileRepository {
final token = await _getToken(); final token = await _getToken();
final useCookie = AuthTokenStore.usesCookie(); final useCookie = AuthTokenStore.usesCookie();
if (token == null && !useCookie) { if (token == null && !useCookie) {
throw Exception( throw Exception(tr('err.userfront.session.missing'));
tr('err.userfront.session.missing'),
);
} }
final url = Uri.parse('$_baseUrl/api/v1/user/me'); final url = Uri.parse('$_baseUrl/api/v1/user/me');
@@ -95,9 +91,7 @@ class ProfileRepository {
final token = await _getToken(); final token = await _getToken();
final useCookie = AuthTokenStore.usesCookie(); final useCookie = AuthTokenStore.usesCookie();
if (token == null && !useCookie) { if (token == null && !useCookie) {
throw Exception( throw Exception(tr('err.userfront.session.missing'));
tr('err.userfront.session.missing'),
);
} }
final url = Uri.parse('$_baseUrl/api/v1/user/me/send-code'); final url = Uri.parse('$_baseUrl/api/v1/user/me/send-code');
@@ -130,9 +124,7 @@ class ProfileRepository {
final token = await _getToken(); final token = await _getToken();
final useCookie = AuthTokenStore.usesCookie(); final useCookie = AuthTokenStore.usesCookie();
if (token == null && !useCookie) { if (token == null && !useCookie) {
throw Exception( throw Exception(tr('err.userfront.session.missing'));
tr('err.userfront.session.missing'),
);
} }
final url = Uri.parse('$_baseUrl/api/v1/user/me/password'); final url = Uri.parse('$_baseUrl/api/v1/user/me/password');
@@ -165,9 +157,7 @@ class ProfileRepository {
final token = await _getToken(); final token = await _getToken();
final useCookie = AuthTokenStore.usesCookie(); final useCookie = AuthTokenStore.usesCookie();
if (token == null && !useCookie) { if (token == null && !useCookie) {
throw Exception( throw Exception(tr('err.userfront.session.missing'));
tr('err.userfront.session.missing'),
);
} }
final url = Uri.parse('$_baseUrl/api/v1/user/me/verify-code'); final url = Uri.parse('$_baseUrl/api/v1/user/me/verify-code');

View File

@@ -232,13 +232,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
}); });
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(content: Text(tr('msg.userfront.profile.phone.code_sent'))),
content: Text(
tr(
'msg.userfront.profile.phone.code_sent',
),
),
),
); );
} }
} catch (e) { } catch (e) {
@@ -272,11 +266,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
}); });
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(content: Text(tr('msg.userfront.profile.phone.verified'))),
content: Text(
tr('msg.userfront.profile.phone.verified'),
),
),
); );
} }
if (_editingField == 'phone') { if (_editingField == 'phone') {
@@ -315,17 +305,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
} }
if (newPassword.isEmpty) { if (newPassword.isEmpty) {
setState( setState(
() => _passwordError = tr( () =>
'msg.userfront.profile.password.new_required', _passwordError = tr('msg.userfront.profile.password.new_required'),
),
); );
return; return;
} }
if (newPassword != confirmPassword) { if (newPassword != confirmPassword) {
setState( setState(
() => _passwordError = tr( () => _passwordError = tr('msg.userfront.profile.password.mismatch'),
'msg.userfront.profile.password.mismatch',
),
); );
return; return;
} }
@@ -347,9 +334,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_newPasswordController?.clear(); _newPasswordController?.clear();
_confirmPasswordController?.clear(); _confirmPasswordController?.clear();
setState(() { setState(() {
_passwordSuccess = tr( _passwordSuccess = tr('msg.userfront.profile.password.changed');
'msg.userfront.profile.password.changed',
);
}); });
} catch (e) { } catch (e) {
final message = e.toString().replaceFirst('Exception: ', ''); final message = e.toString().replaceFirst('Exception: ', '');
@@ -431,22 +416,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
if (_editingField == 'name' && nextName.isEmpty) { if (_editingField == 'name' && nextName.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(content: Text(tr('msg.userfront.profile.name_required'))),
content: Text(
tr('msg.userfront.profile.name_required'),
),
),
); );
return; return;
} }
if (_editingField == 'department' && nextDepartment.isEmpty) { if (_editingField == 'department' && nextDepartment.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(tr('msg.userfront.profile.department_required')),
tr(
'msg.userfront.profile.department_required',
),
),
), ),
); );
return; return;
@@ -454,24 +431,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
if (_editingField == 'phone') { if (_editingField == 'phone') {
if (nextPhone.isEmpty) { if (nextPhone.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(content: Text(tr('msg.userfront.profile.phone_required'))),
content: Text(
tr(
'msg.userfront.profile.phone_required',
),
),
),
); );
return; return;
} }
if (_isPhoneChanged && !_isPhoneVerified) { if (_isPhoneChanged && !_isPhoneVerified) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(tr('msg.userfront.profile.phone_verify_required')),
tr(
'msg.userfront.profile.phone_verify_required',
),
),
), ),
); );
return; return;
@@ -511,13 +478,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_departmentTouched = false; _departmentTouched = false;
}); });
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(content: Text(tr('msg.userfront.profile.update_success'))),
content: Text(
tr(
'msg.userfront.profile.update_success',
),
),
),
); );
} }
} catch (e) { } catch (e) {
@@ -657,10 +618,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
tr( tr('msg.userfront.profile.greeting', params: {'name': name}),
'msg.userfront.profile.greeting',
params: {'name': name},
),
style: const TextStyle( style: const TextStyle(
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
@@ -833,9 +791,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
child: Text( child: Text(
_isCodeSent _isCodeSent
? tr('ui.common.resend') ? tr('ui.common.resend')
: tr( : tr('ui.userfront.profile.phone.request_code'),
'ui.userfront.profile.phone.request_code',
),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -859,9 +815,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
onSubmitted: (_) => _verifyCode(profile), onSubmitted: (_) => _verifyCode(profile),
decoration: InputDecoration( decoration: InputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
hintText: tr( hintText: tr('ui.userfront.profile.phone.code_hint'),
'ui.userfront.profile.phone.code_hint',
),
), ),
), ),
), ),
@@ -877,9 +831,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
Padding( Padding(
padding: const EdgeInsets.only(top: 8.0), padding: const EdgeInsets.only(top: 8.0),
child: Text( child: Text(
tr( tr('msg.userfront.profile.phone.verify_notice'),
'msg.userfront.profile.phone.verify_notice',
),
style: const TextStyle(color: Colors.orange, fontSize: 12), style: const TextStyle(color: Colors.orange, fontSize: 12),
), ),
), ),
@@ -898,9 +850,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
tr( tr('msg.userfront.profile.password.subtitle'),
'msg.userfront.profile.password.subtitle',
),
style: const TextStyle(color: Color(0xFF6B7280)), style: const TextStyle(color: Color(0xFF6B7280)),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -908,9 +858,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
controller: _currentPasswordController, controller: _currentPasswordController,
obscureText: !_showCurrentPassword, obscureText: !_showCurrentPassword,
decoration: InputDecoration( decoration: InputDecoration(
labelText: tr( labelText: tr('ui.userfront.profile.password.current'),
'ui.userfront.profile.password.current',
),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
suffixIcon: IconButton( suffixIcon: IconButton(
icon: Icon( icon: Icon(
@@ -929,9 +877,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
controller: _newPasswordController, controller: _newPasswordController,
obscureText: !_showNewPassword, obscureText: !_showNewPassword,
decoration: InputDecoration( decoration: InputDecoration(
labelText: tr( labelText: tr('ui.userfront.profile.password.new'),
'ui.userfront.profile.password.new',
),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
suffixIcon: IconButton( suffixIcon: IconButton(
icon: Icon( icon: Icon(
@@ -948,9 +894,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
controller: _confirmPasswordController, controller: _confirmPasswordController,
obscureText: !_showConfirmPassword, obscureText: !_showConfirmPassword,
decoration: InputDecoration( decoration: InputDecoration(
labelText: tr( labelText: tr('ui.userfront.profile.password.confirm'),
'ui.userfront.profile.password.confirm',
),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
suffixIcon: IconButton( suffixIcon: IconButton(
icon: Icon( icon: Icon(
@@ -986,20 +930,12 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
height: 18, height: 18,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
: Text( : Text(tr('ui.userfront.profile.password.change')),
tr(
'ui.userfront.profile.password.change',
),
),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
TextButton( TextButton(
onPressed: () => context.go('/recovery'), onPressed: () => context.go('/recovery'),
child: Text( child: Text(tr('ui.userfront.profile.password.forgot')),
tr(
'ui.userfront.profile.password.forgot',
),
),
), ),
], ],
), ),
@@ -1024,9 +960,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
const SizedBox(height: 28), const SizedBox(height: 28),
_buildSectionTitle( _buildSectionTitle(
tr('ui.userfront.profile.section.basic'), tr('ui.userfront.profile.section.basic'),
tr( tr('msg.userfront.profile.section.basic'),
'msg.userfront.profile.section.basic',
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildCard( _buildCard(
@@ -1034,9 +968,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
children: [ children: [
_buildEditableTile( _buildEditableTile(
field: 'name', field: 'name',
label: tr( label: tr('ui.userfront.profile.field.name'),
'ui.userfront.profile.field.name',
),
value: profile.name, value: profile.name,
profile: profile, profile: profile,
isUpdating: isUpdating, isUpdating: isUpdating,
@@ -1044,9 +976,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
), ),
const Divider(height: 24), const Divider(height: 24),
_buildReadOnlyTile( _buildReadOnlyTile(
tr( tr('ui.userfront.profile.field.email'),
'ui.userfront.profile.field.email',
),
profile.email, profile.email,
), ),
const Divider(height: 24), const Divider(height: 24),
@@ -1056,12 +986,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
), ),
const SizedBox(height: 28), const SizedBox(height: 28),
_buildSectionTitle( _buildSectionTitle(
tr( tr('ui.userfront.profile.section.organization'),
'ui.userfront.profile.section.organization', tr('msg.userfront.profile.section.organization'),
),
tr(
'msg.userfront.profile.section.organization',
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildCard( _buildCard(
@@ -1069,9 +995,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
children: [ children: [
_buildEditableTile( _buildEditableTile(
field: 'department', field: 'department',
label: tr( label: tr('ui.userfront.profile.field.department'),
'ui.userfront.profile.field.department',
),
value: profile.department, value: profile.department,
profile: profile, profile: profile,
isUpdating: isUpdating, isUpdating: isUpdating,
@@ -1079,26 +1003,20 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
), ),
const Divider(height: 24), const Divider(height: 24),
_buildReadOnlyTile( _buildReadOnlyTile(
tr( tr('ui.userfront.profile.field.affiliation'),
'ui.userfront.profile.field.affiliation',
),
profile.affiliationType, profile.affiliationType,
), ),
if (profile.tenant != null) ...[ if (profile.tenant != null) ...[
const Divider(height: 24), const Divider(height: 24),
_buildReadOnlyTile( _buildReadOnlyTile(
tr( tr('ui.userfront.profile.field.tenant'),
'ui.userfront.profile.field.tenant',
),
profile.tenant!.name, profile.tenant!.name,
), ),
], ],
if (profile.companyCode.isNotEmpty) ...[ if (profile.companyCode.isNotEmpty) ...[
const Divider(height: 24), const Divider(height: 24),
_buildReadOnlyTile( _buildReadOnlyTile(
tr( tr('ui.userfront.profile.field.company_code'),
'ui.userfront.profile.field.company_code',
),
profile.companyCode, profile.companyCode,
), ),
], ],
@@ -1108,9 +1026,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
const SizedBox(height: 28), const SizedBox(height: 28),
_buildSectionTitle( _buildSectionTitle(
tr('ui.userfront.profile.section.security'), tr('ui.userfront.profile.section.security'),
tr( tr('msg.userfront.profile.section.security'),
'msg.userfront.profile.section.security',
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildPasswordSection(), _buildPasswordSection(),
@@ -1137,20 +1053,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
final profile = profileState.value ?? _cachedProfile; final profile = profileState.value ?? _cachedProfile;
if (profile == null) { if (profile == null) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: Text(tr('ui.userfront.nav.profile'))),
title: Text(tr('ui.userfront.nav.profile')),
),
body: profileState.isLoading body: profileState.isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: Center( : Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text( Text(tr('msg.userfront.profile.load_failed')),
tr(
'msg.userfront.profile.load_failed',
),
),
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton( ElevatedButton(
onPressed: () => onPressed: () =>

File diff suppressed because one or more lines are too long

View File

@@ -131,8 +131,10 @@ final _router = GoRouter(
GoRoute( GoRoute(
path: 'signin', path: 'signin',
builder: (context, state) { builder: (context, state) {
final loginChallenge = state.uri.queryParameters['login_challenge']; final loginChallenge =
final redirectUrl = state.uri.queryParameters['redirect_uri'] ?? state.uri.queryParameters['login_challenge'];
final redirectUrl =
state.uri.queryParameters['redirect_uri'] ??
state.uri.queryParameters['redirect_url']; state.uri.queryParameters['redirect_url'];
return LoginScreen( return LoginScreen(
key: state.pageKey, key: state.pageKey,
@@ -145,8 +147,10 @@ final _router = GoRouter(
path: 'login', path: 'login',
builder: (context, state) { builder: (context, state) {
// IMPORTANT: Match signin logic to handle OIDC challenges // IMPORTANT: Match signin logic to handle OIDC challenges
final loginChallenge = state.uri.queryParameters['login_challenge']; final loginChallenge =
final redirectUrl = state.uri.queryParameters['redirect_uri'] ?? state.uri.queryParameters['login_challenge'];
final redirectUrl =
state.uri.queryParameters['redirect_uri'] ??
state.uri.queryParameters['redirect_url']; state.uri.queryParameters['redirect_url'];
return LoginScreen( return LoginScreen(
key: state.pageKey, key: state.pageKey,
@@ -158,10 +162,13 @@ final _router = GoRouter(
GoRoute( GoRoute(
path: 'consent', path: 'consent',
builder: (BuildContext context, GoRouterState state) { builder: (BuildContext context, GoRouterState state) {
final consentChallenge = state.uri.queryParameters['consent_challenge']; final consentChallenge =
state.uri.queryParameters['consent_challenge'];
if (consentChallenge == null) { if (consentChallenge == null) {
return const Scaffold( 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); return ConsentScreen(consentChallenge: consentChallenge);
@@ -231,15 +238,13 @@ final _router = GoRouter(
), ),
GoRoute( GoRoute(
path: 'approve', path: 'approve',
builder: (context, state) => ApproveQrScreen( builder: (context, state) =>
pendingRef: state.uri.queryParameters['ref'], ApproveQrScreen(pendingRef: state.uri.queryParameters['ref']),
),
), ),
GoRoute( GoRoute(
path: 'ql/:ref', path: 'ql/:ref',
builder: (context, state) => ApproveQrScreen( builder: (context, state) =>
pendingRef: state.pathParameters['ref'], ApproveQrScreen(pendingRef: state.pathParameters['ref']),
),
), ),
GoRoute( GoRoute(
path: 'scan', path: 'scan',
@@ -265,7 +270,8 @@ final _router = GoRouter(
} }
final token = AuthTokenStore.getToken(); final token = AuthTokenStore.getToken();
final isLoggedIn = (token != null && token.isNotEmpty) || AuthTokenStore.usesCookie(); final isLoggedIn =
(token != null && token.isNotEmpty) || AuthTokenStore.usesCookie();
final path = stripLocalePath(uri); final path = stripLocalePath(uri);
// Precise public path detection // Precise public path detection