diff --git a/templates/_bundled/sirsoft-admin_basic/components.json b/templates/_bundled/sirsoft-admin_basic/components.json index 27c607cd..9de12170 100644 --- a/templates/_bundled/sirsoft-admin_basic/components.json +++ b/templates/_bundled/sirsoft-admin_basic/components.json @@ -2470,6 +2470,103 @@ } } }, + { + "name": "StatCardGrid", + "type": "composite", + "description": "Responsive grid of StatCards driven by a `stats` array. Used for dashboard headers (e.g. gb7-restapi request-log stats).", + "path": "src/components/composite/StatCardGrid.tsx", + "props": { + "stats": { + "type": "array", + "required": true, + "description": "Array of StatCard props (value, label, iconName, trend, change, changeLabel)" + }, + "columns": { + "type": "number", + "required": false, + "default": 4, + "description": "Default lg-breakpoint column count" + }, + "gap": { + "type": "number", + "required": false, + "default": 4, + "description": "Tailwind gap value" + }, + "responsiveColumns": { + "type": "object", + "required": false, + "description": "Per-breakpoint overrides: { sm, md, lg, xl }" + }, + "className": { + "type": "string", + "required": false, + "default": "" + }, + "style": { + "type": "CSSProperties", + "required": false + } + } + }, + { + "name": "OneTimeSecretPanel", + "type": "composite", + "description": "Display a one-shot plaintext secret (API key, webhook signing secret) with reveal/copy/acknowledge UX. The secret is hidden behind a click, copyable to clipboard, and the panel collapses on acknowledgement so the value disappears from the DOM.", + "path": "src/components/composite/OneTimeSecretPanel.tsx", + "props": { + "value": { + "type": "string", + "required": true, + "description": "The plaintext secret. Should come from the API response that minted it; never bind to anything that re-renders after acknowledgement." + }, + "title": { + "type": "string", + "required": false, + "description": "Heading text" + }, + "warning": { + "type": "string", + "required": false, + "description": "Warning paragraph above the secret" + }, + "copyButtonLabel": { + "type": "string", + "required": false, + "default": "Copy to clipboard" + }, + "revealButtonLabel": { + "type": "string", + "required": false, + "default": "Reveal" + }, + "acknowledgeLabel": { + "type": "string", + "required": false, + "default": "I have saved this secret" + }, + "afterAcknowledge": { + "type": "object", + "required": false, + "description": "{ redirect?: string, dispatch?: { handler: string, ... } }" + }, + "initiallyHidden": { + "type": "boolean", + "required": false, + "default": true, + "description": "Whether the secret starts hidden behind a Reveal click (recommended)" + }, + "className": { + "type": "string", + "required": false, + "default": "" + }, + "style": { + "type": "CSSProperties", + "required": false + } + } + }, { "name": "StatCard", "type": "composite", diff --git a/templates/_bundled/sirsoft-admin_basic/src/components/composite/OneTimeSecretPanel.tsx b/templates/_bundled/sirsoft-admin_basic/src/components/composite/OneTimeSecretPanel.tsx new file mode 100644 index 00000000..7b4d4519 --- /dev/null +++ b/templates/_bundled/sirsoft-admin_basic/src/components/composite/OneTimeSecretPanel.tsx @@ -0,0 +1,226 @@ +import React, { useState, useCallback } from 'react'; +import { Div } from '../basic/Div'; +import { H3 } from '../basic/H3'; +import { P } from '../basic/P'; +import { Button } from '../basic/Button'; +import { Icon } from '../basic/Icon'; + +// G7Core.t() 번역 함수 참조 +const t = (key: string, params?: Record) => + (window as any).G7Core?.t?.(key, params) ?? key; + +/** + * OneTimeSecretPanel — "shown once, capture now" UX for plaintext secrets. + * + * Used by: + * - gb7-restapi POST /admin/keys (API key plaintext, returned only at issuance) + * - gb7-restapi POST /admin/keys/{id}/rotate + * - gb7-restapi POST /admin/webhooks (signing secret, same pattern) + * - gb7-restapi POST /admin/webhooks/{id}/rotate-secret + * + * The secret is rendered AFTER an explicit user click ("Reveal") so it + * doesn't sit on screen waiting to be shoulder-surfed. A "Copy to + * clipboard" button hands it to the integrator's secret manager. An + * "I have saved the key" acknowledgement gate prevents accidentally + * navigating away before capture. + * + * @example + * // Layout JSON usage (referenced from data_sources response): + * { + * "name": "OneTimeSecretPanel", + * "props": { + * "value_path": "data.plaintext", + * "title": "$t:gb7-restapi::admin.keys.one_time_title", + * "warning": "$t:gb7-restapi::admin.keys.one_time_warning", + * "copy_button_label": "$t:gb7-restapi::admin.keys.one_time_copy", + * "acknowledge_label": "$t:gb7-restapi::admin.keys.one_time_acknowledge", + * "after_acknowledge": { + * "redirect": "/admin/gb7-restapi/keys" + * } + * } + * } + */ +export interface OneTimeSecretPanelProps { + /** + * The plaintext secret to display. Should be passed via the layout + * engine's `value_path` resolution from a fresh API response — + * never bound to anything that could trigger re-render after the + * acknowledgement (which would re-show a secret the user already + * captured). + */ + value: string; + + /** Heading text. */ + title?: string; + + /** Warning paragraph above the secret. */ + warning?: string; + + /** Label for the "Copy to clipboard" button. */ + copyButtonLabel?: string; + + /** Label for the "Reveal" toggle that uncovers the secret. */ + revealButtonLabel?: string; + + /** Label for the "I have saved the key" acknowledgement button. */ + acknowledgeLabel?: string; + + /** + * What to do after the user acknowledges. `redirect` navigates to + * the URL; `dispatch` fires a layout-engine action. + */ + afterAcknowledge?: { + redirect?: string; + dispatch?: { handler: string; [k: string]: unknown }; + }; + + /** + * If true, the secret is hidden behind a "Reveal" click. Defaults + * to true — flip to false only for low-sensitivity values. + */ + initiallyHidden?: boolean; + + className?: string; + style?: React.CSSProperties; +} + +export const OneTimeSecretPanel: React.FC = ({ + value, + title, + warning, + copyButtonLabel, + revealButtonLabel, + acknowledgeLabel, + afterAcknowledge, + initiallyHidden = true, + className = '', + style, +}) => { + const [revealed, setRevealed] = useState(!initiallyHidden); + const [copied, setCopied] = useState(false); + const [acknowledged, setAcknowledged] = useState(false); + + const resolvedTitle = title ?? t('common.one_time_secret.title', { default: 'Save this secret now' }); + const resolvedWarning = warning ?? t('common.one_time_secret.warning', { default: 'This is the only time the plaintext value will be shown. Copy it into your secrets manager before continuing.' }); + const resolvedCopy = copyButtonLabel ?? t('common.one_time_secret.copy', { default: 'Copy to clipboard' }); + const resolvedReveal = revealButtonLabel ?? t('common.one_time_secret.reveal', { default: 'Reveal' }); + const resolvedAck = acknowledgeLabel ?? t('common.one_time_secret.acknowledge', { default: 'I have saved this secret' }); + + const handleCopy = useCallback(async () => { + if (!value) return; + try { + await navigator.clipboard.writeText(value); + setCopied(true); + // Auto-clear the "Copied!" indicator after 2 seconds so the + // panel doesn't stay in a "you're done" state when the user + // hasn't actually acknowledged. + setTimeout(() => setCopied(false), 2000); + } catch (err) { + // navigator.clipboard requires HTTPS in modern browsers; fall + // back to a textarea+execCommand path if it isn't available. + const ta = document.createElement('textarea'); + ta.value = value; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + try { + document.execCommand('copy'); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } finally { + document.body.removeChild(ta); + } + } + }, [value]); + + const handleAcknowledge = useCallback(() => { + setAcknowledged(true); + if (afterAcknowledge?.redirect) { + window.location.href = afterAcknowledge.redirect; + return; + } + if (afterAcknowledge?.dispatch) { + const G7Core = (window as any).G7Core; + G7Core?.ActionDispatcher?.dispatch?.(afterAcknowledge.dispatch, {}); + } + }, [afterAcknowledge]); + + if (!value) { + return null; + } + + // Once acknowledged, the panel collapses to a confirmation message — + // the secret is gone from the DOM as well as from the user's + // working memory. + if (acknowledged) { + return ( +
+
+ +

+ {t('common.one_time_secret.saved', { default: 'Secret saved.' })} +

+
+
+ ); + } + + return ( +
+
+ +
+

+ {resolvedTitle} +

+

+ {resolvedWarning} +

+
+
+ +
+ {revealed ? ( + {value} + ) : ( + + )} +
+ +
+ + + +
+
+ ); +}; + +export default OneTimeSecretPanel; diff --git a/templates/_bundled/sirsoft-admin_basic/src/components/composite/StatCardGrid.tsx b/templates/_bundled/sirsoft-admin_basic/src/components/composite/StatCardGrid.tsx new file mode 100644 index 00000000..2ad287c1 --- /dev/null +++ b/templates/_bundled/sirsoft-admin_basic/src/components/composite/StatCardGrid.tsx @@ -0,0 +1,119 @@ +import React, { useMemo } from 'react'; +import { Div } from '../basic/Div'; +import { StatCard, StatCardProps } from './StatCard'; + +/** + * StatCardGrid — responsive grid of StatCards driven by a data array. + * + * The existing StatCard composite renders a single tile; admins + * configuring a dashboard usually want a row of them (total / 4xx + * /429 / latency, etc.). StatCardGrid wraps the responsive Tailwind + * grid + iteration boilerplate so layouts can express the dashboard + * declaratively. + * + * Used by: + * - gb7-restapi admin_gb7_request_logs_index.json (dashboard header + * with 4 stat cards: total / 2xx / 4xx / 429) + * + * @example + * // Layout JSON usage: + * { + * "name": "StatCardGrid", + * "props": { + * "stats": [ + * { "value": "{{logs.data.stats.total}}", "label": "$t:gb7-restapi::admin.logs.stat_total", "iconName": "bar-chart" }, + * { "value": "{{logs.data.stats.2xx}}", "label": "$t:gb7-restapi::admin.logs.stat_2xx", "iconName": "check", "trend": "up" }, + * { "value": "{{logs.data.stats.4xx}}", "label": "$t:gb7-restapi::admin.logs.stat_4xx", "iconName": "alert-circle" }, + * { "value": "{{logs.data.stats.429}}", "label": "$t:gb7-restapi::admin.logs.stat_429", "iconName": "clock", "trend": "down" } + * ], + * "columns": 4, + * "responsiveColumns": { "sm": 1, "md": 2, "lg": 4 } + * } + * } + */ +export interface StatCardGridProps { + /** + * Array of StatCard props. Each entry renders one tile. The full + * StatCardProps shape is accepted (value, label, change, trend, + * iconName, etc.). + */ + stats: StatCardProps[]; + + /** Default column count (lg breakpoint). 4 fits 4 stat cards on desktop. */ + columns?: number; + + /** Tailwind gap value (translated to gap-{n}). */ + gap?: number; + + /** + * Per-breakpoint overrides. Without this, the grid uses + * `grid-cols-1` on mobile and `lg:grid-cols-{columns}` on desktop. + */ + responsiveColumns?: { + sm?: number; + md?: number; + lg?: number; + xl?: number; + }; + + className?: string; + style?: React.CSSProperties; +} + +export const StatCardGrid: React.FC = ({ + stats, + columns = 4, + gap = 4, + responsiveColumns, + className = '', + style, +}) => { + // Build responsive grid classes. Same approach as CardGrid so the + // visual rhythm matches. + const gridClasses = useMemo(() => { + const classes: string[] = []; + + const smCols = responsiveColumns?.sm ?? 1; + classes.push(`grid-cols-${smCols}`); + + if (responsiveColumns?.sm) { + classes.push(`sm:grid-cols-${responsiveColumns.sm}`); + } + + const mdCols = responsiveColumns?.md ?? Math.min(columns, 2); + classes.push(`md:grid-cols-${mdCols}`); + + const lgCols = responsiveColumns?.lg ?? columns; + classes.push(`lg:grid-cols-${lgCols}`); + + if (responsiveColumns?.xl) { + classes.push(`xl:grid-cols-${responsiveColumns.xl}`); + } + + return classes.join(' '); + }, [columns, responsiveColumns]); + + // Defensive — the layout engine may pass `undefined` while data is + // still loading. Render an empty grid rather than crashing. + if (!Array.isArray(stats) || stats.length === 0) { + return ( +