forked from baron/baron-sso
Merge branch 'dev' into feature/i18n
This commit is contained in:
@@ -88,8 +88,12 @@ HYDRA_VERSION=v25.4.0-distroless
|
||||
|
||||
# Ory Keto Configuration
|
||||
KETO_VERSION=v25.4.0-distroless
|
||||
KETO_READ_URL=http://keto:4466
|
||||
KETO_WRITE_URL=http://keto:4467
|
||||
# KETO_READ_PORT=4466 # Internal only
|
||||
# KETO_WRITE_PORT=4467 # Internal only
|
||||
KETO_READ_URL=http://keto:4466
|
||||
KETO_WRITE_URL=http://keto:4467
|
||||
|
||||
# Kratos Selfservice UI upstreams (override for deployments)
|
||||
ORY_SDK_URL=http://kratos:4433
|
||||
|
||||
179
.gitea/workflows/staging_code_pull.yml
Normal file
179
.gitea/workflows/staging_code_pull.yml
Normal file
@@ -0,0 +1,179 @@
|
||||
name: Release Baron SSO to Staging
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target_branch:
|
||||
description: "Branch to deploy"
|
||||
required: true
|
||||
default: "dev"
|
||||
|
||||
jobs:
|
||||
deploy-staging:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup SSH
|
||||
uses: webfactory/ssh-agent@v0.9.0
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.STAGE_SSH_PRIVATE_KEY }}
|
||||
|
||||
- name: Deploy to Staging by git pull
|
||||
env:
|
||||
DEPLOY_PATH: ${{ vars.STAGE_DEPLOY_PATH }}
|
||||
STAGE_HOST: ${{ vars.STAGE_HOST }}
|
||||
STAGE_USER: ${{ vars.STAGE_USER }}
|
||||
TARGET_BRANCH: ${{ inputs.target_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
echo "DEBUG: STAGE_USER='${STAGE_USER}'"
|
||||
echo "DEBUG: STAGE_HOST='${STAGE_HOST}'"
|
||||
echo "DEBUG: DEPLOY_PATH='${DEPLOY_PATH}'"
|
||||
echo "DEBUG: TARGET_BRANCH='${TARGET_BRANCH}'"
|
||||
|
||||
# Sanity check
|
||||
if [ -z "${STAGE_USER}" ] || [ -z "${STAGE_HOST}" ] || [ -z "${DEPLOY_PATH}" ] || [ -z "${TARGET_BRANCH}" ]; then
|
||||
echo "::error::Missing required vars (STAGE_USER/STAGE_HOST/DEPLOY_PATH/TARGET_BRANCH)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ssh-keyscan -H "${STAGE_HOST}" >> ~/.ssh/known_hosts
|
||||
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p '${DEPLOY_PATH}'"
|
||||
|
||||
# .env 파일 생성
|
||||
cat <<'EOF' > .env
|
||||
APP_ENV=${{ vars.APP_ENV }}
|
||||
TZ=Asia/Seoul
|
||||
IDP_PROVIDER=ory
|
||||
|
||||
# DB & Clickhouse
|
||||
DB_PORT=${{ vars.DB_PORT }}
|
||||
CLICKHOUSE_PORT_HTTP=${{ vars.CLICKHOUSE_PORT_HTTP }}
|
||||
CLICKHOUSE_PORT_NATIVE=${{ vars.CLICKHOUSE_PORT_NATIVE }}
|
||||
CLICKHOUSE_HOST=${{ vars.CLICKHOUSE_HOST }}
|
||||
CLICKHOUSE_USER=${{ vars.CLICKHOUSE_USER }}
|
||||
CLICKHOUSE_PASSWORD=${{ secrets.CLICKHOUSE_PASSWORD }}
|
||||
|
||||
|
||||
BACKEND_PORT=${{ vars.BACKEND_PORT }}
|
||||
ADMINFRONT_PORT=${{ vars.ADMINFRONT_PORT }}
|
||||
DEVFRONT_PORT=${{ vars.DEVFRONT_PORT }}
|
||||
USERFRONT_PORT=${{ vars.USERFRONT_PORT }}
|
||||
|
||||
OATHKEEPER_API_URL=${{ vars.OATHKEEPER_API_URL }}
|
||||
|
||||
DB_USER=${{ vars.DB_USER }}
|
||||
DB_PASSWORD=${{ secrets.STG_DB_PASSWORD }}
|
||||
DB_NAME=${{ vars.DB_NAME }}
|
||||
COOKIE_SECRET=${{ secrets.STG_COOKIE_SECRET }}
|
||||
JWT_SECRET=${{ secrets.STG_JWT_SECRET }}
|
||||
REDIS_ADDR=${{ vars.REDIS_ADDR }}
|
||||
CORS_ALLOWED_ORIGINS=${{ vars.CORS_ALLOWED_ORIGINS }}
|
||||
AUDIT_WORKER_COUNT=5
|
||||
AUDIT_QUEUE_SIZE=2000
|
||||
PROFILE_CACHE_TTL=${{ vars.PROFILE_CACHE_TTL }}
|
||||
DESCOPE_TEST_ACCOUNT=${{ vars.DESCOPE_TEST_ACCOUNT }}
|
||||
NAVER_CLOUD_ACCESS_KEY=${{ vars.NAVER_CLOUD_ACCESS_KEY }}
|
||||
NAVER_CLOUD_SECRET_KEY=${{ secrets.NAVER_CLOUD_SECRET_KEY }}
|
||||
NAVER_CLOUD_SERVICE_ID=${{ vars.NAVER_CLOUD_SERVICE_ID }}
|
||||
NAVER_SENDER_PHONE_NUMBER=${{ vars.NAVER_SENDER_PHONE_NUMBER }}
|
||||
AWS_REGION=${{ vars.AWS_REGION }}
|
||||
AWS_ACCESS_KEY_ID=${{ vars.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_SES_SENDER=${{ vars.AWS_SES_SENDER }}
|
||||
ADMIN_EMAIL=${{ vars.ADMIN_EMAIL }}
|
||||
ADMIN_PASSWORD=${{ secrets.STG_ADMIN_PASSWORD }}
|
||||
USERFRONT_URL=${{ vars.USERFRONT_URL }}
|
||||
BACKEND_URL=${{ vars.BACKEND_URL }}
|
||||
OATHKEEPER_PUBLIC_URL=${{ vars.OATHKEEPER_PUBLIC_URL }}
|
||||
ORY_POSTGRES_TAG=${{ vars.ORY_POSTGRES_TAG }}
|
||||
ORY_POSTGRES_USER=${{ vars.ORY_POSTGRES_USER }}
|
||||
ORY_POSTGRES_PASSWORD=${{ secrets.STG_ORY_POSTGRES_PASSWORD }}
|
||||
ORY_POSTGRES_DB=${{ vars.ORY_POSTGRES_DB }}
|
||||
KRATOS_DB=${{ vars.KRATOS_DB }}
|
||||
HYDRA_DB=${{ vars.HYDRA_DB }}
|
||||
KETO_DB=${{ vars.KETO_DB }}
|
||||
KRATOS_VERSION=${{ vars.KRATOS_VERSION }}
|
||||
KRATOS_UI_NODE_VERSION=${{ vars.KRATOS_UI_NODE_VERSION }}
|
||||
HYDRA_VERSION=${{ vars.HYDRA_VERSION }}
|
||||
KETO_VERSION=${{ vars.KETO_VERSION }}
|
||||
ORY_SDK_URL=${{ vars.ORY_SDK_URL }}
|
||||
KRATOS_PUBLIC_URL=${{ vars.KRATOS_PUBLIC_URL }}
|
||||
KRATOS_ADMIN_URL=${{ vars.KRATOS_ADMIN_URL }}
|
||||
KRATOS_BROWSER_URL=${{ vars.KRATOS_BROWSER_URL }}
|
||||
KRATOS_UI_URL=${{ vars.KRATOS_UI_URL }}
|
||||
HYDRA_ADMIN_URL=${{ vars.HYDRA_ADMIN_URL }}
|
||||
HYDRA_PUBLIC_URL=${{ vars.HYDRA_PUBLIC_URL }}
|
||||
JWKS_URL=${{ vars.JWKS_URL }}
|
||||
OATHKEEPER_VERSION=${{ vars.OATHKEEPER_VERSION }}
|
||||
OATHKEEPER_UID=${{ vars.OATHKEEPER_UID }}
|
||||
OATHKEEPER_GID=${{ vars.OATHKEEPER_GID }}
|
||||
OATHKEEPER_HEALTH_URL=${{ vars.OATHKEEPER_HEALTH_URL }}
|
||||
OATHKEEPER_HEALTH_INTERVAL_SECONDS=${{ vars.OATHKEEPER_HEALTH_INTERVAL_SECONDS }}
|
||||
OATHKEEPER_HEALTH_TIMEOUT_SECONDS=${{ vars.OATHKEEPER_HEALTH_TIMEOUT_SECONDS }}
|
||||
OATHKEEPER_HEALTH_ENABLED=${{ vars.OATHKEEPER_HEALTH_ENABLED }}
|
||||
CSRF_COOKIE_NAME=${{ vars.CSRF_COOKIE_NAME }}
|
||||
CSRF_COOKIE_SECRET=${{ secrets.STG_CSRF_COOKIE_SECRET }}
|
||||
# OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }}
|
||||
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
|
||||
EOF
|
||||
|
||||
# 코드 업데이트 (Git)
|
||||
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p '${DEPLOY_PATH}' && cd '${DEPLOY_PATH}' && \
|
||||
if [ ! -d .git ]; then
|
||||
git init
|
||||
git remote add origin ssh://git@172.16.10.175:222/baron/baron-sso.git
|
||||
else
|
||||
git remote set-url origin ssh://git@172.16.10.175:222/baron/baron-sso.git
|
||||
fi
|
||||
git fetch --depth 1 origin '${TARGET_BRANCH}' && \
|
||||
git reset --hard FETCH_HEAD && \
|
||||
git clean -fd && \
|
||||
git checkout -B '${TARGET_BRANCH}' FETCH_HEAD"
|
||||
|
||||
# .env 파일 복사
|
||||
scp .env "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/"
|
||||
|
||||
# 배포 실행
|
||||
ssh "${STAGE_USER}@${STAGE_HOST}" "DEPLOY_PATH='${DEPLOY_PATH}' bash -s" <<'EOSSH'
|
||||
set -euo pipefail
|
||||
cd "${DEPLOY_PATH}"
|
||||
set -a; . ./.env; set +a;
|
||||
|
||||
# 네트워크 생성
|
||||
for net in baron_net public_net ory-net hydranet kratosnet; do
|
||||
docker network inspect "${net}" >/dev/null 2>&1 || docker network create "${net}"
|
||||
done
|
||||
|
||||
|
||||
# [중요] 설정 파일 권한 문제 해결 (Ory 이미지는 root가 아닌 사용자로 실행됨)
|
||||
chmod -R 777 docker/ory || true
|
||||
|
||||
cp docker/staging_pull_compose.template.yaml staging_pull_compose.yaml
|
||||
|
||||
docker compose -f staging_pull_compose.yaml pull
|
||||
|
||||
# [주의] DB 초기화 스크립트는 '새로운 볼륨'에서만 실행됨.
|
||||
docker compose -f staging_pull_compose.yaml down || true
|
||||
|
||||
# 코드 변경 반영을 위해 build 수행 (userfront nginx.conf 등)
|
||||
docker compose -f staging_pull_compose.yaml build --pull
|
||||
|
||||
docker compose -f staging_pull_compose.yaml up -d --remove-orphans
|
||||
|
||||
# 배포 후 상태 확인 (실패 시 로그 출력을 위함)
|
||||
sleep 10
|
||||
kratos_migrate_cid="$(docker compose -f staging_pull_compose.yaml ps -q kratos-migrate || true)"
|
||||
if [ -n "${kratos_migrate_cid}" ]; then
|
||||
if [ "$(docker inspect -f '{{.State.ExitCode}}' "${kratos_migrate_cid}")" -ne 0 ]; then
|
||||
echo 'Kratos Migrate Failed. Logs:'
|
||||
docker logs "${kratos_migrate_cid}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "WARN: kratos-migrate container not found; skipping exit-code check."
|
||||
fi
|
||||
EOSSH
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -14,6 +14,7 @@
|
||||
# Docker Services Data (Volumes)
|
||||
postgres_data/
|
||||
clickhouse_data/
|
||||
docker/ory/oathkeeper/logs/
|
||||
|
||||
# Backend (Go)
|
||||
backend/main
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -3,9 +3,18 @@ import AppLayout from "../components/layout/AppLayout";
|
||||
import ApiKeyCreatePage from "../features/api-keys/ApiKeyCreatePage";
|
||||
import ApiKeyListPage from "../features/api-keys/ApiKeyListPage";
|
||||
import AuditLogsPage from "../features/audit/AuditLogsPage";
|
||||
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
|
||||
import AuthPage from "../features/auth/AuthPage";
|
||||
import LoginPage from "../features/auth/LoginPage";
|
||||
import DashboardPage from "../features/dashboard/DashboardPage";
|
||||
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
|
||||
import TenantGroupAdminsTab from "../features/tenant-groups/routes/TenantGroupAdminsTab";
|
||||
import TenantGroupCreatePage from "../features/tenant-groups/routes/TenantGroupCreatePage";
|
||||
import TenantGroupDetailPage from "../features/tenant-groups/routes/TenantGroupDetailPage";
|
||||
import TenantGroupListPage from "../features/tenant-groups/routes/TenantGroupListPage";
|
||||
import TenantGroupProfileTab from "../features/tenant-groups/routes/TenantGroupProfileTab";
|
||||
import TenantGroupTenantsTab from "../features/tenant-groups/routes/TenantGroupTenantsTab";
|
||||
import TenantAdminsTab from "../features/tenants/routes/TenantAdminsTab";
|
||||
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
|
||||
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
|
||||
import TenantListPage from "../features/tenants/routes/TenantListPage";
|
||||
@@ -17,6 +26,14 @@ import UserListPage from "../features/users/UserListPage";
|
||||
|
||||
export const router = createBrowserRouter(
|
||||
[
|
||||
{
|
||||
path: "/login",
|
||||
element: <LoginPage />,
|
||||
},
|
||||
{
|
||||
path: "/auth/callback",
|
||||
element: <AuthCallbackPage />,
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
element: <AppLayout />,
|
||||
@@ -30,11 +47,23 @@ export const router = createBrowserRouter(
|
||||
{ path: "users/:id", element: <UserDetailPage /> },
|
||||
{ path: "tenants", element: <TenantListPage /> },
|
||||
{ path: "tenants/new", element: <TenantCreatePage /> },
|
||||
{ path: "tenant-groups", element: <TenantGroupListPage /> },
|
||||
{ path: "tenant-groups/new", element: <TenantGroupCreatePage /> },
|
||||
{
|
||||
path: "tenant-groups/:id",
|
||||
element: <TenantGroupDetailPage />,
|
||||
children: [
|
||||
{ index: true, element: <TenantGroupProfileTab /> },
|
||||
{ path: "tenants", element: <TenantGroupTenantsTab /> },
|
||||
{ path: "admins", element: <TenantGroupAdminsTab /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "tenants/:tenantId",
|
||||
element: <TenantDetailPage />,
|
||||
children: [
|
||||
{ index: true, element: <TenantProfilePage /> },
|
||||
{ path: "admins", element: <TenantAdminsTab /> },
|
||||
{ path: "schema", element: <TenantSchemaPage /> },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -29,7 +29,6 @@ const navItems = [
|
||||
{ label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs },
|
||||
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound },
|
||||
];
|
||||
|
||||
function AppLayout() {
|
||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||
const stored = window.localStorage.getItem("admin_theme");
|
||||
|
||||
@@ -26,7 +26,8 @@ const badgeVariants = cva(
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
extends
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
|
||||
@@ -34,7 +34,8 @@ const buttonVariants = cva(
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
extends
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
|
||||
43
adminfront/src/features/auth/AuthCallbackPage.tsx
Normal file
43
adminfront/src/features/auth/AuthCallbackPage.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { ShieldHalf } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
|
||||
function AuthCallbackPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
const token = searchParams.get("token");
|
||||
if (token) {
|
||||
window.localStorage.setItem("admin_session", token);
|
||||
|
||||
// 만약 팝업창에서 실행 중이라면 부모 창에 알리고 닫기
|
||||
if (window.opener) {
|
||||
window.opener.postMessage({ type: "LOGIN_SUCCESS", token }, "*");
|
||||
window.close();
|
||||
} else {
|
||||
// 일반 리다이렉트 방식인 경우 홈으로 이동
|
||||
navigate("/", { replace: true });
|
||||
}
|
||||
} else {
|
||||
console.error("No token found in callback URL");
|
||||
navigate("/login", { replace: true });
|
||||
}
|
||||
}, [navigate, searchParams]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/15 text-primary shadow-lg animate-pulse">
|
||||
<ShieldHalf size={32} />
|
||||
</div>
|
||||
<div className="text-lg font-semibold">인증 완료 중...</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
세션을 동기화하고 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuthCallbackPage;
|
||||
132
adminfront/src/features/auth/LoginPage.tsx
Normal file
132
adminfront/src/features/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
|
||||
function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for login success message from the popup
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
// Security check: In production, verify event.origin
|
||||
if (event.data?.type === "LOGIN_SUCCESS" && event.data?.token) {
|
||||
window.localStorage.setItem("admin_session", event.data.token);
|
||||
setIsLoggingIn(false);
|
||||
navigate("/");
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => window.removeEventListener("message", handleMessage);
|
||||
}, [navigate]);
|
||||
|
||||
const handleSSOLogin = () => {
|
||||
const userfrontUrl = import.meta.env.USERFRONT_URL || "https://sso.hmac.kr";
|
||||
const callbackUrl = `${window.location.origin}/auth/callback`;
|
||||
|
||||
// 항상 redirect_uri를 포함하여 로그인이 성공하면 콜백 페이지로 오도록 함
|
||||
const loginUrl = `${userfrontUrl}/signin?source=adminfront&redirect_uri=${encodeURIComponent(callbackUrl)}`;
|
||||
|
||||
const width = 500;
|
||||
const height = 700;
|
||||
const left = window.screen.width / 2 - width / 2;
|
||||
const top = window.screen.height / 2 - height / 2;
|
||||
|
||||
const popup = window.open(
|
||||
loginUrl,
|
||||
"BaronSSOLogin",
|
||||
`width=${width},height=${height},top=${top},left=${left},status=no,menubar=no,toolbar=no`,
|
||||
);
|
||||
|
||||
if (popup) {
|
||||
setIsLoggingIn(true);
|
||||
const timer = setInterval(() => {
|
||||
if (popup.closed) {
|
||||
clearInterval(timer);
|
||||
setIsLoggingIn(false);
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
alert("팝업 차단이 설정되어 있습니다. 팝업 허용 후 다시 시도해 주세요.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-12 sm:px-6 lg:px-8 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-primary/10 via-background to-background">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="flex flex-col items-center justify-center space-y-4 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/15 text-primary shadow-[0_20px_50px_rgba(54,211,153,0.3)]">
|
||||
<ShieldHalf size={32} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Baron SSO</h1>
|
||||
<p className="text-sm text-muted-foreground uppercase tracking-[0.2em]">
|
||||
Admin Control Plane
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-primary/20 bg-card/50 backdrop-blur-xl shadow-2xl">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl flex items-center gap-2">
|
||||
<LogIn size={20} className="text-primary" />
|
||||
관리자 로그인
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Baron 통합 인증(SSO)을 통해 관리자 페이지에 접속합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4 pb-8 space-y-3">
|
||||
<Button
|
||||
onClick={handleSSOLogin}
|
||||
className="w-full h-14 text-lg font-semibold flex gap-3 shadow-lg"
|
||||
disabled={isLoggingIn}
|
||||
>
|
||||
{isLoggingIn ? (
|
||||
<>
|
||||
<div className="h-5 w-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
로그인 진행 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldHalf size={22} />
|
||||
SSO 계정으로 로그인
|
||||
<ExternalLink size={16} className="opacity-50" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<p className="mt-6 text-xs text-center text-muted-foreground leading-relaxed">
|
||||
관리자 전역 세션은 보안을 위해 15분간 유지됩니다.
|
||||
<br />
|
||||
민감한 작업 시 재인증을 요구할 수 있습니다.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-center gap-4">
|
||||
<div className="h-1 w-1 rounded-full bg-primary/30" />
|
||||
<div className="h-1 w-1 rounded-full bg-primary/30" />
|
||||
<div className="h-1 w-1 rounded-full bg-primary/30" />
|
||||
</div>
|
||||
|
||||
<p className="px-8 text-center text-sm text-muted-foreground">
|
||||
인증 정보가 없거나 로그인이 되지 않는 경우
|
||||
<br />
|
||||
시스템 관리자에게 문의하세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoginPage;
|
||||
@@ -216,6 +216,8 @@ function GlobalOverviewPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<PermissionChecker />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { CheckCircle2, Search, ShieldAlert, XCircle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import apiClient from "../../../lib/apiClient";
|
||||
|
||||
type CheckPermissionResponse = {
|
||||
allowed: boolean;
|
||||
query: {
|
||||
namespace: string;
|
||||
object: string;
|
||||
relation: string;
|
||||
subject: string;
|
||||
};
|
||||
};
|
||||
|
||||
function PermissionChecker() {
|
||||
const [namespace, setNamespace] = useState("Tenant");
|
||||
const [object, setObject] = useState("");
|
||||
const [relation, setRelation] = useState("manage");
|
||||
const [subject, setSubject] = useState("");
|
||||
|
||||
const checkMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await apiClient.get<CheckPermissionResponse>(
|
||||
"/v1/admin/debug/check-permission",
|
||||
{
|
||||
params: { namespace, object, relation, subject },
|
||||
},
|
||||
);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const result = checkMutation.data;
|
||||
|
||||
return (
|
||||
<Card className="bg-[var(--color-panel)] border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ShieldAlert size={20} className="text-primary" />
|
||||
ReBAC 권한 검증 도구
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
특정 주체(Subject)가 특정 리소스(Object)에 대해 권한이 있는지 Ory
|
||||
Keto를 통해 실시간으로 확인합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Namespace</Label>
|
||||
<select
|
||||
value={namespace}
|
||||
onChange={(e) => setNamespace(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="Tenant">Tenant</option>
|
||||
<option value="TenantGroup">TenantGroup</option>
|
||||
<option value="RelyingParty">RelyingParty</option>
|
||||
<option value="System">System</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Relation</Label>
|
||||
<Input
|
||||
placeholder="view, manage, admins..."
|
||||
value={relation}
|
||||
onChange={(e) => setRelation(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Object ID</Label>
|
||||
<Input
|
||||
placeholder="Tenant UUID 등"
|
||||
value={object}
|
||||
onChange={(e) => setObject(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Subject (User:ID)</Label>
|
||||
<Input
|
||||
placeholder="User:uuid 또는 Namespace:ID#Relation"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
onClick={() => checkMutation.mutate()}
|
||||
disabled={!object || !subject || checkMutation.isPending}
|
||||
className="w-full md:w-auto px-12"
|
||||
>
|
||||
{checkMutation.isPending ? "검증 중..." : "권한 확인 실행"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{checkMutation.isSuccess && (
|
||||
<div
|
||||
className={`p-6 rounded-xl border-2 flex flex-col items-center justify-center gap-3 animate-in zoom-in duration-300 ${
|
||||
result.allowed
|
||||
? "bg-green-500/10 border-green-500/50 text-green-600"
|
||||
: "bg-destructive/10 border-destructive/50 text-destructive"
|
||||
}`}
|
||||
>
|
||||
{result.allowed ? (
|
||||
<>
|
||||
<CheckCircle2 size={48} />
|
||||
<div className="text-xl font-bold">Access ALLOWED</div>
|
||||
<p className="text-sm opacity-80 text-center">
|
||||
해당 사용자는 요청한 리소스에 대해 권한이 있습니다. (상속
|
||||
포함)
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle size={48} />
|
||||
<div className="text-xl font-bold">Access DENIED</div>
|
||||
<p className="text-sm opacity-80 text-center">
|
||||
해당 사용자는 요청한 리소스에 대해 권한이 없습니다.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default PermissionChecker;
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Plus, Search, ShieldCheck, Trash2, UserPlus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useOutletContext } 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 {
|
||||
type TenantGroupSummary,
|
||||
addGroupAdmin,
|
||||
fetchGroupAdmins,
|
||||
fetchUsers,
|
||||
removeGroupAdmin,
|
||||
} from "../../../lib/adminApi";
|
||||
|
||||
function TenantGroupAdminsTab() {
|
||||
const { group } = useOutletContext<{
|
||||
group: TenantGroupSummary;
|
||||
}>();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
// 현재 관리자 목록
|
||||
const adminsQuery = useQuery({
|
||||
queryKey: ["tenant-group-admins", group.id],
|
||||
queryFn: () => fetchGroupAdmins(group.id),
|
||||
enabled: !!group.id,
|
||||
});
|
||||
|
||||
// 전체 사용자 목록 (관리자 추가용)
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ["users", { limit: 100, search: searchTerm }],
|
||||
queryFn: () => fetchUsers(100, 0, searchTerm),
|
||||
enabled: searchTerm.length > 1, // 2글자 이상 입력 시 검색
|
||||
});
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (userId: string) => addGroupAdmin(group.id, userId),
|
||||
onSuccess: () => {
|
||||
adminsQuery.refetch();
|
||||
setSearchTerm("");
|
||||
},
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (userId: string) => removeGroupAdmin(group.id, userId),
|
||||
onSuccess: () => {
|
||||
adminsQuery.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddAdmin = (userId: string) => {
|
||||
addMutation.mutate(userId);
|
||||
};
|
||||
|
||||
const handleRemoveAdmin = (userId: string, userName: string) => {
|
||||
if (window.confirm(`${userName} 사용자의 관리자 권한을 회수할까요?`)) {
|
||||
removeMutation.mutate(userId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* 현재 그룹 관리자 */}
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ShieldCheck size={18} className="text-primary" />
|
||||
그룹 관리자
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
이 그룹과 소속 테넌트를 관리할 수 있는 권한을 가진 사용자들입니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>이름</TableHead>
|
||||
<TableHead>이메일</TableHead>
|
||||
<TableHead className="text-right">회수</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{adminsQuery.data?.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
등록된 관리자가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{adminsQuery.data?.map((admin) => (
|
||||
<TableRow key={admin.id}>
|
||||
<TableCell className="font-medium">
|
||||
{admin.name || "Unknown"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{admin.email}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveAdmin(admin.id, admin.name)}
|
||||
disabled={removeMutation.isPending}
|
||||
>
|
||||
<Trash2 size={14} className="text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 사용자 검색 및 추가 */}
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<UserPlus size={18} className="text-primary" />
|
||||
관리자 추가
|
||||
</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
관리자로 추가할 사용자를 검색하세요 (이름 또는 이메일).
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="사용자 검색 (최소 2자)..."
|
||||
className="pl-10"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>사용자</TableHead>
|
||||
<TableHead className="text-right">추가</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{searchTerm.length < 2 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={2}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
사용자 이름을 입력하여 검색하세요.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{searchTerm.length >= 2 &&
|
||||
usersQuery.data?.items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={2}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
검색 결과가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{usersQuery.data?.items
|
||||
.filter((u) => !adminsQuery.data?.some((a) => a.id === u.id))
|
||||
.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{user.name}</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{user.email}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleAddAdmin(user.id)}
|
||||
disabled={addMutation.isPending}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TenantGroupAdminsTab;
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { LayoutGrid, Sparkles } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import { Textarea } from "../../../components/ui/textarea";
|
||||
import { createTenantGroup } from "../../../lib/adminApi";
|
||||
|
||||
function TenantGroupCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createTenantGroup({
|
||||
name,
|
||||
slug: slug || name.toLowerCase().replace(/ /g, "-"),
|
||||
description: description || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
navigate("/tenant-groups");
|
||||
},
|
||||
});
|
||||
|
||||
const errorMsg = (mutation.error as AxiosError<{ error?: string }>)?.response
|
||||
?.data?.error;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||
<span>Tenants</span>
|
||||
<span>/</span>
|
||||
<span>Groups</span>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">Create</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-3xl font-semibold">테넌트 그룹 추가</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
여러 테넌트를 논리적으로 묶어 관리하기 위한 그룹을 생성합니다.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="muted">Super Admin only</Badge>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<LayoutGrid size={18} className="text-primary" />
|
||||
Group Profile
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
그룹 이름과 식별자(Slug)를 입력합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
Group Name <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="예: 바론소프트웨어 통합그룹"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">Slug</Label>
|
||||
<Input
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
placeholder="baron-group"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
URL이나 API에서 사용될 고유 식별자입니다. 비워두면 이름 기반으로
|
||||
자동 생성됩니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">Description</Label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="그룹에 대한 설명을 입력하세요."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errorMsg && (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Sparkles size={18} />
|
||||
권한 상속 안내
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
테넌트 그룹의 관리자는 소속된 모든 테넌트에 대한 관리 권한을
|
||||
자동으로 가집니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-[var(--color-muted)]">
|
||||
생성 후 상세 페이지에서 테넌트를 이 그룹에 할당할 수 있습니다.
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Button variant="outline" onClick={() => navigate("/tenant-groups")}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending || name.trim() === ""}
|
||||
>
|
||||
생성
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TenantGroupCreatePage;
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft, LayoutGrid } from "lucide-react";
|
||||
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { fetchTenantGroup } from "../../../lib/adminApi";
|
||||
|
||||
function TenantGroupDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const location = useLocation();
|
||||
|
||||
const groupQuery = useQuery({
|
||||
queryKey: ["tenant-group", id],
|
||||
queryFn: () => fetchTenantGroup(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const isTenantsTab = location.pathname.endsWith("/tenants");
|
||||
const isAdminTab = location.pathname.endsWith("/admins");
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||
<Link
|
||||
to="/tenant-groups"
|
||||
className="inline-flex items-center gap-2 hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
Groups
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">Detail</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<LayoutGrid size={24} className="text-primary" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{groupQuery.data?.name ?? "Loading Group..."}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
{groupQuery.data?.description ||
|
||||
"그룹 정보를 관리하고 소속 테넌트를 구성합니다."}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="muted">Super Admin only</Badge>
|
||||
</header>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-border">
|
||||
<Link
|
||||
to={`/tenant-groups/${id}`}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors ${
|
||||
!isTenantsTab && !isAdminTab
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
기본 정보
|
||||
</Link>
|
||||
<Link
|
||||
to={`/tenant-groups/${id}/tenants`}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors ${
|
||||
isTenantsTab
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
소속 테넌트 ({groupQuery.data?.tenants?.length ?? 0})
|
||||
</Link>
|
||||
<Link
|
||||
to={`/tenant-groups/${id}/admins`}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors ${
|
||||
isAdminTab
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
관리자
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Outlet
|
||||
context={{ group: groupQuery.data, refetch: groupQuery.refetch }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TenantGroupDetailPage;
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { LayoutGrid, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import { deleteTenantGroup, fetchTenantGroups } from "../../../lib/adminApi";
|
||||
|
||||
function TenantGroupListPage() {
|
||||
const navigate = useNavigate();
|
||||
const query = useQuery({
|
||||
queryKey: ["tenant-groups", { limit: 50, offset: 0 }],
|
||||
queryFn: () => fetchTenantGroups(50, 0),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (groupId: string) => deleteTenantGroup(groupId),
|
||||
onSuccess: () => {
|
||||
query.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
||||
?.data?.error;
|
||||
const fallbackError =
|
||||
!errorMsg && query.isError ? "테넌트 그룹 목록 조회에 실패했습니다." : null;
|
||||
|
||||
const items = query.data?.items ?? [];
|
||||
|
||||
const handleDelete = (groupId: string, groupName: string) => {
|
||||
if (!window.confirm(`테넌트 그룹 "${groupName}"를 삭제할까요?`)) {
|
||||
return;
|
||||
}
|
||||
deleteMutation.mutate(groupId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||
<span>Tenants</span>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">Groups</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold">테넌트 그룹 목록</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
여러 테넌트를 하나의 그룹으로 묶어 관리합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/tenant-groups/new">
|
||||
<Plus size={16} />
|
||||
그룹 추가
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<LayoutGrid size={20} className="text-primary" />
|
||||
Tenant Group Registry
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
총 {query.data?.total ?? 0}개 그룹
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="muted">Super Admin only</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(errorMsg || fallbackError) && (
|
||||
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{errorMsg ?? fallbackError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>NAME</TableHead>
|
||||
<TableHead>SLUG</TableHead>
|
||||
<TableHead>TENANTS</TableHead>
|
||||
<TableHead>CREATED</TableHead>
|
||||
<TableHead className="text-right">ACTIONS</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>로딩 중...</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!query.isLoading && items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>
|
||||
아직 등록된 테넌트 그룹이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{items.map((group) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell className="font-semibold">{group.name}</TableCell>
|
||||
<TableCell>{group.slug}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">
|
||||
{group.tenants?.length ?? 0}개
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{group.createdAt
|
||||
? new Date(group.createdAt).toLocaleDateString("ko-KR")
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/tenant-groups/${group.id}`)}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
관리
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(group.id, group.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TenantGroupListPage;
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { useState } from "react";
|
||||
import { useOutletContext } 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 { Label } from "../../../components/ui/label";
|
||||
import { Textarea } from "../../../components/ui/textarea";
|
||||
import {
|
||||
type TenantGroupSummary,
|
||||
updateTenantGroup,
|
||||
} from "../../../lib/adminApi";
|
||||
|
||||
function TenantGroupProfileTab() {
|
||||
const { group, refetch } = useOutletContext<{
|
||||
group: TenantGroupSummary;
|
||||
refetch: () => void;
|
||||
}>();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [name, setName] = useState(group?.name ?? "");
|
||||
const [description, setDescription] = useState(group?.description ?? "");
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => updateTenantGroup(group.id, { name, description }),
|
||||
onSuccess: () => {
|
||||
refetch();
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant-groups"] });
|
||||
},
|
||||
});
|
||||
|
||||
const errorMsg = (mutation.error as AxiosError<{ error?: string }>)?.response
|
||||
?.data?.error;
|
||||
|
||||
if (!group) return null;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader>
|
||||
<CardTitle>그룹 정보 수정</CardTitle>
|
||||
<CardDescription>
|
||||
그룹의 기본 이름과 설명을 변경할 수 있습니다. 식별자(Slug)는 변경할
|
||||
수 없습니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>그룹 ID (고유 식별자)</Label>
|
||||
<Input value={group.id} disabled className="bg-muted" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Slug</Label>
|
||||
<Input value={group.slug} disabled className="bg-muted" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="groupName">Group Name</Label>
|
||||
<Input
|
||||
id="groupName"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="groupDesc">Description</Label>
|
||||
<Textarea
|
||||
id="groupDesc"
|
||||
rows={4}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errorMsg && (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={
|
||||
mutation.isPending ||
|
||||
(name === group.name && description === group.description)
|
||||
}
|
||||
>
|
||||
{mutation.isPending ? "저장 중..." : "변경사항 저장"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TenantGroupProfileTab;
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Building2, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
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 {
|
||||
type TenantGroupSummary,
|
||||
addTenantToGroup,
|
||||
fetchTenants,
|
||||
removeTenantFromGroup,
|
||||
} from "../../../lib/adminApi";
|
||||
|
||||
function TenantGroupTenantsTab() {
|
||||
const { group, refetch } = useOutletContext<{
|
||||
group: TenantGroupSummary;
|
||||
refetch: () => void;
|
||||
}>();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
// 전체 테넌트 목록 (할당용)
|
||||
const tenantsQuery = useQuery({
|
||||
queryKey: ["tenants", { limit: 100 }],
|
||||
queryFn: () => fetchTenants(100, 0),
|
||||
});
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (tenantId: string) => addTenantToGroup(group.id, tenantId),
|
||||
onSuccess: () => {
|
||||
refetch();
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant-group", group.id] });
|
||||
},
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (tenantId: string) => removeTenantFromGroup(group.id, tenantId),
|
||||
onSuccess: () => {
|
||||
refetch();
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant-group", group.id] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddTenant = (tenantId: string) => {
|
||||
addMutation.mutate(tenantId);
|
||||
};
|
||||
|
||||
const handleRemoveTenant = (tenantId: string) => {
|
||||
if (window.confirm("이 테넌트를 그룹에서 제외할까요?")) {
|
||||
removeMutation.mutate(tenantId);
|
||||
}
|
||||
};
|
||||
|
||||
const availableTenants =
|
||||
tenantsQuery.data?.items.filter(
|
||||
(t) => !group.tenants?.some((gt) => gt.id === t.id),
|
||||
) || [];
|
||||
|
||||
const filteredAvailable = availableTenants.filter(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
t.slug.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* 현재 소속 테넌트 */}
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 size={18} className="text-primary" />
|
||||
소속 테넌트
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
현재 이 그룹에 포함된 테넌트 목록입니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>이름</TableHead>
|
||||
<TableHead>Slug</TableHead>
|
||||
<TableHead className="text-right">제외</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{group.tenants?.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
소속된 테넌트가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{group.tenants?.map((t) => (
|
||||
<TableRow key={t.id}>
|
||||
<TableCell className="font-medium">{t.name}</TableCell>
|
||||
<TableCell className="text-xs">{t.slug}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveTenant(t.id)}
|
||||
disabled={removeMutation.isPending}
|
||||
>
|
||||
<Trash2 size={14} className="text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 추가 가능한 테넌트 */}
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Plus size={18} className="text-primary" />
|
||||
테넌트 추가
|
||||
</CardTitle>
|
||||
<div className="relative w-48">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="검색..."
|
||||
className="pl-8 h-9"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>
|
||||
다른 그룹에 속하지 않았거나 이동이 필요한 테넌트를 선택하세요.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>이름</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead className="text-right">추가</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredAvailable.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
추가할 수 있는 테넌트가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{filteredAvailable.map((t) => (
|
||||
<TableRow key={t.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{t.name}</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{t.slug}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{t.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleAddTenant(t.id)}
|
||||
disabled={addMutation.isPending}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TenantGroupTenantsTab;
|
||||
214
adminfront/src/features/tenants/routes/TenantAdminsTab.tsx
Normal file
214
adminfront/src/features/tenants/routes/TenantAdminsTab.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Plus, Search, ShieldCheck, Trash2, UserPlus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
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 {
|
||||
addTenantAdmin,
|
||||
fetchTenantAdmins,
|
||||
fetchUsers,
|
||||
removeTenantAdmin,
|
||||
} from "../../../lib/adminApi";
|
||||
|
||||
function TenantAdminsTab() {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
if (!tenantId) return null;
|
||||
|
||||
// 현재 관리자 목록
|
||||
const adminsQuery = useQuery({
|
||||
queryKey: ["tenant-admins", tenantId],
|
||||
queryFn: () => fetchTenantAdmins(tenantId),
|
||||
enabled: !!tenantId,
|
||||
});
|
||||
|
||||
// 전체 사용자 목록 (관리자 추가용)
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ["users", { limit: 100, search: searchTerm }],
|
||||
queryFn: () => fetchUsers(100, 0, searchTerm),
|
||||
enabled: searchTerm.length > 1,
|
||||
});
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (userId: string) => addTenantAdmin(tenantId, userId),
|
||||
onSuccess: () => {
|
||||
adminsQuery.refetch();
|
||||
setSearchTerm("");
|
||||
},
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (userId: string) => removeTenantAdmin(tenantId, userId),
|
||||
onSuccess: () => {
|
||||
adminsQuery.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddAdmin = (userId: string) => {
|
||||
addMutation.mutate(userId);
|
||||
};
|
||||
|
||||
const handleRemoveAdmin = (userId: string, userName: string) => {
|
||||
if (window.confirm(`${userName} 사용자의 관리자 권한을 회수할까요?`)) {
|
||||
removeMutation.mutate(userId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-2 mt-6">
|
||||
{/* 현재 테넌트 관리자 */}
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ShieldCheck size={18} className="text-primary" />
|
||||
테넌트 관리자
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
이 테넌트의 자원을 관리할 수 있는 권한을 가진 사용자들입니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>이름</TableHead>
|
||||
<TableHead>이메일</TableHead>
|
||||
<TableHead className="text-right">회수</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{adminsQuery.data?.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
등록된 관리자가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{adminsQuery.data?.map((admin) => (
|
||||
<TableRow key={admin.id}>
|
||||
<TableCell className="font-medium">
|
||||
{admin.name || "Unknown"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{admin.email}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveAdmin(admin.id, admin.name)}
|
||||
disabled={removeMutation.isPending}
|
||||
>
|
||||
<Trash2 size={14} className="text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 사용자 검색 및 추가 */}
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<UserPlus size={18} className="text-primary" />
|
||||
관리자 추가
|
||||
</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
관리자로 추가할 사용자를 검색하세요 (이름 또는 이메일).
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="사용자 검색 (최소 2자)..."
|
||||
className="pl-10"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>사용자</TableHead>
|
||||
<TableHead className="text-right">추가</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{searchTerm.length < 2 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={2}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
사용자 이름을 입력하여 검색하세요.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{searchTerm.length >= 2 &&
|
||||
usersQuery.data?.items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={2}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
검색 결과가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{usersQuery.data?.items
|
||||
.filter((u) => !adminsQuery.data?.some((a) => a.id === u.id))
|
||||
.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{user.name}</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{user.email}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleAddAdmin(user.id)}
|
||||
disabled={addMutation.isPending}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TenantAdminsTab;
|
||||
@@ -16,6 +16,7 @@ function TenantDetailPage() {
|
||||
});
|
||||
|
||||
const isFederationTab = location.pathname.includes("/federation");
|
||||
const isAdminTab = location.pathname.includes("/admins");
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@@ -44,7 +45,9 @@ function TenantDetailPage() {
|
||||
<Link
|
||||
to={`/tenants/${tenantId}`}
|
||||
className={`px-4 py-2 text-sm font-medium ${
|
||||
!isFederationTab
|
||||
!isFederationTab &&
|
||||
!isAdminTab &&
|
||||
!location.pathname.includes("/schema")
|
||||
? "border-b-2 border-blue-500 text-blue-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
@@ -61,6 +64,16 @@ function TenantDetailPage() {
|
||||
>
|
||||
Federation
|
||||
</Link>
|
||||
<Link
|
||||
to={`/tenants/${tenantId}/admins`}
|
||||
className={`px-4 py-2 text-sm font-medium ${
|
||||
isAdminTab
|
||||
? "border-b-2 border-blue-500 text-blue-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
Admins
|
||||
</Link>
|
||||
<Link
|
||||
to={`/tenants/${tenantId}/schema`}
|
||||
className={`px-4 py-2 text-sm font-medium ${
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
approveTenant,
|
||||
deleteTenant,
|
||||
fetchTenant,
|
||||
fetchTenantGroups,
|
||||
updateTenant,
|
||||
} from "../../../lib/adminApi";
|
||||
|
||||
@@ -35,11 +36,17 @@ export function TenantProfilePage() {
|
||||
queryFn: () => fetchTenant(tenantId),
|
||||
});
|
||||
|
||||
const groupsQuery = useQuery({
|
||||
queryKey: ["tenant-groups", { limit: 100 }],
|
||||
queryFn: () => fetchTenantGroups(100, 0),
|
||||
});
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [status, setStatus] = useState("active");
|
||||
const [domains, setDomains] = useState("");
|
||||
const [tenantGroupId, setTenantGroupId] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (tenantQuery.data) {
|
||||
@@ -48,6 +55,7 @@ export function TenantProfilePage() {
|
||||
setDescription(tenantQuery.data.description ?? "");
|
||||
setStatus(tenantQuery.data.status);
|
||||
setDomains(tenantQuery.data.domains?.join(", ") ?? "");
|
||||
setTenantGroupId(tenantQuery.data.tenantGroupId ?? "");
|
||||
}
|
||||
}, [tenantQuery.data]);
|
||||
|
||||
@@ -58,6 +66,7 @@ export function TenantProfilePage() {
|
||||
slug,
|
||||
description: description || undefined,
|
||||
status,
|
||||
tenantGroupId: tenantGroupId || undefined,
|
||||
domains: domains
|
||||
.split(",")
|
||||
.map((d) => d.trim())
|
||||
@@ -136,6 +145,25 @@ export function TenantProfilePage() {
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">Tenant Group</Label>
|
||||
<select
|
||||
value={tenantGroupId}
|
||||
onChange={(e) => setTenantGroupId(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="">그룹 없음</option>
|
||||
{groupsQuery.data?.items.map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name} ({group.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
테넌트가 속할 그룹을 지정합니다. 그룹 관리자는 소속 테넌트에 대한
|
||||
접근 권한을 가집니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
Allowed Domains (Comma separated)
|
||||
|
||||
@@ -54,7 +54,8 @@
|
||||
|
||||
body {
|
||||
@apply min-h-screen bg-background font-sans text-foreground antialiased;
|
||||
background-image: radial-gradient(
|
||||
background-image:
|
||||
radial-gradient(
|
||||
circle at 10% 18%,
|
||||
rgba(54, 211, 153, 0.16),
|
||||
transparent 28%
|
||||
|
||||
@@ -371,3 +371,33 @@ export async function updateRelyingParty(id: string, payload: HydraClientReq) {
|
||||
export async function deleteRelyingParty(id: string) {
|
||||
await apiClient.delete(`/v1/admin/relying-parties/${id}`);
|
||||
}
|
||||
|
||||
export type RPOwner = {
|
||||
subject: string;
|
||||
|
||||
name?: string;
|
||||
|
||||
email?: string;
|
||||
|
||||
type: string;
|
||||
};
|
||||
|
||||
export async function fetchRPOwners(clientId: string) {
|
||||
const { data } = await apiClient.get<RPOwner[]>(
|
||||
`/v1/admin/relying-parties/${clientId}/owners`,
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function addRPOwner(clientId: string, subject: string) {
|
||||
await apiClient.post(
|
||||
`/v1/admin/relying-parties/${clientId}/owners/${subject}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeRPOwner(clientId: string, subject: string) {
|
||||
await apiClient.delete(
|
||||
`/v1/admin/relying-parties/${clientId}/owners/${subject}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,10 @@ apiClient.interceptors.request.use((config) => {
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
// TODO: 401/403 응답 시 로그인/재인증 플로우로 리다이렉션한다.
|
||||
if (error.response?.status === 401) {
|
||||
window.localStorage.removeItem("admin_session");
|
||||
window.location.href = "/login";
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
1316
adminfront/src/locales/en.toml
Normal file
1316
adminfront/src/locales/en.toml
Normal file
File diff suppressed because one or more lines are too long
1316
adminfront/src/locales/ko.toml
Normal file
1316
adminfront/src/locales/ko.toml
Normal file
File diff suppressed because one or more lines are too long
1316
adminfront/src/locales/template.toml
Normal file
1316
adminfront/src/locales/template.toml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import { defineConfig } from "vite";
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
envPrefix: ["VITE_", "USERFRONT_"],
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
proxy: {
|
||||
|
||||
@@ -146,7 +146,7 @@ services:
|
||||
- OATHKEEPER_INTROSPECT_CLIENT_SECRET=${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}
|
||||
volumes:
|
||||
- ./docker/ory/oathkeeper:/etc/config/oathkeeper
|
||||
- ./docker/ory/oathkeeper/logs:/var/log/oathkeeper
|
||||
- oathkeeper_logs:/var/log/oathkeeper
|
||||
entrypoint: ["/etc/config/oathkeeper/entrypoint.sh"]
|
||||
networks:
|
||||
- ory-net
|
||||
@@ -169,7 +169,7 @@ services:
|
||||
container_name: ory_vector
|
||||
volumes:
|
||||
- ./docker/ory/vector:/etc/vector
|
||||
- ./docker/ory/oathkeeper/logs:/var/log/oathkeeper
|
||||
- oathkeeper_logs:/var/log/oathkeeper
|
||||
command: ["-c", "/etc/vector/vector.toml"]
|
||||
depends_on:
|
||||
- oathkeeper
|
||||
@@ -239,6 +239,7 @@ services:
|
||||
volumes:
|
||||
ory_postgres_data:
|
||||
ory_clickhouse_data:
|
||||
oathkeeper_logs:
|
||||
|
||||
networks:
|
||||
ory-net:
|
||||
|
||||
36
devfront/package-lock.json
generated
36
devfront/package-lock.json
generated
@@ -18,9 +18,11 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"oidc-client-ts": "^3.4.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-oidc-context": "^3.3.0",
|
||||
"react-router-dom": "^6.28.2",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^3.24.1"
|
||||
@@ -2332,6 +2334,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jwt-decode": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
|
||||
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
|
||||
@@ -2774,6 +2785,18 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/oidc-client-ts": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.4.1.tgz",
|
||||
"integrity": "sha512-jNdst/U28Iasukx/L5MP6b274Vr7ftQs6qAhPBCvz6Wt5rPCA+Q/tUmCzfCHHWweWw5szeMy2Gfrm1rITwUKrw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"jwt-decode": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/path-parse": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
@@ -3095,6 +3118,19 @@
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-oidc-context": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-oidc-context/-/react-oidc-context-3.3.0.tgz",
|
||||
"integrity": "sha512-302T/ma4AOVAxrHdYctDSKXjCq9KNHT564XEO2yOPxRfxEP58xa4nz+GQinNl8x7CnEXECSM5JEjQJk3Cr5BvA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"oidc-client-ts": "^3.1.0",
|
||||
"react": ">=16.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||
|
||||
@@ -22,9 +22,11 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"oidc-client-ts": "^3.4.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-oidc-context": "^3.3.0",
|
||||
"react-router-dom": "^6.28.2",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^3.24.1"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Navigate, createBrowserRouter } from "react-router-dom";
|
||||
import AppLayout from "../components/layout/AppLayout";
|
||||
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
|
||||
import AuthGuard from "../features/auth/AuthGuard";
|
||||
import LoginPage from "../features/auth/LoginPage";
|
||||
import ClientConsentsPage from "../features/clients/ClientConsentsPage";
|
||||
import ClientDetailsPage from "../features/clients/ClientDetailsPage";
|
||||
import ClientGeneralPage from "../features/clients/ClientGeneralPage";
|
||||
@@ -7,16 +10,29 @@ import ClientsPage from "../features/clients/ClientsPage";
|
||||
|
||||
export const router = createBrowserRouter(
|
||||
[
|
||||
{
|
||||
path: "/login",
|
||||
element: <LoginPage />,
|
||||
},
|
||||
{
|
||||
path: "/callback",
|
||||
element: <AuthCallbackPage />,
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
element: <AppLayout />,
|
||||
element: <AuthGuard />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/clients" replace /> },
|
||||
{ path: "clients", element: <ClientsPage /> },
|
||||
{ path: "clients/new", element: <ClientGeneralPage /> },
|
||||
{ path: "clients/:id", element: <ClientDetailsPage /> },
|
||||
{ path: "clients/:id/consents", element: <ClientConsentsPage /> },
|
||||
{ path: "clients/:id/settings", element: <ClientGeneralPage /> },
|
||||
{
|
||||
element: <AppLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/clients" replace /> },
|
||||
{ path: "clients", element: <ClientsPage /> },
|
||||
{ path: "clients/new", element: <ClientGeneralPage /> },
|
||||
{ path: "clients/:id", element: <ClientDetailsPage /> },
|
||||
{ path: "clients/:id/consents", element: <ClientConsentsPage /> },
|
||||
{ path: "clients/:id/settings", element: <ClientGeneralPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BadgeCheck, Moon, ShieldHalf, Sun } from "lucide-react";
|
||||
import { BadgeCheck, LogOut, Moon, ShieldHalf, Sun } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
import { t } from "../../lib/i18n";
|
||||
import LanguageSelector from "../common/LanguageSelector";
|
||||
|
||||
@@ -26,7 +26,8 @@ const badgeVariants = cva(
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
extends
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
|
||||
@@ -34,7 +34,8 @@ const buttonVariants = cva(
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
extends
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
|
||||
19
devfront/src/features/auth/AuthCallbackPage.tsx
Normal file
19
devfront/src/features/auth/AuthCallbackPage.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useEffect } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export default function AuthCallbackPage() {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.isAuthenticated) {
|
||||
navigate("/", { replace: true });
|
||||
} else if (auth.error) {
|
||||
console.error("Auth Error:", auth.error);
|
||||
navigate("/login", { replace: true });
|
||||
}
|
||||
}, [auth.isAuthenticated, auth.error, navigate]);
|
||||
|
||||
return <div>Loading Auth...</div>;
|
||||
}
|
||||
20
devfront/src/features/auth/AuthGuard.tsx
Normal file
20
devfront/src/features/auth/AuthGuard.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { Navigate, Outlet } from "react-router-dom";
|
||||
|
||||
export default function AuthGuard() {
|
||||
const auth = useAuth();
|
||||
|
||||
if (auth.isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (auth.error) {
|
||||
return <div>Auth Error: {auth.error.message}</div>;
|
||||
}
|
||||
|
||||
if (!auth.isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
87
devfront/src/features/auth/LoginPage.tsx
Normal file
87
devfront/src/features/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { ShieldHalf, LogIn, ExternalLink } from "lucide-react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
|
||||
function LoginPage() {
|
||||
const auth = useAuth();
|
||||
|
||||
const handleSSOLogin = () => {
|
||||
// OIDC client-side authentication flow started here
|
||||
auth.signinRedirect();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-12 sm:px-6 lg:px-8 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-primary/10 via-background to-background">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="flex flex-col items-center justify-center space-y-4 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/15 text-primary shadow-[0_20px_50px_rgba(54,211,153,0.3)]">
|
||||
<ShieldHalf size={32} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Baron SSO</h1>
|
||||
<p className="text-sm text-muted-foreground uppercase tracking-[0.2em]">
|
||||
Developer Control Plane
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-primary/20 bg-card/50 backdrop-blur-xl shadow-2xl">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl flex items-center gap-2">
|
||||
<LogIn size={20} className="text-primary" />
|
||||
개발자 포털 로그인
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Baron 통합 인증(SSO)을 통해 개발자 포털에 접속합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4 pb-8 space-y-3">
|
||||
<Button
|
||||
onClick={handleSSOLogin}
|
||||
className="w-full h-14 text-lg font-semibold flex gap-3 shadow-lg"
|
||||
disabled={auth.isLoading}
|
||||
>
|
||||
{auth.isLoading ? (
|
||||
<>
|
||||
<div className="h-5 w-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
로그인 진행 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldHalf size={22} />
|
||||
SSO 계정으로 로그인
|
||||
<ExternalLink size={16} className="opacity-50" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<p className="mt-6 text-xs text-center text-muted-foreground leading-relaxed">
|
||||
개발자 포털 세션은 브라우저 정책에 따라 유지됩니다.<br />
|
||||
민감한 작업 시 재인증을 요구할 수 있습니다.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-center gap-4">
|
||||
<div className="h-1 w-1 rounded-full bg-primary/30"></div>
|
||||
<div className="h-1 w-1 rounded-full bg-primary/30"></div>
|
||||
<div className="h-1 w-1 rounded-full bg-primary/30"></div>
|
||||
</div>
|
||||
|
||||
<p className="px-8 text-center text-sm text-muted-foreground">
|
||||
인증 정보가 없거나 로그인이 되지 않는 경우<br />
|
||||
시스템 관리자에게 문의하세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoginPage;
|
||||
22
devfront/src/features/auth/authApi.ts
Normal file
22
devfront/src/features/auth/authApi.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import apiClient from "../../lib/apiClient";
|
||||
|
||||
export interface Tenant {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
companyCode?: string;
|
||||
tenantId?: string;
|
||||
tenant?: Tenant;
|
||||
}
|
||||
|
||||
export async function fetchMe() {
|
||||
const { data } = await apiClient.get<UserProfile>("/user/me");
|
||||
return data;
|
||||
}
|
||||
@@ -211,14 +211,10 @@ function ClientsPage() {
|
||||
variant={
|
||||
item.tone === "up"
|
||||
? "success"
|
||||
: item.tone === "down"
|
||||
? "warning"
|
||||
: "muted"
|
||||
: "muted"
|
||||
}
|
||||
className={cn(
|
||||
"px-2",
|
||||
item.tone === "down" &&
|
||||
"bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-200",
|
||||
item.tone === "stable" && "bg-muted/40 text-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -54,7 +54,8 @@
|
||||
|
||||
body {
|
||||
@apply min-h-screen bg-background font-sans text-foreground antialiased;
|
||||
background-image: radial-gradient(
|
||||
background-image:
|
||||
radial-gradient(
|
||||
circle at 10% 18%,
|
||||
rgba(54, 211, 153, 0.16),
|
||||
transparent 28%
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios from "axios";
|
||||
import { userManager } from "./auth";
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL:
|
||||
@@ -7,15 +8,15 @@ const apiClient = axios.create({
|
||||
"/api/v1",
|
||||
});
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
// TODO: IdP 중립 Auth 레이어 연동 시 세션 토큰을 주입한다.
|
||||
const sessionToken = window.localStorage.getItem("admin_session");
|
||||
if (sessionToken) {
|
||||
config.headers.Authorization = `Bearer ${sessionToken}`;
|
||||
apiClient.interceptors.request.use(async (config) => {
|
||||
// OIDC Access Token 주입
|
||||
const user = await userManager.getUser();
|
||||
if (user?.access_token) {
|
||||
config.headers.Authorization = `Bearer ${user.access_token}`;
|
||||
}
|
||||
|
||||
// TODO: 테넌트 선택 값을 보관하고 헤더로 전달한다.
|
||||
const tenantId = window.localStorage.getItem("admin_tenant");
|
||||
const tenantId = window.localStorage.getItem("dev_tenant_id"); // 키 이름을 좀 더 명확하게 변경 고려
|
||||
if (tenantId) {
|
||||
config.headers["X-Tenant-ID"] = tenantId;
|
||||
}
|
||||
@@ -26,7 +27,13 @@ apiClient.interceptors.request.use((config) => {
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
// TODO: 401/403 응답 시 로그인/재인증 플로우로 리다이렉션한다.
|
||||
if (error.response?.status === 401) {
|
||||
// 401 발생 시 로그인 페이지로 리다이렉트
|
||||
const isAuthPath = window.location.pathname.startsWith("/callback");
|
||||
if (!isAuthPath) {
|
||||
userManager.signinRedirect();
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
20
devfront/src/lib/auth.ts
Normal file
20
devfront/src/lib/auth.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { UserManager, WebStorageStateStore } from "oidc-client-ts";
|
||||
import type { AuthProviderProps } from "react-oidc-context";
|
||||
|
||||
export const oidcConfig: AuthProviderProps = {
|
||||
authority: import.meta.env.VITE_OIDC_AUTHORITY || "http://localhost:5000/oidc", // Gateway Proxy URL
|
||||
client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "devfront-client",
|
||||
redirect_uri: `${window.location.origin}/callback`,
|
||||
response_type: "code",
|
||||
scope: "openid offline_access profile email", // offline_access for refresh token
|
||||
post_logout_redirect_uri: window.location.origin,
|
||||
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
||||
automaticSilentRenew: true,
|
||||
};
|
||||
|
||||
export const userManager = new UserManager({
|
||||
...oidcConfig,
|
||||
authority: oidcConfig.authority || "",
|
||||
client_id: oidcConfig.client_id || "",
|
||||
redirect_uri: oidcConfig.redirect_uri || "",
|
||||
});
|
||||
@@ -9,6 +9,7 @@ export type ClientSummary = {
|
||||
type: ClientType;
|
||||
status: ClientStatus;
|
||||
createdAt?: string;
|
||||
clientSecret?: string;
|
||||
redirectUris: string[];
|
||||
scopes: string[];
|
||||
};
|
||||
|
||||
1316
devfront/src/locales/en.toml
Normal file
1316
devfront/src/locales/en.toml
Normal file
File diff suppressed because one or more lines are too long
1316
devfront/src/locales/ko.toml
Normal file
1316
devfront/src/locales/ko.toml
Normal file
File diff suppressed because one or more lines are too long
1316
devfront/src/locales/template.toml
Normal file
1316
devfront/src/locales/template.toml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,13 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { AuthProvider } from "react-oidc-context";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import { queryClient } from "./app/queryClient";
|
||||
import { router } from "./app/routes";
|
||||
import { Toaster } from "./components/ui/toaster";
|
||||
import "./index.css";
|
||||
import { oidcConfig } from "./lib/auth";
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
|
||||
@@ -15,9 +17,11 @@ if (!rootElement) {
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster />
|
||||
</QueryClientProvider>
|
||||
<AuthProvider {...oidcConfig}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -23,6 +23,8 @@ services:
|
||||
- KRATOS_ADMIN_URL=${KRATOS_ADMIN_URL:-http://kratos:4434}
|
||||
- HYDRA_ADMIN_URL=${HYDRA_ADMIN_URL:-http://hydra:4445}
|
||||
- HYDRA_PUBLIC_URL=${HYDRA_PUBLIC_URL:-http://hydra:4444}
|
||||
- KETO_READ_URL=${KETO_READ_URL:-http://keto:4466}
|
||||
- KETO_WRITE_URL=${KETO_WRITE_URL:-http://keto:4467}
|
||||
- DB_HOST=postgres
|
||||
- CLICKHOUSE_HOST=clickhouse
|
||||
- CLICKHOUSE_PORT=${CLICKHOUSE_PORT_NATIVE:-9000}
|
||||
@@ -54,6 +56,7 @@ services:
|
||||
environment:
|
||||
- APP_ENV=${APP_ENV:-development}
|
||||
- API_PROXY_TARGET=http://baron_backend:3000
|
||||
- USERFRONT_URL=${USERFRONT_URL}
|
||||
ports:
|
||||
- "${ADMIN_PORT:-5173}:5173"
|
||||
volumes:
|
||||
@@ -72,6 +75,7 @@ services:
|
||||
environment:
|
||||
- APP_ENV=${APP_ENV:-development}
|
||||
- API_PROXY_TARGET=http://baron_backend:3000
|
||||
- USERFRONT_URL=${USERFRONT_URL}
|
||||
ports:
|
||||
- "${DEVFRONT_PORT:-5174}:5173"
|
||||
volumes:
|
||||
|
||||
@@ -2,6 +2,12 @@ import { Namespace, Subject, Context, SubjectSet } from "@ory/keto-definitions"
|
||||
|
||||
class User implements Namespace {}
|
||||
|
||||
class TenantGroup implements Namespace {
|
||||
related: {
|
||||
admins: User[]
|
||||
}
|
||||
}
|
||||
|
||||
class UserGroup implements Namespace {
|
||||
related: {
|
||||
members: User[]
|
||||
@@ -19,17 +25,20 @@ class Tenant implements Namespace {
|
||||
admins: User[]
|
||||
members: User[]
|
||||
parent: Tenant[]
|
||||
parent_group: TenantGroup[]
|
||||
}
|
||||
|
||||
permits = {
|
||||
view: (ctx: Context): boolean =>
|
||||
this.related.members.includes(ctx.subject) ||
|
||||
this.related.admins.includes(ctx.subject) ||
|
||||
this.related.parent.traverse((p) => p.permits.view(ctx)),
|
||||
this.related.parent.traverse((p) => p.permits.view(ctx)) ||
|
||||
this.related.parent_group.traverse((g) => g.related.admins.includes(ctx.subject)),
|
||||
|
||||
manage: (ctx: Context): boolean =>
|
||||
this.related.admins.includes(ctx.subject) ||
|
||||
this.related.parent.traverse((p) => p.permits.manage(ctx)),
|
||||
this.related.parent.traverse((p) => p.permits.manage(ctx)) ||
|
||||
this.related.parent_group.traverse((g) => g.related.admins.includes(ctx.subject)),
|
||||
|
||||
create_subtenant: (ctx: Context): boolean =>
|
||||
this.permits.manage(ctx)
|
||||
|
||||
@@ -13,6 +13,12 @@ serve:
|
||||
admin:
|
||||
base_url: http://localhost:4434/
|
||||
|
||||
session:
|
||||
cookie:
|
||||
domain: hmac.kr
|
||||
same_site: Lax
|
||||
path: /
|
||||
|
||||
selfservice:
|
||||
default_browser_return_url: http://localhost:5000/
|
||||
allowed_return_urls:
|
||||
|
||||
91
docs/devfront_auth_flow_explanation.md
Normal file
91
docs/devfront_auth_flow_explanation.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# DevFront OIDC 인증 흐름 및 무한 루프 해결 보고
|
||||
|
||||
이 문서는 `devfront` 개발자 포털의 OIDC 인증 구현 방식, 무한 루프 문제의 원인 및 해결 방법, 그리고 클라이언트 자동 등록 원리에 대해 설명합니다.
|
||||
|
||||
## 1. DevFront 로그인 동작 플로우 (OIDC Authorization Code Flow)
|
||||
|
||||
### 시퀀스 다이어그램
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant DF as DevFront (RP)
|
||||
participant HY as Ory Hydra (OP)
|
||||
participant UF as UserFront (Login UI)
|
||||
participant KR as Ory Kratos (Identity)
|
||||
|
||||
User->>DF: 로그인 버튼 클릭
|
||||
DF->>HY: 인증 요청 (/oauth2/auth)
|
||||
HY->>UF: 로그인 UI 리다이렉트
|
||||
UF->>User: 로그인 페이지 표시
|
||||
User->>UF: 자격 증명 입력 (Email/PW)
|
||||
UF->>KR: 인증 수행
|
||||
KR-->>UF: 인증 성공 (Session 생성)
|
||||
UF->>HY: 로그인 승인 요청
|
||||
HY->>User: 권한 동의(Consent) 화면 표시
|
||||
User->>HY: '허용' 클릭
|
||||
HY-->>DF: 인증 코드와 함께 리다이렉트 (/callback?code=...)
|
||||
DF->>HY: 토큰 교환 요청 (Code -> ID/Access Token)
|
||||
HY-->>DF: 토큰 발급
|
||||
Note over DF: [FIX] 백엔드 /api/me 호출 대신<br/>ID Token에서 프로필 정보 직접 추출
|
||||
DF->>User: 대시보드 및 프로필 표시
|
||||
```
|
||||
|
||||
### 단계별 설명
|
||||
|
||||
1. **인증 요청 (Login Request)**:
|
||||
* 사용자가 `devfront` (localhost:5174)의 로그인 버튼을 클릭합니다.
|
||||
* `react-oidc-context` 라이브러리가 Hydra의 `/oauth2/auth` 엔드포인트로 사용자를 리다이렉트합니다.
|
||||
* 이때 클라이언트 ID(`devfront`), 리다이렉트 URI, Scope 등을 파라미터로 전달합니다.
|
||||
|
||||
2. **사용자 인증 (Authentication)**:
|
||||
* Hydra는 현재 세션이 없음을 확인하고, 설정된 로그인 UI(`userfront`)로 사용자를 보냅니다.
|
||||
* 사용자는 `userfront`에서 아이디/비밀번호를 입력하여 Kratos를 통해 인증을 마칩니다.
|
||||
|
||||
3. **권한 동의 (Consent)**:
|
||||
* 인증이 완료되면 Hydra는 사용자에게 `devfront` 앱이 요청한 권한(openid, profile, email 등)을 허용할지 묻는 Consent 화면을 띄웁니다.
|
||||
* 사용자가 '허용'을 누르면 Hydra는 `devfront`가 신뢰할 수 있는 앱임을 기록합니다.
|
||||
|
||||
4. **인증 코드 전달 및 토큰 교환 (Callback)**:
|
||||
* Hydra는 사용자를 `devfront`의 콜백 페이지(`http://localhost:5174/callback?code=...`)로 보냅니다.
|
||||
* `devfront`는 이 코드를 Hydra의 토큰 엔드포인트로 보내 **ID Token**과 **Access Token**을 발급받습니다.
|
||||
|
||||
5. **사용자 정보 로드 (Profile Recovery)**:
|
||||
* `devfront`는 발급받은 **ID Token**의 Payload를 디코딩하여 사용자 이름, 이메일 등의 프로필 정보를 즉시 화면에 렌더링합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 클라이언트 자동 등록의 원리
|
||||
|
||||
사용자가 직접 `devfront`를 클라이언트로 등록하지 않았음에도 로그인이 가능한 이유는 인프라 설정 파일인 `compose.ory.yaml`에 정의된 **`init-rp` 컨테이너** 덕분입니다.
|
||||
|
||||
### `init-rp` 서비스의 역할
|
||||
* Ory 스택(Kratos, Hydra, Keto)이 모두 정상 가동된 직후 실행됩니다.
|
||||
* Hydra Admin API를 호출하여 서비스 운영에 필수적인 기본 클라이언트들을 자동으로 생성합니다.
|
||||
|
||||
### 자동 등록된 `devfront` 명세
|
||||
```bash
|
||||
hydra clients create
|
||||
--endpoint http://hydra:4445
|
||||
--id devfront
|
||||
--grant-types authorization_code,refresh_token
|
||||
--response-types code
|
||||
--scope openid,offline_access,profile,email
|
||||
--token-endpoint-auth-method none \ # Public Client (PKCE 사용)
|
||||
--callbacks http://localhost:5174/callback;
|
||||
```
|
||||
이 설정으로 인해 `devfront`라는 ID의 클라이언트가 미리 존재하게 되며, `localhost:5174`로의 리다이렉션이 안전하게 허용됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 무한 루프 문제 해결 분석
|
||||
|
||||
### 발생 원인 (Problem)
|
||||
* `devfront`가 로그인 성공 후 사용자 정보를 가져오기 위해 백엔드 API인 `/api/v1/user/me`를 호출했습니다.
|
||||
* 이 API는 백엔드 세션 쿠키를 기반으로 동작하도록 설계되어 있었습니다.
|
||||
* 하지만 브라우저 보안 정책(SameSite/Cross-Domain)으로 인해 `localhost`에서 보낸 요청에는 `sso-test.hmac.kr` 도메인의 쿠키가 포함되지 않았습니다.
|
||||
* **결과**: 백엔드는 401 Unauthorized를 반환 -> `devfront`는 401을 받으면 다시 로그인을 시도하도록 구현되어 있어 무한 루프가 발생했습니다.
|
||||
|
||||
### 해결 방법 (Solution)
|
||||
* **쿠키 의존성 제거**: `AppLayout.tsx`에서 백엔드 호출(`fetchMe`)을 삭제했습니다.
|
||||
* **ID Token 직접 활용**: 이미 OIDC 인증 성공 시점에 전달받은 **ID Token(`auth.user.profile`)**에 필요한 모든 사용자 정보가 들어있으므로, 이를 직접 사용하도록 수정했습니다.
|
||||
* **표준 RP 구조 확립**: 이제 `devfront`는 백엔드의 세션 쿠키를 전혀 사용하지 않으며, API 요청 시에도 `Authorization: Bearer <token>` 헤더를 사용하여 정당한 권한을 증명합니다.
|
||||
@@ -24,6 +24,9 @@ server {
|
||||
client_header_buffer_size 16k;
|
||||
large_client_header_buffers 4 64k;
|
||||
include /etc/nginx/mime.types;
|
||||
types {
|
||||
application/javascript mjs;
|
||||
}
|
||||
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
set $backend_upstream http://baron_backend:3000;
|
||||
|
||||
@@ -11,7 +11,8 @@ class AuditService {
|
||||
return dotenv.env[key] ?? fallback;
|
||||
}
|
||||
|
||||
static String get _baseUrl => _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
|
||||
static String get _baseUrl =>
|
||||
_envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
|
||||
|
||||
static Future<void> logEvent({
|
||||
required String userId,
|
||||
@@ -20,7 +21,7 @@ class AuditService {
|
||||
String? details,
|
||||
}) async {
|
||||
final url = Uri.parse('$_baseUrl/api/v1/audit');
|
||||
|
||||
|
||||
try {
|
||||
final response = await http.post(
|
||||
url,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'http_client_stub.dart'
|
||||
if (dart.library.html) 'http_client_web.dart';
|
||||
import 'http_client_stub.dart' if (dart.library.html) 'http_client_web.dart';
|
||||
|
||||
http.Client createHttpClient({bool withCredentials = false}) {
|
||||
return httpClientFactory.create(withCredentials: withCredentials);
|
||||
|
||||
@@ -25,8 +25,10 @@ class LoggerService {
|
||||
);
|
||||
|
||||
// 2. Configure Standard Logger (logging package)
|
||||
std_log.Logger.root.level = kReleaseMode ? std_log.Level.WARNING : std_log.Level.ALL;
|
||||
|
||||
std_log.Logger.root.level = kReleaseMode
|
||||
? std_log.Level.WARNING
|
||||
: std_log.Level.ALL;
|
||||
|
||||
std_log.Logger.root.onRecord.listen((record) {
|
||||
if (kReleaseMode) {
|
||||
// [Production] Log as JSON
|
||||
@@ -41,13 +43,17 @@ class LoggerService {
|
||||
/// Initialize the logger. Call this in main.dart
|
||||
static void init() {
|
||||
// Accessing the instance triggers the constructor
|
||||
LoggerService();
|
||||
LoggerService();
|
||||
std_log.Logger('BaronSSO').info('Logger initialized');
|
||||
}
|
||||
|
||||
void _logPretty(std_log.LogRecord record) {
|
||||
if (record.level >= std_log.Level.SEVERE) {
|
||||
_prettyLogger.e(record.message, error: record.error, stackTrace: record.stackTrace);
|
||||
_prettyLogger.e(
|
||||
record.message,
|
||||
error: record.error,
|
||||
stackTrace: record.stackTrace,
|
||||
);
|
||||
} else if (record.level >= std_log.Level.WARNING) {
|
||||
_prettyLogger.w(record.message);
|
||||
} else if (record.level >= std_log.Level.INFO) {
|
||||
@@ -66,7 +72,7 @@ class LoggerService {
|
||||
if (record.error != null) 'error': record.error.toString(),
|
||||
if (record.stackTrace != null) 'stack': record.stackTrace.toString(),
|
||||
};
|
||||
|
||||
|
||||
// 1. Print to Browser Console (F12)
|
||||
debugPrint(jsonEncode(logData));
|
||||
|
||||
|
||||
@@ -14,15 +14,63 @@ void implSendLoginSuccess(String token) {
|
||||
} catch (e) {
|
||||
debugPrint('Failed to postMessage: $e');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Final fallback: regex or manual search in fullUrl
|
||||
if (redirectUri == null) {
|
||||
for (final key in ['redirect_uri=', 'redirect_url=']) {
|
||||
if (fullUrl.contains(key)) {
|
||||
final start = fullUrl.indexOf(key) + key.length;
|
||||
var end = fullUrl.indexOf('&', start);
|
||||
if (end == -1) end = fullUrl.length;
|
||||
final raw = fullUrl.substring(start, end);
|
||||
try {
|
||||
redirectUri = Uri.decodeComponent(raw);
|
||||
break;
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (redirectUri != null && redirectUri.isNotEmpty) {
|
||||
// Redirection flow
|
||||
final target = Uri.parse(redirectUri);
|
||||
final query = Map<String, String>.from(target.queryParameters);
|
||||
query['token'] = effectiveToken;
|
||||
final finalUri = target.replace(queryParameters: query);
|
||||
|
||||
debugPrint('Redirecting to: ${finalUri.toString()}');
|
||||
html.window.location.href = finalUri.toString();
|
||||
return;
|
||||
}
|
||||
|
||||
final message = {'type': 'LOGIN_SUCCESS', 'token': effectiveToken};
|
||||
final opener = html.window.opener;
|
||||
|
||||
if (opener != null) {
|
||||
try {
|
||||
opener.postMessage(message, '*');
|
||||
debugPrint('Sent login success message to opener');
|
||||
} catch (e) {
|
||||
debugPrint('Failed to postMessage: $e');
|
||||
}
|
||||
|
||||
// Close the popup after a short delay to ensure message sending
|
||||
Timer(const Duration(milliseconds: 500), () {
|
||||
html.window.close();
|
||||
try {
|
||||
html.window.close();
|
||||
} catch (e) {
|
||||
debugPrint('Failed to close window: $e');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Should not happen given isPopup check, but as fallback:
|
||||
debugPrint('No opener found during popup flow.');
|
||||
}
|
||||
|
||||
// No opener and no redirect: fall back to local navigation
|
||||
debugPrint('No opener found. Redirecting to /.');
|
||||
html.window.location.href = '/';
|
||||
}
|
||||
|
||||
bool implIsPopup() {
|
||||
|
||||
@@ -15,7 +15,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
|
||||
final TextEditingController _emailController = TextEditingController();
|
||||
final TextEditingController _phoneController = TextEditingController();
|
||||
final TextEditingController _nameController = TextEditingController();
|
||||
|
||||
|
||||
bool _isLoading = false;
|
||||
bool _isAuthorized = false;
|
||||
String? _verifiedAdminPassword;
|
||||
@@ -28,7 +28,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
|
||||
|
||||
Future<void> _verifyAccess() async {
|
||||
final passwordController = TextEditingController();
|
||||
|
||||
|
||||
// Show blocking dialog
|
||||
final String? inputPassword = await showDialog<String>(
|
||||
context: context,
|
||||
@@ -86,7 +86,10 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Invalid Password. Access Denied.'), backgroundColor: Colors.red),
|
||||
const SnackBar(
|
||||
content: Text('Invalid Password. Access Denied.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
context.go('/'); // Kick out
|
||||
}
|
||||
@@ -116,7 +119,9 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
String? phone = _phoneController.text.trim().isEmpty ? null : _phoneController.text.trim();
|
||||
String? phone = _phoneController.text.trim().isEmpty
|
||||
? null
|
||||
: _phoneController.text.trim();
|
||||
if (phone != null && !phone.contains('@')) {
|
||||
phone = phone.replaceAll(RegExp(r'[-\s]'), '');
|
||||
if (phone.startsWith('010')) {
|
||||
@@ -128,14 +133,21 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
|
||||
await AuthProxyService.createUser(
|
||||
loginId: loginId,
|
||||
adminPassword: _verifiedAdminPassword!,
|
||||
email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(),
|
||||
email: _emailController.text.trim().isEmpty
|
||||
? null
|
||||
: _emailController.text.trim(),
|
||||
phone: phone,
|
||||
displayName: _nameController.text.trim().isEmpty ? null : _nameController.text.trim(),
|
||||
displayName: _nameController.text.trim().isEmpty
|
||||
? null
|
||||
: _nameController.text.trim(),
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('User created successfully!'), backgroundColor: Colors.green),
|
||||
const SnackBar(
|
||||
content: Text('User created successfully!'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
_formKey.currentState!.reset();
|
||||
_loginIdController.clear();
|
||||
@@ -158,9 +170,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
// Hide content until authorized
|
||||
if (!_isAuthorized) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
@@ -186,7 +196,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
|
||||
TextFormField(
|
||||
controller: _loginIdController,
|
||||
decoration: const InputDecoration(
|
||||
@@ -194,10 +204,12 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
|
||||
border: OutlineInputBorder(),
|
||||
helperText: "Unique identifier (Email or Phone)",
|
||||
),
|
||||
validator: (value) => value == null || value.isEmpty ? 'Please enter Login ID' : null,
|
||||
validator: (value) => value == null || value.isEmpty
|
||||
? 'Please enter Login ID'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
@@ -207,7 +219,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
decoration: const InputDecoration(
|
||||
@@ -217,7 +229,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
TextFormField(
|
||||
controller: _phoneController,
|
||||
decoration: const InputDecoration(
|
||||
@@ -228,14 +240,14 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
|
||||
FilledButton(
|
||||
onPressed: _isLoading ? null : _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
child: _isLoading
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
child: _isLoading
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: const Text("Create User"),
|
||||
),
|
||||
],
|
||||
@@ -245,4 +257,4 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ class UserManagementScreen extends StatefulWidget {
|
||||
State<UserManagementScreen> createState() => _UserManagementScreenState();
|
||||
}
|
||||
|
||||
class _UserManagementScreenState extends State<UserManagementScreen> with SingleTickerProviderStateMixin {
|
||||
class _UserManagementScreenState extends State<UserManagementScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
bool _isAuthorized = false;
|
||||
String? _verifiedAdminPassword;
|
||||
@@ -23,7 +24,8 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
||||
|
||||
// --- Create Tab Controllers ---
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final TextEditingController _createLoginIdController = TextEditingController();
|
||||
final TextEditingController _createLoginIdController =
|
||||
TextEditingController();
|
||||
final TextEditingController _createEmailController = TextEditingController();
|
||||
final TextEditingController _createPhoneController = TextEditingController();
|
||||
final TextEditingController _createNameController = TextEditingController();
|
||||
@@ -50,7 +52,7 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
||||
// --- Authentication ---
|
||||
Future<void> _verifyAccess() async {
|
||||
final passwordController = TextEditingController();
|
||||
|
||||
|
||||
final String? inputPassword = await showDialog<String>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
@@ -64,15 +66,24 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
||||
TextField(
|
||||
controller: passwordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(labelText: "Password", border: OutlineInputBorder()),
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Password",
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
autofocus: true,
|
||||
onSubmitted: (value) => Navigator.pop(context, value),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, null), child: const Text("Cancel")),
|
||||
FilledButton(onPressed: () => Navigator.pop(context, passwordController.text), child: const Text("Enter")),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, null),
|
||||
child: const Text("Cancel"),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, passwordController.text),
|
||||
child: const Text("Enter"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -96,7 +107,12 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Invalid Password'), backgroundColor: Colors.red));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Invalid Password'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
context.go('/');
|
||||
}
|
||||
}
|
||||
@@ -107,7 +123,10 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
||||
if (_verifiedAdminPassword == null) return;
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final users = await AuthProxyService.listUsers(_verifiedAdminPassword!, query: query);
|
||||
final users = await AuthProxyService.listUsers(
|
||||
_verifiedAdminPassword!,
|
||||
query: query,
|
||||
);
|
||||
setState(() => _users = users);
|
||||
} catch (e) {
|
||||
_showError("Failed to load users: $e");
|
||||
@@ -125,18 +144,23 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
||||
|
||||
Future<void> _deleteUser(String loginId) async {
|
||||
if (_verifiedAdminPassword == null) return;
|
||||
|
||||
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text("Delete User"),
|
||||
content: Text("Are you sure you want to delete $loginId? This cannot be undone."),
|
||||
content: Text(
|
||||
"Are you sure you want to delete $loginId? This cannot be undone.",
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("Cancel")),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text("Cancel"),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: Colors.red),
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text("Delete")
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text("Delete"),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -158,11 +182,17 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
||||
|
||||
Future<void> _toggleStatus(String loginId, String currentStatus) async {
|
||||
if (_verifiedAdminPassword == null) return;
|
||||
final newStatus = (currentStatus == "enabled" || currentStatus == "active") ? "disabled" : "enabled";
|
||||
|
||||
final newStatus = (currentStatus == "enabled" || currentStatus == "active")
|
||||
? "disabled"
|
||||
: "enabled";
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
await AuthProxyService.updateUserStatus(_verifiedAdminPassword!, loginId, newStatus);
|
||||
await AuthProxyService.updateUserStatus(
|
||||
_verifiedAdminPassword!,
|
||||
loginId,
|
||||
newStatus,
|
||||
);
|
||||
_showSuccess("User status updated to $newStatus");
|
||||
_loadUsers(query: _searchController.text);
|
||||
} catch (e) {
|
||||
@@ -174,14 +204,20 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
||||
|
||||
Future<void> _editUser(Map user) async {
|
||||
if (_verifiedAdminPassword == null) return;
|
||||
|
||||
|
||||
final loginIDs = (user['loginIds'] as List?) ?? [];
|
||||
final loginId = loginIDs.isNotEmpty ? loginIDs.first.toString() : "";
|
||||
if (loginId.isEmpty) return;
|
||||
|
||||
final nameController = TextEditingController(text: user['name'] ?? user['user']?['name'] ?? "");
|
||||
final emailController = TextEditingController(text: user['user']?['email'] ?? "");
|
||||
final phoneController = TextEditingController(text: user['user']?['phone'] ?? "");
|
||||
final nameController = TextEditingController(
|
||||
text: user['name'] ?? user['user']?['name'] ?? "",
|
||||
);
|
||||
final emailController = TextEditingController(
|
||||
text: user['user']?['email'] ?? "",
|
||||
);
|
||||
final phoneController = TextEditingController(
|
||||
text: user['user']?['phone'] ?? "",
|
||||
);
|
||||
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
@@ -190,14 +226,29 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(controller: nameController, decoration: const InputDecoration(labelText: "Name")),
|
||||
TextField(controller: emailController, decoration: const InputDecoration(labelText: "Email")),
|
||||
TextField(controller: phoneController, decoration: const InputDecoration(labelText: "Phone")),
|
||||
TextField(
|
||||
controller: nameController,
|
||||
decoration: const InputDecoration(labelText: "Name"),
|
||||
),
|
||||
TextField(
|
||||
controller: emailController,
|
||||
decoration: const InputDecoration(labelText: "Email"),
|
||||
),
|
||||
TextField(
|
||||
controller: phoneController,
|
||||
decoration: const InputDecoration(labelText: "Phone"),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("Cancel")),
|
||||
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text("Save")),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text("Cancel"),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text("Save"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -206,7 +257,9 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
String? phone = phoneController.text.trim().isEmpty ? null : phoneController.text.trim();
|
||||
String? phone = phoneController.text.trim().isEmpty
|
||||
? null
|
||||
: phoneController.text.trim();
|
||||
if (phone != null && !phone.contains('@')) {
|
||||
phone = phone.replaceAll(RegExp(r'[-\s]'), '');
|
||||
if (phone.startsWith('010')) {
|
||||
@@ -246,7 +299,9 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
||||
}
|
||||
}
|
||||
|
||||
String? phone = _createPhoneController.text.trim().isEmpty ? null : _createPhoneController.text.trim();
|
||||
String? phone = _createPhoneController.text.trim().isEmpty
|
||||
? null
|
||||
: _createPhoneController.text.trim();
|
||||
if (phone != null && !phone.contains('@')) {
|
||||
phone = phone.replaceAll(RegExp(r'[-\s]'), '');
|
||||
if (phone.startsWith('010')) {
|
||||
@@ -258,9 +313,13 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
||||
await AuthProxyService.createUser(
|
||||
loginId: loginId,
|
||||
adminPassword: _verifiedAdminPassword!,
|
||||
email: _createEmailController.text.trim().isEmpty ? null : _createEmailController.text.trim(),
|
||||
email: _createEmailController.text.trim().isEmpty
|
||||
? null
|
||||
: _createEmailController.text.trim(),
|
||||
phone: phone,
|
||||
displayName: _createNameController.text.trim().isEmpty ? null : _createNameController.text.trim(),
|
||||
displayName: _createNameController.text.trim().isEmpty
|
||||
? null
|
||||
: _createNameController.text.trim(),
|
||||
);
|
||||
|
||||
_showSuccess("User created successfully");
|
||||
@@ -269,11 +328,10 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
||||
_createEmailController.clear();
|
||||
_createPhoneController.clear();
|
||||
_createNameController.clear();
|
||||
|
||||
|
||||
// Switch to list tab and reload
|
||||
_tabController.animateTo(0);
|
||||
_loadUsers();
|
||||
|
||||
} catch (e) {
|
||||
_showError("Error: $e");
|
||||
} finally {
|
||||
@@ -284,12 +342,16 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
||||
// --- UI Helpers ---
|
||||
void _showError(String msg) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.red));
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.red));
|
||||
}
|
||||
|
||||
void _showSuccess(String msg) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.green));
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.green));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -315,10 +377,7 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildUserListTab(),
|
||||
_buildCreateUserTab(),
|
||||
],
|
||||
children: [_buildUserListTab(), _buildCreateUserTab()],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -341,32 +400,49 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _users.isEmpty
|
||||
? const Center(child: Text("No users found."))
|
||||
child: _users.isEmpty
|
||||
? const Center(child: Text("No users found."))
|
||||
: ListView.separated(
|
||||
itemCount: _users.length,
|
||||
separatorBuilder: (context, index) => const Divider(),
|
||||
itemBuilder: (context, index) {
|
||||
final user = _users[index];
|
||||
// 일부 응답은 최상위 또는 user 하위에 필드를 포함합니다.
|
||||
|
||||
|
||||
final loginIDs = (user['loginIds'] as List?) ?? [];
|
||||
final loginId = loginIDs.isNotEmpty ? loginIDs.first.toString() : "Unknown ID";
|
||||
final name = user['name'] ?? user['user']?['name'] ?? "No Name";
|
||||
final loginId = loginIDs.isNotEmpty
|
||||
? loginIDs.first.toString()
|
||||
: "Unknown ID";
|
||||
final name =
|
||||
user['name'] ?? user['user']?['name'] ?? "No Name";
|
||||
final status = user['status'] ?? "unknown";
|
||||
final isEnabled = status == "enabled" || status == "active";
|
||||
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: isEnabled ? Colors.green.shade100 : Colors.grey.shade300,
|
||||
child: Icon(Icons.person, color: isEnabled ? Colors.green : Colors.grey),
|
||||
backgroundColor: isEnabled
|
||||
? Colors.green.shade100
|
||||
: Colors.grey.shade300,
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
color: isEnabled ? Colors.green : Colors.grey,
|
||||
),
|
||||
),
|
||||
title: Text(name),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(loginId, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
Text("Status: $status", style: TextStyle(color: isEnabled ? Colors.green : Colors.red, fontSize: 12)),
|
||||
Text(
|
||||
loginId,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
"Status: $status",
|
||||
style: TextStyle(
|
||||
color: isEnabled ? Colors.green : Colors.red,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
@@ -378,7 +454,10 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
||||
onPressed: () => _editUser(user),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(isEnabled ? Icons.block : Icons.check_circle, color: isEnabled ? Colors.orange : Colors.green),
|
||||
icon: Icon(
|
||||
isEnabled ? Icons.block : Icons.check_circle,
|
||||
color: isEnabled ? Colors.orange : Colors.green,
|
||||
),
|
||||
tooltip: isEnabled ? "Disable User" : "Enable User",
|
||||
onPressed: () => _toggleStatus(loginId, status),
|
||||
),
|
||||
@@ -417,27 +496,44 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
||||
border: OutlineInputBorder(),
|
||||
helperText: "Unique identifier (Email or Phone)",
|
||||
),
|
||||
validator: (value) => value == null || value.isEmpty ? 'Please enter Login ID' : null,
|
||||
validator: (value) => value == null || value.isEmpty
|
||||
? 'Please enter Login ID'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _createNameController,
|
||||
decoration: const InputDecoration(labelText: "Display Name", border: OutlineInputBorder(), prefixIcon: Icon(Icons.person)),
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Display Name",
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.person),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _createEmailController,
|
||||
decoration: const InputDecoration(labelText: "Email", border: OutlineInputBorder(), prefixIcon: Icon(Icons.email)),
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Email",
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.email),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _createPhoneController,
|
||||
decoration: const InputDecoration(labelText: "Phone Number", border: OutlineInputBorder(), prefixIcon: Icon(Icons.phone), helperText: "010-xxxx-xxxx"),
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Phone Number",
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.phone),
|
||||
helperText: "010-xxxx-xxxx",
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
FilledButton(
|
||||
onPressed: _isLoading ? null : _createUserSubmit,
|
||||
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
child: const Text("Create User"),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -134,13 +134,16 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
|
||||
style: TextStyle(color: Colors.grey.shade600),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
|
||||
if (_message != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Text(
|
||||
_message!,
|
||||
style: TextStyle(color: _success ? Colors.green : Colors.red, fontWeight: FontWeight.bold),
|
||||
style: TextStyle(
|
||||
color: _success ? Colors.green : Colors.red,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
@@ -155,7 +158,7 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
|
||||
backgroundColor: Colors.blue,
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
if (!isLoggedIn && !_success)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
|
||||
@@ -17,7 +17,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
bool _isLoading = true;
|
||||
bool _isSubmitting = false;
|
||||
String? _error;
|
||||
|
||||
|
||||
// 사용자가 선택한 스코프 목록
|
||||
final Set<String> _selectedScopes = {};
|
||||
|
||||
@@ -41,8 +41,10 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
|
||||
Future<void> _fetchConsentInfo() async {
|
||||
try {
|
||||
final info = await AuthProxyService.getConsentInfo(widget.consentChallenge);
|
||||
|
||||
final info = await AuthProxyService.getConsentInfo(
|
||||
widget.consentChallenge,
|
||||
);
|
||||
|
||||
// [Skip Logic] 백엔드에서 자동 승인되어 리다이렉트 URL이 온 경우 즉시 이동
|
||||
if (info['redirectTo'] != null) {
|
||||
webWindow.redirectTo(info['redirectTo']);
|
||||
@@ -52,19 +54,20 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
// 백엔드에서 전달받은 커스텀 스코프 정보(scope_details) 적용
|
||||
if (info['scope_details'] != null) {
|
||||
final details = info['scope_details'] as Map<String, dynamic>;
|
||||
|
||||
|
||||
details.forEach((scope, detail) {
|
||||
if (detail is Map<String, dynamic>) {
|
||||
// 설명 업데이트
|
||||
if (detail['description'] != null && detail['description'].toString().isNotEmpty) {
|
||||
if (detail['description'] != null &&
|
||||
detail['description'].toString().isNotEmpty) {
|
||||
_scopeDescriptions[scope] = detail['description'].toString();
|
||||
}
|
||||
// 필수 여부 업데이트
|
||||
if (detail['mandatory'] == true) {
|
||||
_mandatoryScopes.add(scope);
|
||||
} else {
|
||||
// openid는 기본적으로 필수지만 설정에서 굳이 껐다면?
|
||||
// 안전을 위해 openid는 항상 필수로 유지하는 것이 좋지만,
|
||||
// openid는 기본적으로 필수지만 설정에서 굳이 껐다면?
|
||||
// 안전을 위해 openid는 항상 필수로 유지하는 것이 좋지만,
|
||||
// 여기서는 서버 설정을 존중하되 openid는 예외처리 할 수도 있음.
|
||||
// 우선 서버 설정이 있으면 반영 (단, openid는 제거하지 않음)
|
||||
if (scope != 'openid') {
|
||||
@@ -76,7 +79,8 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
}
|
||||
|
||||
// 초기 선택 상태 설정: 모든 요청된 스코프를 기본 선택
|
||||
final requestedScopes = (info['requested_scope'] as List<dynamic>?)?.cast<String>() ?? [];
|
||||
final requestedScopes =
|
||||
(info['requested_scope'] as List<dynamic>?)?.cast<String>() ?? [];
|
||||
_selectedScopes.addAll(requestedScopes);
|
||||
|
||||
setState(() {
|
||||
@@ -102,7 +106,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
widget.consentChallenge,
|
||||
grantScope: _selectedScopes.toList(),
|
||||
);
|
||||
|
||||
|
||||
if (result['redirectTo'] != null) {
|
||||
webWindow.redirectTo(result['redirectTo']);
|
||||
} else {
|
||||
@@ -142,7 +146,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
if (confirmed == true) {
|
||||
setState(() => _isSubmitting = true);
|
||||
try {
|
||||
final resp = await AuthProxyService.rejectConsent(widget.consentChallenge);
|
||||
final resp = await AuthProxyService.rejectConsent(
|
||||
widget.consentChallenge,
|
||||
);
|
||||
final redirectTo = resp['redirectTo'];
|
||||
if (redirectTo != null) {
|
||||
webWindow.redirectTo(redirectTo);
|
||||
@@ -152,9 +158,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
} catch (e) {
|
||||
setState(() => _isSubmitting = false);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('취소 처리 중 오류가 발생했습니다: $e')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('취소 처리 중 오류가 발생했습니다: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,13 +170,13 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
// 배경색을 약간 어둡게 처리하거나, 전체적인 테마 색상을 사용
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.grey[100],
|
||||
backgroundColor: Colors.grey[100],
|
||||
body: Center(
|
||||
child: _isLoading
|
||||
? const CircularProgressIndicator()
|
||||
: _error != null
|
||||
? _buildErrorCard()
|
||||
: _buildConsentCard(context),
|
||||
? _buildErrorCard()
|
||||
: _buildConsentCard(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -196,7 +202,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
final clientName = _consentInfo?['client']?['client_name'] ?? '알 수 없는 앱';
|
||||
final clientId = _consentInfo?['client']?['client_id'] ?? '-';
|
||||
final clientLogo = _consentInfo?['client']?['logo_uri'];
|
||||
final requestedScopes = (_consentInfo?['requested_scope'] as List<dynamic>?)?.cast<String>() ?? [];
|
||||
final requestedScopes =
|
||||
(_consentInfo?['requested_scope'] as List<dynamic>?)?.cast<String>() ??
|
||||
[];
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Container(
|
||||
@@ -204,7 +212,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
margin: const EdgeInsets.all(16),
|
||||
child: Card(
|
||||
elevation: 8,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Column(
|
||||
@@ -235,7 +245,8 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (clientLogo != null && clientLogo.toString().isNotEmpty)
|
||||
if (clientLogo != null &&
|
||||
clientLogo.toString().isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: CircleAvatar(
|
||||
@@ -286,7 +297,10 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
children: [
|
||||
const Text(
|
||||
'요청된 권한',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'총 ${requestedScopes.length}개',
|
||||
@@ -354,7 +368,10 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
)
|
||||
: const Text(
|
||||
'동의하고 계속하기',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
@@ -18,22 +18,19 @@ String _envOrDefault(String key, String fallback) {
|
||||
String get _baseUrl => _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
|
||||
|
||||
Future<AuditPage> _fetchAuthTimelinePage({String? cursor}) async {
|
||||
final queryParameters = <String, String>{
|
||||
'limit': '20',
|
||||
};
|
||||
final queryParameters = <String, String>{'limit': '20'};
|
||||
if (cursor != null && cursor.isNotEmpty) {
|
||||
queryParameters['cursor'] = cursor;
|
||||
}
|
||||
|
||||
final url = Uri.parse('$_baseUrl/api/v1/audit/auth/timeline')
|
||||
.replace(queryParameters: queryParameters);
|
||||
final url = Uri.parse(
|
||||
'$_baseUrl/api/v1/audit/auth/timeline',
|
||||
).replace(queryParameters: queryParameters);
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
final token = AuthTokenStore.getToken();
|
||||
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
@@ -60,10 +57,6 @@ Future<AuditPage> _fetchAuthTimelinePage({String? cursor}) async {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
typedef AuthTimelineFetcher = Future<AuditPage> Function({String? cursor});
|
||||
|
||||
final authTimelineFetcherProvider = Provider<AuthTimelineFetcher>((ref) {
|
||||
@@ -86,11 +79,11 @@ class AuthTimelineState {
|
||||
});
|
||||
|
||||
const AuthTimelineState.initial()
|
||||
: items = const [],
|
||||
nextCursor = null,
|
||||
isLoading = false,
|
||||
isLoadingMore = false,
|
||||
error = null;
|
||||
: items = const [],
|
||||
nextCursor = null,
|
||||
isLoading = false,
|
||||
isLoadingMore = false,
|
||||
error = null;
|
||||
|
||||
AuthTimelineState copyWith({
|
||||
List<AuditLogEntry>? items,
|
||||
@@ -187,6 +180,7 @@ class AuthTimelineNotifier extends Notifier<AuthTimelineState> {
|
||||
}
|
||||
}
|
||||
|
||||
final authTimelineProvider = NotifierProvider<AuthTimelineNotifier, AuthTimelineState>(
|
||||
AuthTimelineNotifier.new,
|
||||
);
|
||||
final authTimelineProvider =
|
||||
NotifierProvider<AuthTimelineNotifier, AuthTimelineState>(
|
||||
AuthTimelineNotifier.new,
|
||||
);
|
||||
|
||||
@@ -64,14 +64,12 @@ class LinkedRpsNotifier extends AsyncNotifier<List<LinkedRp>> {
|
||||
try {
|
||||
final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
|
||||
final url = Uri.parse('$baseUrl/api/v1/user/rp/linked');
|
||||
|
||||
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
final token = AuthTokenStore.getToken();
|
||||
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
@@ -85,7 +83,7 @@ class LinkedRpsNotifier extends AsyncNotifier<List<LinkedRp>> {
|
||||
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final items = (body['items'] as List?) ?? [];
|
||||
|
||||
|
||||
return items
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(LinkedRp.fromJson)
|
||||
@@ -106,6 +104,7 @@ class LinkedRpsNotifier extends AsyncNotifier<List<LinkedRp>> {
|
||||
}
|
||||
}
|
||||
|
||||
final linkedRpsProvider = AsyncNotifierProvider<LinkedRpsNotifier, List<LinkedRp>>(() {
|
||||
return LinkedRpsNotifier();
|
||||
});
|
||||
final linkedRpsProvider =
|
||||
AsyncNotifierProvider<LinkedRpsNotifier, List<LinkedRp>>(() {
|
||||
return LinkedRpsNotifier();
|
||||
});
|
||||
|
||||
@@ -21,12 +21,7 @@ class Tenant {
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'slug': slug,
|
||||
'description': description,
|
||||
};
|
||||
return {'id': id, 'name': name, 'slug': slug, 'description': description};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +57,9 @@ class UserProfile {
|
||||
department: json['department'] ?? '',
|
||||
affiliationType: json['affiliationType'] ?? '',
|
||||
companyCode: json['companyCode'] ?? '',
|
||||
metadata: json['metadata'] != null ? Map<String, dynamic>.from(json['metadata']) : null,
|
||||
metadata: json['metadata'] != null
|
||||
? Map<String, dynamic>.from(json['metadata'])
|
||||
: null,
|
||||
tenant: json['tenant'] != null ? Tenant.fromJson(json['tenant']) : null,
|
||||
);
|
||||
}
|
||||
@@ -81,11 +78,7 @@ class UserProfile {
|
||||
};
|
||||
}
|
||||
|
||||
UserProfile copyWith({
|
||||
String? name,
|
||||
String? phone,
|
||||
String? department,
|
||||
}) {
|
||||
UserProfile copyWith({String? name, String? phone, String? department}) {
|
||||
return UserProfile(
|
||||
id: id,
|
||||
email: email,
|
||||
|
||||
@@ -13,7 +13,8 @@ class ProfileRepository {
|
||||
return dotenv.env[key] ?? fallback;
|
||||
}
|
||||
|
||||
static String get _baseUrl => _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
|
||||
static String get _baseUrl =>
|
||||
_envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
|
||||
|
||||
// Helper to get session token
|
||||
static Future<String?> _getToken() async {
|
||||
@@ -31,9 +32,7 @@ class ProfileRepository {
|
||||
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/me');
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
@@ -67,9 +66,7 @@ class ProfileRepository {
|
||||
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/me');
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
@@ -105,9 +102,7 @@ class ProfileRepository {
|
||||
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/me/send-code');
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
@@ -142,9 +137,7 @@ class ProfileRepository {
|
||||
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/me/password');
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
@@ -179,9 +172,7 @@ class ProfileRepository {
|
||||
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/me/verify-code');
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
|
||||
@@ -32,20 +32,20 @@ class ProfileNotifier extends AsyncNotifier<UserProfile?> {
|
||||
}) async {
|
||||
// Show loading state
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
|
||||
// Perform update and then re-fetch profile
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(profileRepositoryProvider).updateMyProfile(
|
||||
name: name,
|
||||
phone: phone,
|
||||
department: department,
|
||||
);
|
||||
await ref
|
||||
.read(profileRepositoryProvider)
|
||||
.updateMyProfile(name: name, phone: phone, department: department);
|
||||
return _fetch();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Provider definition
|
||||
final profileProvider = AsyncNotifierProvider<ProfileNotifier, UserProfile?>(() {
|
||||
return ProfileNotifier();
|
||||
});
|
||||
final profileProvider = AsyncNotifierProvider<ProfileNotifier, UserProfile?>(
|
||||
() {
|
||||
return ProfileNotifier();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -4,11 +4,7 @@ class ProfileInfoRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const ProfileInfoRow({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
const ProfileInfoRow({super.key, required this.label, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -22,6 +22,10 @@ log_format json_combined escape=json
|
||||
server {
|
||||
listen 5000;
|
||||
include /etc/nginx/mime.types;
|
||||
types {
|
||||
application/javascript mjs;
|
||||
application/wasm wasm;
|
||||
}
|
||||
|
||||
error_log /dev/stderr warn;
|
||||
access_log /var/log/nginx/access.log json_combined;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<!--
|
||||
<head>
|
||||
<!--
|
||||
If you are serving your web app in a path other than the root, change the
|
||||
href value below to reflect the base path you are serving from.
|
||||
|
||||
@@ -14,29 +14,29 @@
|
||||
This is a placeholder for base href that will be replaced by the value of
|
||||
the `--base-href` argument provided to `flutter build`.
|
||||
-->
|
||||
<base href="/" />
|
||||
<base href="/" />
|
||||
|
||||
<meta charset="UTF-8" />
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible" />
|
||||
<meta name="description" content="바론 SW 포털" />
|
||||
|
||||
<!-- iOS meta tags & icons -->
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||
<meta name="apple-mobile-web-app-title" content="Baron 로그인" />
|
||||
<link rel="apple-touch-icon" href="icons/Icon-192.png" />
|
||||
<!-- iOS meta tags & icons -->
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||
<meta name="apple-mobile-web-app-title" content="Baron 로그인" />
|
||||
<link rel="apple-touch-icon" href="icons/Icon-192.png" />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
|
||||
<title>Baron 로그인</title>
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/icon?family=Material+Icons"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<script src="flutter_bootstrap.js" async></script>
|
||||
</body>
|
||||
<title>Baron 로그인</title>
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/icon?family=Material+Icons"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<script src="flutter_bootstrap.js" async></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
{
|
||||
"name": "Baron 로그인",
|
||||
"short_name": "Baron 로그인",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
"theme_color": "#0175C2",
|
||||
"description": "Baron 로그인 사용자 포털.",
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/Icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
"name": "Baron 로그인",
|
||||
"short_name": "Baron 로그인",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
"theme_color": "#0175C2",
|
||||
"description": "Baron 로그인 사용자 포털.",
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/Icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user