diff --git a/apps/pyconkr-admin/src/components/pages/external_api/google_oauth2_editor.tsx b/apps/pyconkr-admin/src/components/pages/external_api/google_oauth2_editor.tsx new file mode 100644 index 0000000..5358720 --- /dev/null +++ b/apps/pyconkr-admin/src/components/pages/external_api/google_oauth2_editor.tsx @@ -0,0 +1,105 @@ +import { useBackendAdminClient, useIssueGoogleOAuth2AccessTokenMutation } from "@frontend/common/src/hooks/useAdminAPI"; +import { GoogleOAuth2AccessTokenResponseSchema } from "@frontend/common/src/schemas/backendAdminAPI"; +import { VpnKey } from "@mui/icons-material"; +import { Box, Button, Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography } from "@mui/material"; +import * as React from "react"; +import { useParams } from "react-router-dom"; + +import { addErrorSnackbar, addSnackbar } from "../../../utils/snackbar"; +import { AdminEditor } from "../../layouts/admin_editor"; + +type CachedToken = { issuedAt: number; response: GoogleOAuth2AccessTokenResponseSchema }; +type TokenState = { issuedAt: Date; response: GoogleOAuth2AccessTokenResponseSchema }; + +const LOCAL_STORAGE_KEY_PREFIX = "googleoauth2-access-token-"; + +const buildLocalStorageKey = (id: string) => `${LOCAL_STORAGE_KEY_PREFIX}${id}`; + +const loadCachedToken = (id: string): TokenState | null => { + try { + const raw = window.localStorage.getItem(buildLocalStorageKey(id)); + if (!raw) return null; + const parsed = JSON.parse(raw) as CachedToken; + const expiryMs = parsed.issuedAt + (parsed.response.expires_in ?? 0) * 1000; + if (expiryMs <= Date.now()) { + window.localStorage.removeItem(buildLocalStorageKey(id)); + return null; + } + return { issuedAt: new Date(parsed.issuedAt), response: parsed.response }; + } catch { + return null; + } +}; + +export const AdminGoogleOAuth2Editor: React.FC = () => { + const { id } = useParams<{ id?: string }>(); + const backendAdminClient = useBackendAdminClient(); + const [tokenState, setTokenState] = React.useState(() => (id ? loadCachedToken(id) : null)); + + const accessTokenMutation = useIssueGoogleOAuth2AccessTokenMutation(backendAdminClient, id ?? ""); + + const handleIssueToken = () => { + if (!id || accessTokenMutation.isPending) return; + accessTokenMutation.mutate(undefined, { + onSuccess: (response) => { + const issuedAt = new Date(); + setTokenState({ issuedAt, response }); + window.localStorage.setItem(buildLocalStorageKey(id), JSON.stringify({ issuedAt: issuedAt.getTime(), response })); + addSnackbar("Access Token을 발급했습니다.", "success"); + }, + onError: addErrorSnackbar, + }); + }; + + const renderValue = (key: string, value: unknown): React.ReactNode => { + if (Array.isArray(value)) { + return ( + + {value.map((v, idx) => ( +
  • {String(v)}
  • + ))} +
    + ); + } + if (value == null) return ""; + if (key === "expires_in" && typeof value === "number" && tokenState) { + return new Date(tokenState.issuedAt.getTime() + value * 1000).toLocaleString(); + } + return String(value); + }; + + return ( + + {id && ( + + + {tokenState && ( + + + 발급 결과 + + + + + 필드 + + + + + {Object.entries(tokenState.response).map(([key, value]) => ( + + {key} + {renderValue(key, value)} + + ))} + +
    +
    + )} +
    + )} +
    + ); +}; diff --git a/apps/pyconkr-admin/src/components/pages/notification/email_template_editor.tsx b/apps/pyconkr-admin/src/components/pages/notification/email_template_editor.tsx new file mode 100644 index 0000000..17ce1d3 --- /dev/null +++ b/apps/pyconkr-admin/src/components/pages/notification/email_template_editor.tsx @@ -0,0 +1,204 @@ +import { + useBackendAdminClient, + useCreateMutation, + useRenderTemplateMutation, + useRetrieveQuery, + useUpdateMutation, +} from "@frontend/common/src/hooks/useAdminAPI"; +import { Add, Close, Save, Visibility } from "@mui/icons-material"; +import { Box, Button, Chip, CircularProgress, IconButton, Stack, TextField, Typography } from "@mui/material"; +import { ErrorBoundary, Suspense } from "@suspensive/react"; +import * as React from "react"; +import { useNavigate, useParams } from "react-router-dom"; + +import { addErrorSnackbar, addSnackbar } from "../../../utils/snackbar"; +import { BackendAdminSignInGuard } from "../../elements/admin_signin_guard"; +import { ErrorFallback } from "../../elements/error_fallback"; + +const APP = "notification/email"; +const RESOURCE = "template"; + +type EmailTemplateFormData = { + code: string; + title: string; + description: string; + data: string; + sent_from: string; +}; + +type EmailTemplateSchema = EmailTemplateFormData & { + id: string; + created_at: string; + created_by: string | null; + updated_at: string; + updated_by: string | null; + deleted_at: string | null; + deleted_by: string | null; + str_repr: string; + template_variables: string[]; +}; + +const isValidJson = (s: string): boolean => { + if (!s.trim()) return true; + try { + JSON.parse(s); + return true; + } catch { + return false; + } +}; + +const InnerAdminEmailTemplateEditor: React.FC = ErrorBoundary.with( + { fallback: ErrorFallback }, + Suspense.with({ fallback: }, () => { + const navigate = useNavigate(); + const { id } = useParams<{ id?: string }>(); + const backendAdminClient = useBackendAdminClient(); + const { data: retrievedData } = useRetrieveQuery(backendAdminClient, APP, RESOURCE, id || ""); + + const [formData, setFormData] = React.useState(() => ({ + code: retrievedData?.code ?? "", + title: retrievedData?.title ?? "", + description: retrievedData?.description ?? "", + data: retrievedData?.data ?? "", + sent_from: retrievedData?.sent_from ?? "", + })); + const [contextJson, setContextJson] = React.useState("{}"); + + const createMutation = useCreateMutation(backendAdminClient, APP, RESOURCE); + const updateMutation = useUpdateMutation(backendAdminClient, APP, RESOURCE, id || ""); + const renderMutation = useRenderTemplateMutation(backendAdminClient, APP, RESOURCE); + + const setField = (key: K, value: EmailTemplateFormData[K]) => setFormData((p) => ({ ...p, [key]: value })); + const onClose = () => navigate(`/${APP}/${RESOURCE}`); + + const isPending = createMutation.isPending || updateMutation.isPending; + const jsonValid = isValidJson(contextJson); + + const handleSubmit = () => { + if (isPending) return; + if (id) { + updateMutation.mutate(formData, { + onSuccess: () => addSnackbar("수정했습니다.", "success"), + onError: addErrorSnackbar, + }); + } else { + createMutation.mutate(formData, { + onSuccess: (data) => { + addSnackbar("생성했습니다.", "success"); + const newId = (data as EmailTemplateFormData & { id?: string }).id; + if (newId) navigate(`/${APP}/${RESOURCE}/${newId}`); + }, + onError: addErrorSnackbar, + }); + } + }; + + const handlePreview = () => { + if (!id || renderMutation.isPending || !jsonValid) return; + const context = contextJson.trim() ? JSON.parse(contextJson) : {}; + renderMutation.mutate({ id, context }); + }; + + const title = `${APP.toUpperCase()} > ${RESOURCE.toUpperCase()} > ${id ? "편집: " + id : "새 객체 추가"}`; + + return ( + + + {title} + } /> + + + setField("code", e.target.value)} fullWidth /> + setField("title", e.target.value)} fullWidth /> + setField("description", e.target.value)} + multiline + minRows={2} + fullWidth + /> + setField("sent_from", e.target.value)} + helperText="발신 이메일 주소" + fullWidth + /> + setField("data", e.target.value)} + helperText="이메일 본문 (HTML/MJML). 변수는 {{ name }} 형식으로 사용." + multiline + minRows={8} + fullWidth + /> + + {retrievedData && retrievedData.template_variables.length > 0 && ( + + + 템플릿 변수 + + + {retrievedData.template_variables.map((v) => ( + + ))} + + + )} + + {id && ( + + + 미리보기 + + + setContextJson(e.target.value)} + error={!jsonValid} + helperText={jsonValid ? '예: {"name": "홍길동"}' : "유효한 JSON이 아닙니다."} + multiline + minRows={3} + fullWidth + /> + + {renderMutation.isPending ? ( + + ) : renderMutation.error ? ( + 미리보기를 불러오지 못했습니다. + ) : renderMutation.data ? ( +