Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<TokenState | null>(() => (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 (
<Box component="ul" sx={{ m: 0, pl: 2 }}>
{value.map((v, idx) => (
<li key={idx}>{String(v)}</li>
))}
</Box>
);
}
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 (
<AdminEditor app="external-api/google" resource="oauth2" id={id}>
{id && (
<Stack spacing={2} sx={{ my: 2 }}>
<Button variant="outlined" color="primary" onClick={handleIssueToken} disabled={accessTokenMutation.isPending} startIcon={<VpnKey />}>
Access Token 발급
</Button>
{tokenState && (
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
발급 결과
</Typography>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ width: "30%" }}>필드</TableCell>
<TableCell>값</TableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.entries(tokenState.response).map(([key, value]) => (
<TableRow key={key}>
<TableCell>{key}</TableCell>
<TableCell sx={{ wordBreak: "break-all" }}>{renderValue(key, value)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
)}
</Stack>
)}
</AdminEditor>
);
};
Original file line number Diff line number Diff line change
@@ -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: <CircularProgress /> }, () => {
const navigate = useNavigate();
const { id } = useParams<{ id?: string }>();
const backendAdminClient = useBackendAdminClient();
const { data: retrievedData } = useRetrieveQuery<EmailTemplateSchema>(backendAdminClient, APP, RESOURCE, id || "");

const [formData, setFormData] = React.useState<EmailTemplateFormData>(() => ({
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<EmailTemplateFormData>(backendAdminClient, APP, RESOURCE);
const updateMutation = useUpdateMutation<EmailTemplateFormData>(backendAdminClient, APP, RESOURCE, id || "");
const renderMutation = useRenderTemplateMutation(backendAdminClient, APP, RESOURCE);

const setField = <K extends keyof EmailTemplateFormData>(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 (
<Box sx={{ flexGrow: 1, width: "100%", minHeight: "100%" }}>
<Stack direction="row" justifyContent="space-between">
<Typography variant="h5">{title}</Typography>
<IconButton onClick={onClose} children={<Close />} />
</Stack>
<Stack spacing={2} sx={{ my: 2 }}>
<TextField label="code" value={formData.code} onChange={(e) => setField("code", e.target.value)} fullWidth />
<TextField label="title" value={formData.title} onChange={(e) => setField("title", e.target.value)} fullWidth />
<TextField
label="description"
value={formData.description}
onChange={(e) => setField("description", e.target.value)}
multiline
minRows={2}
fullWidth
/>
<TextField
label="sent_from"
value={formData.sent_from}
onChange={(e) => setField("sent_from", e.target.value)}
helperText="발신 이메일 주소"
fullWidth
/>
<TextField
label="data"
value={formData.data}
onChange={(e) => setField("data", e.target.value)}
helperText="이메일 본문 (HTML/MJML). 변수는 {{ name }} 형식으로 사용."
multiline
minRows={8}
fullWidth
/>

{retrievedData && retrievedData.template_variables.length > 0 && (
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
템플릿 변수
</Typography>
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap">
{retrievedData.template_variables.map((v) => (
<Chip key={v} label={v} size="small" />
))}
</Stack>
</Box>
)}

{id && (
<Box>
<Typography variant="subtitle1" sx={{ mb: 1 }}>
미리보기
</Typography>
<Stack spacing={2}>
<TextField
label="context (JSON)"
value={contextJson}
onChange={(e) => setContextJson(e.target.value)}
error={!jsonValid}
helperText={jsonValid ? '예: {"name": "홍길동"}' : "유효한 JSON이 아닙니다."}
multiline
minRows={3}
fullWidth
/>
<Button variant="outlined" startIcon={<Visibility />} onClick={handlePreview} disabled={renderMutation.isPending || !jsonValid}>
미리보기 갱신
</Button>
{renderMutation.isPending ? (
<CircularProgress size={20} />
) : renderMutation.error ? (
<Typography color="error">미리보기를 불러오지 못했습니다.</Typography>
) : renderMutation.data ? (
<iframe
srcDoc={renderMutation.data}
style={{ width: "100%", height: 500, border: "1px solid #ccc", borderRadius: 4 }}
title="이메일 템플릿 미리보기"
/>
) : (
<Typography variant="body2" color="text.secondary">
미리보기 갱신 버튼을 눌러주세요.
</Typography>
)}
</Stack>
</Box>
)}
</Stack>
<Stack direction="row" spacing={2} sx={{ justifyContent: "flex-end" }}>
<Button variant="contained" color="primary" onClick={handleSubmit} disabled={isPending} startIcon={id ? <Save /> : <Add />}>
{id ? "수정" : "새 객체 추가"}
</Button>
</Stack>
</Box>
);
})
);

export const AdminEmailTemplateEditor: React.FC = () => (
<BackendAdminSignInGuard>
<InnerAdminEmailTemplateEditor />
</BackendAdminSignInGuard>
);
Loading