1
0
forked from baron/baron-sso

devfront UI 일관성 및 정렬 정책 적용

This commit is contained in:
2026-02-25 16:07:41 +09:00
parent 85538ae672
commit 3cfece2a33
8 changed files with 287 additions and 257 deletions

View File

@@ -197,7 +197,20 @@ function AppLayout() {
))} ))}
</div> </div>
</nav> </nav>
<div className="hidden space-y-2 px-5 pb-6 text-xs text-[var(--color-muted)] md:block"> </div>
<div>
<div className="px-3 pt-4 border-t border-border/50">
<button
type="button"
onClick={handleLogout}
className="w-full flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
>
<LogOut size={18} />
<span>{t("ui.dev.nav.logout", "Logout")}</span>
</button>
</div>
<div className="hidden space-y-2 px-5 pb-6 pt-2 text-xs text-[var(--color-muted)] md:block">
<p>{t("msg.dev.sidebar.notice", "개발자 전용 콘솔입니다.")}</p> <p>{t("msg.dev.sidebar.notice", "개발자 전용 콘솔입니다.")}</p>
<p> <p>
{t( {t(
@@ -207,17 +220,6 @@ function AppLayout() {
</p> </p>
</div> </div>
</div> </div>
<div className="px-2 pb-6 md:px-3">
<button
type="button"
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-muted/10 hover:text-foreground"
>
<LogOut size={18} />
<span>{t("ui.dev.nav.logout", "Logout")}</span>
</button>
</div>
</aside> </aside>
<div className="relative"> <div className="relative">

View File

@@ -1,6 +1,6 @@
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, Shield, Sparkles, Trash2, Upload } from "lucide-react"; import { Plus, Save, Shield, Sparkles, Trash2, Upload } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import { Badge } from "../../components/ui/badge"; import { Badge } from "../../components/ui/badge";
@@ -16,6 +16,7 @@ import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label"; import { Label } from "../../components/ui/label";
import { Switch } from "../../components/ui/switch"; import { Switch } from "../../components/ui/switch";
import { Textarea } from "../../components/ui/textarea"; import { Textarea } from "../../components/ui/textarea";
import { toast } from "../../components/ui/use-toast";
import { import {
createClient, createClient,
deleteClient, deleteClient,
@@ -128,6 +129,21 @@ function ClientGeneralPage() {
setScopes(scopes.filter((s) => s.id !== id)); setScopes(scopes.filter((s) => s.id !== id));
}; };
const handleStatusChange = (nextStatus: ClientStatus) => {
setStatus(nextStatus);
const statusLabel =
nextStatus === "active"
? t("ui.common.status.active", "Active")
: t("ui.common.status.inactive", "Inactive");
toast(
t(
"msg.dev.clients.general.status_changed",
"상태가 {{status}}로 변경되었습니다.",
{ status: statusLabel },
),
);
};
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const scopeNames = scopes.map((scope) => scope.name).filter(Boolean); const scopeNames = scopes.map((scope) => scope.name).filter(Boolean);
@@ -204,7 +220,7 @@ function ClientGeneralPage() {
window.confirm( window.confirm(
t( t(
"msg.dev.clients.delete_confirm", "msg.dev.clients.delete_confirm",
"정말로 이 앱을 삭제하시습니까? 이 작업은 되돌릴 수 없습니다.", "정말로 이 앱을 삭제하시습니까? 이 작업은 되돌릴 수 없습니다.",
), ),
) )
) { ) {
@@ -309,33 +325,6 @@ function ClientGeneralPage() {
)} )}
</CardDescription> </CardDescription>
</div> </div>
{!isCreate && (
<div className="flex flex-col items-end gap-2">
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("ui.dev.clients.table.status", "상태")}
</Label>
<div className="flex items-center gap-3">
<Switch
checked={status === "active"}
onCheckedChange={(checked) =>
setStatus(checked ? "active" : "inactive")
}
/>
<span
className={cn(
"text-sm font-medium",
status === "active"
? "text-emerald-400"
: "text-muted-foreground",
)}
>
{status === "active"
? t("ui.common.status.active", "활성")
: t("ui.common.status.inactive", "비활성")}
</span>
</div>
</div>
)}
</div> </div>
<div className="grid gap-8 md:grid-cols-2"> <div className="grid gap-8 md:grid-cols-2">
<div className="space-y-5"> <div className="space-y-5">
@@ -371,40 +360,66 @@ function ClientGeneralPage() {
/> />
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-5">
<Label className="text-sm font-semibold"> <div className="space-y-2">
{t("ui.dev.clients.general.identity.logo", "App Logo URL")} <Label className="text-sm font-semibold">
</Label> {t("ui.dev.clients.general.identity.logo", "App Logo URL")}
<div className="flex gap-4"> </Label>
<div className="flex-1 space-y-2"> <div className="flex gap-4">
<Input <div className="flex-1 space-y-2">
value={logoUrl} <Input
onChange={(e) => setLogoUrl(e.target.value)} value={logoUrl}
placeholder={t( onChange={(e) => setLogoUrl(e.target.value)}
"ui.dev.clients.general.identity.logo_placeholder", placeholder={t(
"https://example.com/logo.png", "ui.dev.clients.general.identity.logo_placeholder",
)} "https://example.com/logo.png",
/>
<p className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.identity.logo_help",
"인증 화면에 표시될 PNG/SVG URL입니다.",
)}
</p>
</div>
<div className="flex h-20 w-20 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/40 shrink-0">
{logoUrl ? (
<img
src={logoUrl}
alt={t(
"ui.dev.clients.general.identity.logo_preview",
"Logo Preview",
)} )}
className="h-full w-full object-contain"
/> />
) : ( <p className="text-xs text-muted-foreground">
<Upload className="h-5 w-5 text-muted-foreground" /> {t(
)} "msg.dev.clients.general.identity.logo_help",
"인증 화면에 표시될 PNG/SVG URL입니다.",
)}
</p>
</div>
<div className="flex h-20 w-20 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/40 shrink-0">
{logoUrl ? (
<img
src={logoUrl}
alt={t(
"ui.dev.clients.general.identity.logo_preview",
"Logo Preview",
)}
className="h-full w-full object-contain"
/>
) : (
<Upload className="h-5 w-5 text-muted-foreground" />
)}
</div>
</div>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.dev.clients.table.status", "상태")}
</Label>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant={status === "active" ? "default" : "outline"}
onClick={() => handleStatusChange("active")}
>
{t("ui.common.status.active", "활성")}
</Button>
<Button
type="button"
size="sm"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => handleStatusChange("inactive")}
>
{t("ui.common.status.inactive", "비활성")}
</Button>
</div> </div>
</div> </div>
</div> </div>
@@ -658,20 +673,30 @@ function ClientGeneralPage() {
</Button> </Button>
)} )}
</div> </div>
<div className="flex items-center gap-3"> <div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
<Button variant="outline" onClick={() => navigate("/clients")}> <Button variant="outline" onClick={() => navigate("/clients")}>
{t("ui.common.cancel", "취소")} {t("ui.common.cancel", "취소")}
</Button> </Button>
<Button <Button
onClick={() => mutation.mutate()} onClick={() => mutation.mutate()}
disabled={mutation.isPending} disabled={
className="px-8 shadow-lg shadow-primary/20" mutation.isPending ||
isLoading ||
name.trim() === "" ||
(isCreate && redirectUris.trim() === "")
}
className="shadow-lg shadow-primary/20"
> >
{mutation.isPending ? (
<div className="h-4 w-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
) : (
<Save size={16} className="mr-2" />
)}
{mutation.isPending {mutation.isPending
? t("msg.common.saving", "저장 중...") ? t("msg.common.saving", "저장 중...")
: isCreate : isCreate
? t("ui.dev.clients.general.create", "클라이언트 생성") ? t("ui.dev.clients.general.create", "클라이언트 생성")
: t("ui.dev.clients.general.save", "설정 저장")} : t("ui.common.save", "저장")}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -1,11 +1,30 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2, Edit, Globe, Save } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { import {
createIdpConfigForClient, createIdpConfigForClient,
listIdpConfigsForClient, listIdpConfigsForClient,
} from "../../../lib/devApi"; } from "../../../lib/devApi";
import type { IdpConfig, IdpConfigCreateRequest } from "../../../lib/devApi"; import type { IdpConfig, IdpConfigCreateRequest } from "../../../lib/devApi";
import { t } from "../../../lib/i18n";
// Proper Modal Component with Form // Proper Modal Component with Form
const CreateIdpModal = ({ const CreateIdpModal = ({
@@ -37,12 +56,10 @@ const CreateIdpModal = ({
onClose(); onClose();
}, },
onError: (error) => { onError: (error) => {
// Basic error handling
alert(`Failed to create configuration: ${error.message}`); alert(`Failed to create configuration: ${error.message}`);
}, },
}); });
// 이 내용으로 교체해주세요
const handleChange = ( const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>, e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => { ) => {
@@ -61,104 +78,89 @@ const CreateIdpModal = ({
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 backdrop-blur-sm">
<div className="bg-white p-6 rounded-lg shadow-xl w-full max-w-lg"> <Card className="w-full max-w-lg shadow-2xl animate-in zoom-in-95 duration-200">
<h2 className="text-xl font-bold mb-4">Add New IdP Configuration</h2> <CardHeader>
<form onSubmit={handleSubmit}> <CardTitle>{t("ui.dev.clients.federation.add_title", "Add Identity Provider")}</CardTitle>
{/* Display Name */} <CardDescription>
<div className="mb-4"> {t("msg.dev.clients.federation.add_subtitle", "Connect an external OIDC provider.")}
<label className="block text-gray-700 text-sm font-bold mb-2"> </CardDescription>
Display Name </CardHeader>
</label> <CardContent>
<input <form onSubmit={handleSubmit} className="space-y-4">
type="text" <div className="space-y-2">
name="display_name" <label className="text-sm font-semibold">Display Name</label>
value={formData.display_name} <Input
onChange={handleChange} name="display_name"
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" value={formData.display_name}
required onChange={handleChange}
/> placeholder="e.g. Google Workspace"
</div> required
/>
</div>
{/* Issuer URL */} <div className="space-y-2">
<div className="mb-4"> <label className="text-sm font-semibold">Issuer URL</label>
<label className="block text-gray-700 text-sm font-bold mb-2"> <Input
Issuer URL type="url"
</label> name="issuer_url"
<input value={formData.issuer_url}
type="url" onChange={handleChange}
name="issuer_url" placeholder="https://accounts.google.com"
value={formData.issuer_url} required
onChange={handleChange} />
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" </div>
placeholder="https://accounts.google.com"
required
/>
</div>
{/* Client ID */} <div className="grid grid-cols-2 gap-4">
<div className="mb-4"> <div className="space-y-2">
<label className="block text-gray-700 text-sm font-bold mb-2"> <label className="text-sm font-semibold">Client ID</label>
Client ID <Input
</label> name="oidc_client_id"
<input value={formData.oidc_client_id}
type="text" onChange={handleChange}
name="oidc_client_id" required
value={formData.oidc_client_id} />
onChange={handleChange} </div>
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" <div className="space-y-2">
required <label className="text-sm font-semibold">Client Secret</label>
/> <Input
</div> type="password"
name="oidc_client_secret"
value={formData.oidc_client_secret}
onChange={handleChange}
required
/>
</div>
</div>
{/* Client Secret */} <div className="space-y-2">
<div className="mb-4"> <label className="text-sm font-semibold">Scopes</label>
<label className="block text-gray-700 text-sm font-bold mb-2"> <Input
Client Secret name="scopes"
</label> value={formData.scopes}
<input onChange={handleChange}
type="password" />
name="oidc_client_secret" </div>
value={formData.oidc_client_secret}
onChange={handleChange}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
required
/>
</div>
{/* Scopes */} <div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 pt-4">
<div className="mb-4"> <Button type="button" variant="outline" onClick={onClose}>
<label className="block text-gray-700 text-sm font-bold mb-2"> {t("ui.common.cancel", "Cancel")}
Scopes </Button>
</label> <Button
<input type="submit"
type="text" disabled={mutation.isPending || formData.display_name.trim() === "" || formData.issuer_url.trim() === ""}
name="scopes" >
value={formData.scopes} {mutation.isPending ? (
onChange={handleChange} <div className="h-4 w-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" ) : (
/> <Save size={16} className="mr-2" />
</div> )}
{mutation.isPending ? t("msg.common.saving", "Saving...") : t("ui.common.save", "Save Configuration")}
{/* Action Buttons */} </Button>
<div className="flex items-center justify-end"> </div>
<button </form>
type="button" </CardContent>
onClick={onClose} </Card>
className="bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded mr-2"
>
Cancel
</button>
<button
type="submit"
disabled={mutation.isPending}
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded disabled:opacity-50"
>
{mutation.isPending ? "Saving..." : "Save Configuration"}
</button>
</div>
</form>
</div>
</div> </div>
); );
}; };
@@ -168,7 +170,7 @@ export function ClientFederationPage() {
const [isCreateModalOpen, setCreateModalOpen] = useState(false); const [isCreateModalOpen, setCreateModalOpen] = useState(false);
if (!clientId) { if (!clientId) {
return <div>Client ID is missing</div>; return <div className="p-8 text-center text-destructive">Client ID is missing</div>;
} }
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
@@ -177,94 +179,92 @@ export function ClientFederationPage() {
}); });
return ( return (
<div className="p-4"> <div className="space-y-6 p-1">
<h1 className="text-2xl font-bold mb-4">Identity Federation Settings</h1> <header className="flex flex-wrap items-center justify-between gap-4">
<p className="mb-4 text-gray-600"> <div>
Manage external identity providers for this application. <h1 className="text-2xl font-bold flex items-center gap-2">
</p> <Globe className="h-6 w-6 text-primary" />
{t("ui.dev.clients.federation.title", "Identity Federation")}
</h1>
<p className="text-muted-foreground">
{t("msg.dev.clients.federation.subtitle", "Manage external identity providers for this application.")}
</p>
</div>
<Button onClick={() => setCreateModalOpen(true)} className="gap-2">
<Plus className="h-4 w-4" />
{t("ui.dev.clients.federation.add_btn", "Add Provider")}
</Button>
</header>
<div className="mb-4"> <Card className="glass-panel">
<button <CardContent className="p-0">
type="button" <Table>
onClick={() => setCreateModalOpen(true)} <TableHeader>
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" <TableRow>
> <TableHead>Display Name</TableHead>
+ Add IdP Configuration <TableHead>Provider Type</TableHead>
</button> <TableHead>Status</TableHead>
</div> <TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center">
{t("msg.common.loading", "Loading...")}
</TableCell>
</TableRow>
) : error ? (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center text-destructive">
{(error as Error).message}
</TableCell>
</TableRow>
) : data?.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center text-muted-foreground">
{t("msg.dev.clients.federation.empty", "No IdP configurations found.")}
</TableCell>
</TableRow>
) : (
data?.map((config: IdpConfig) => (
<tr key={config.id} className="border-b transition-colors hover:bg-muted/50">
<TableCell className="font-medium">{config.display_name}</TableCell>
<TableCell>{config.provider_type.toUpperCase()}</TableCell>
<TableCell>
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${
config.status === "active"
? "bg-emerald-500/10 text-emerald-500"
: "bg-muted text-muted-foreground"
}`}
>
{config.status}
</span>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button variant="ghost" size="icon" className="h-8 w-8">
<Edit className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive">
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</tr>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
<CreateIdpModal <CreateIdpModal
isOpen={isCreateModalOpen} isOpen={isCreateModalOpen}
onClose={() => setCreateModalOpen(false)} onClose={() => setCreateModalOpen(false)}
clientId={clientId} clientId={clientId}
/> />
{isLoading && <div>Loading configurations...</div>}
{error && (
<div className="text-red-500">
Failed to load configurations: {error.message}
</div>
)}
{data && (
<div className="overflow-x-auto">
<table className="min-w-full bg-white">
<thead className="bg-gray-200">
<tr>
<th className="py-2 px-4 border-b">Display Name</th>
<th className="py-2 px-4 border-b">Provider Type</th>
<th className="py-2 px-4 border-b">Status</th>
<th className="py-2 px-4 border-b">Actions</th>
</tr>
</thead>
<tbody>
{data.length === 0 ? (
<tr>
<td colSpan={4} className="text-center py-4">
No IdP configurations found.
</td>
</tr>
) : (
data.map((config: IdpConfig) => (
<tr key={config.id}>
<td className="py-2 px-4 border-b">
{config.display_name}
</td>
<td className="py-2 px-4 border-b">
{config.provider_type.toUpperCase()}
</td>
<td className="py-2 px-4 border-b">
<span
className={`px-2 py-1 text-xs font-semibold rounded-full ${
config.status === "active"
? "bg-green-200 text-green-800"
: "bg-gray-200 text-gray-800"
}`}
>
{config.status}
</span>
</td>
<td className="py-2 px-4 border-b">
<button
type="button"
className="text-blue-500 hover:underline mr-2"
>
Edit
</button>
<button
type="button"
className="text-red-500 hover:underline"
>
Delete
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
</div> </div>
); );
} }

View File

@@ -252,6 +252,7 @@ load_error = "Error loading client: {{error}}"
loading = "Loading client..." loading = "Loading client..."
saved = "Saved" saved = "Saved"
save_error = "Failed to save: {{error}}" save_error = "Failed to save: {{error}}"
status_changed = "Status changed to {{status}}."
[msg.dev.clients.general.identity] [msg.dev.clients.general.identity]
logo_help = "Logo Help" logo_help = "Logo Help"

View File

@@ -252,6 +252,7 @@ load_error = "Error loading client: {{error}}"
loading = "Loading client..." loading = "Loading client..."
saved = "설정이 저장되었습니다." saved = "설정이 저장되었습니다."
save_error = "저장 실패: {{error}}" save_error = "저장 실패: {{error}}"
status_changed = "상태가 {{status}}로 변경되었습니다."
[msg.dev.clients.general.identity] [msg.dev.clients.general.identity]
logo_help = "인증 화면에 표시될 PNG/SVG URL입니다." logo_help = "인증 화면에 표시될 PNG/SVG URL입니다."

View File

@@ -252,6 +252,7 @@ load_error = ""
loading = "" loading = ""
saved = "" saved = ""
save_error = "" save_error = ""
status_changed = ""
[msg.dev.clients.general.identity] [msg.dev.clients.general.identity]
logo_help = "" logo_help = ""

View File

@@ -1168,7 +1168,7 @@ settings = "Settings"
[ui.dev.clients.general] [ui.dev.clients.general]
create = "Create Application" create = "Create Application"
display_new = "Add Connected Application" display_new = "Add Connected Application"
save = "Settings Save" save = "Save"
title_create = "Create Client" title_create = "Create Client"
title_edit = "Client Settings" title_edit = "Client Settings"

View File

@@ -1168,7 +1168,7 @@ settings = "Settings"
[ui.dev.clients.general] [ui.dev.clients.general]
create = "앱 생성" create = "앱 생성"
display_new = "연동 앱 추가" display_new = "연동 앱 추가"
save = "설정 저장" save = "저장"
title_create = "Create Client" title_create = "Create Client"
title_edit = "Client Settings" title_edit = "Client Settings"