feat(admin): Phase V2-6 Admin CRM 고도화 — CRUD + 차트 + 결제 + 감사
- DB: audit_log 테이블(diff 포함) + subscriptions.admin_note + super_admin role - Edge Functions 4개: admin-users, admin-subscriptions, admin-payments, admin-audit-log - 공유 유틸: admin-auth.ts(권한 검증), audit.ts(감사로그 기록) - Swagger UI: 독립 정적 페이지 + OpenAPI 3.0 spec - CRUD 페이지: 구독 생성/수정/삭제, role 변경, 감사로그 목록/상세 - recharts: feature별 StackedBar + DAU Line + Top Users HorizontalBar - 결제 이력: DB + Payple API 병행 조회 - 권한: super_admin만 위험 작업, admin은 조회 전용 - RLS: admin/super_admin IN 정책 + super_admin 쓰기 정책 - SQL RPC: admin_usage_by_feature, admin_top_users, admin_dau
This commit is contained in:
parent
f7c50eb2ed
commit
dca1b90faa
34 changed files with 3142 additions and 45 deletions
100
apps/admin-swagger/index.html
Normal file
100
apps/admin-swagger/index.html
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>D3RO-VOICE Admin API</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css" />
|
||||
<style>
|
||||
body { margin: 0; padding: 0; background: #1a1a2e; }
|
||||
.topbar { display: none !important; }
|
||||
.swagger-ui .info .title { color: #e0e0e0; }
|
||||
.swagger-ui { max-width: 1200px; margin: 0 auto; }
|
||||
#header {
|
||||
background: linear-gradient(135deg, #0f0f23, #1a1a2e);
|
||||
color: #e0e0e0;
|
||||
padding: 20px 32px;
|
||||
border-bottom: 1px solid #333;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
#header h1 { margin: 0 0 4px; font-size: 1.4rem; }
|
||||
#header p { margin: 0; font-size: 0.85rem; color: #888; }
|
||||
#auth-bar {
|
||||
background: #0f0f23;
|
||||
padding: 12px 32px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #222;
|
||||
font-family: monospace;
|
||||
}
|
||||
#auth-bar label { color: #aaa; font-size: 0.8rem; }
|
||||
#auth-bar input {
|
||||
flex: 1;
|
||||
max-width: 500px;
|
||||
padding: 6px 10px;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
color: #e0e0e0;
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
#auth-bar button {
|
||||
padding: 6px 16px;
|
||||
background: #4a6cf7;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
#auth-bar button:hover { background: #3a5ce5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="header">
|
||||
<h1>D3RO-VOICE Admin API</h1>
|
||||
<p>Admin CRM Edge Functions — User management, Subscription CRUD, Payments, Audit logs</p>
|
||||
</div>
|
||||
<div id="auth-bar">
|
||||
<label for="token">Bearer Token:</label>
|
||||
<input id="token" type="text" placeholder="Paste your Supabase access_token here..." />
|
||||
<button onclick="applyToken()">Apply</button>
|
||||
</div>
|
||||
<div id="swagger-ui"></div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
let swaggerUI;
|
||||
|
||||
window.onload = function () {
|
||||
swaggerUI = SwaggerUIBundle({
|
||||
url: './openapi.json',
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [SwaggerUIBundle.presets.apis],
|
||||
layout: 'BaseLayout',
|
||||
requestInterceptor: function (req) {
|
||||
const token = document.getElementById('token').value.trim();
|
||||
if (token) {
|
||||
req.headers['Authorization'] = 'Bearer ' + token;
|
||||
}
|
||||
return req;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
function applyToken() {
|
||||
const token = document.getElementById('token').value.trim();
|
||||
if (token) {
|
||||
swaggerUI.preauthorizeApiKey('BearerAuth', token);
|
||||
document.getElementById('token').style.borderColor = '#4a6cf7';
|
||||
setTimeout(function () {
|
||||
document.getElementById('token').style.borderColor = '#444';
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
336
apps/admin-swagger/openapi.json
Normal file
336
apps/admin-swagger/openapi.json
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
{
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "D3RO-VOICE Admin API",
|
||||
"description": "Admin CRM Edge Functions for user management, subscription CRUD, payment history, and audit logs.",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "https://{supabaseRef}.supabase.co/functions/v1",
|
||||
"description": "Production",
|
||||
"variables": {
|
||||
"supabaseRef": {
|
||||
"default": "your-project-ref"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:54321/functions/v1",
|
||||
"description": "Local development"
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"BearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT",
|
||||
"description": "Supabase access_token (admin or super_admin role required)"
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"Profile": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string", "format": "uuid" },
|
||||
"name": { "type": "string", "nullable": true },
|
||||
"avatar_url": { "type": "string", "nullable": true },
|
||||
"locale": { "type": "string" },
|
||||
"tier": { "type": "string", "enum": ["free", "pro", "pro_plus"] },
|
||||
"role": { "type": "string", "enum": ["user", "admin", "super_admin"] },
|
||||
"created_at": { "type": "string", "format": "date-time" },
|
||||
"updated_at": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
},
|
||||
"Subscription": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string", "format": "uuid" },
|
||||
"user_id": { "type": "string", "format": "uuid" },
|
||||
"tier": { "type": "string", "enum": ["free", "pro", "pro_plus"] },
|
||||
"status": { "type": "string", "enum": ["active", "canceled", "past_due", "expired"] },
|
||||
"payment_provider": { "type": "string", "enum": ["none", "stripe", "payple"] },
|
||||
"current_period_start": { "type": "string", "format": "date-time", "nullable": true },
|
||||
"current_period_end": { "type": "string", "format": "date-time", "nullable": true },
|
||||
"overage_credits": { "type": "integer" },
|
||||
"admin_note": { "type": "string", "nullable": true },
|
||||
"renewal_failures": { "type": "integer" },
|
||||
"created_at": { "type": "string", "format": "date-time" },
|
||||
"updated_at": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
},
|
||||
"AuditLog": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"admin_id": { "type": "string", "format": "uuid" },
|
||||
"admin_name": { "type": "string" },
|
||||
"action": { "type": "string" },
|
||||
"target_type": { "type": "string" },
|
||||
"target_id": { "type": "string", "format": "uuid" },
|
||||
"before_data": { "type": "object", "nullable": true },
|
||||
"after_data": { "type": "object", "nullable": true },
|
||||
"memo": { "type": "string" },
|
||||
"created_at": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
},
|
||||
"Error": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
"/admin-users": {
|
||||
"get": {
|
||||
"tags": ["Users"],
|
||||
"summary": "List or get user details",
|
||||
"description": "Admin+. Pass userId for single user detail, or omit for paginated list.",
|
||||
"parameters": [
|
||||
{ "name": "userId", "in": "query", "schema": { "type": "string", "format": "uuid" }, "description": "Specific user ID for detail view" },
|
||||
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
||||
{ "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20 } },
|
||||
{ "name": "search", "in": "query", "schema": { "type": "string" }, "description": "Name search (ilike)" },
|
||||
{ "name": "role", "in": "query", "schema": { "type": "string", "enum": ["user", "admin", "super_admin"] } }
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "User list or detail",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profiles": { "type": "array", "items": { "$ref": "#/components/schemas/Profile" } },
|
||||
"total": { "type": "integer" },
|
||||
"page": { "type": "integer" },
|
||||
"limit": { "type": "integer" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile": { "$ref": "#/components/schemas/Profile" },
|
||||
"subscription": { "$ref": "#/components/schemas/Subscription" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": { "description": "Not admin", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"tags": ["Users"],
|
||||
"summary": "Change user role",
|
||||
"description": "Super admin only. Changes both auth.users.app_metadata.role and profiles.role.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["userId", "newRole", "memo"],
|
||||
"properties": {
|
||||
"userId": { "type": "string", "format": "uuid" },
|
||||
"newRole": { "type": "string", "enum": ["user", "admin", "super_admin"] },
|
||||
"memo": { "type": "string", "description": "Required reason for audit log" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": { "description": "Role changed successfully" },
|
||||
"403": { "description": "Not super_admin" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin-subscriptions": {
|
||||
"get": {
|
||||
"tags": ["Subscriptions"],
|
||||
"summary": "List or get subscription details",
|
||||
"parameters": [
|
||||
{ "name": "userId", "in": "query", "schema": { "type": "string", "format": "uuid" } },
|
||||
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
||||
{ "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20 } },
|
||||
{ "name": "status", "in": "query", "schema": { "type": "string", "enum": ["active", "canceled", "past_due", "expired"] } },
|
||||
{ "name": "tier", "in": "query", "schema": { "type": "string", "enum": ["free", "pro", "pro_plus"] } }
|
||||
],
|
||||
"responses": {
|
||||
"200": { "description": "Subscription list or detail" }
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": ["Subscriptions"],
|
||||
"summary": "Create subscription (VIP grant / record recovery)",
|
||||
"description": "Super admin only.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["userId", "tier", "memo"],
|
||||
"properties": {
|
||||
"userId": { "type": "string", "format": "uuid" },
|
||||
"tier": { "type": "string", "enum": ["free", "pro", "pro_plus"] },
|
||||
"status": { "type": "string", "enum": ["active", "canceled", "past_due", "expired"], "default": "active" },
|
||||
"currentPeriodEnd": { "type": "string", "format": "date-time" },
|
||||
"adminNote": { "type": "string" },
|
||||
"memo": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": { "description": "Subscription created" },
|
||||
"409": { "description": "Subscription already exists" }
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"tags": ["Subscriptions"],
|
||||
"summary": "Update subscription",
|
||||
"description": "Super admin only.",
|
||||
"parameters": [
|
||||
{ "name": "userId", "in": "query", "required": true, "schema": { "type": "string", "format": "uuid" } }
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["memo"],
|
||||
"properties": {
|
||||
"tier": { "type": "string", "enum": ["free", "pro", "pro_plus"] },
|
||||
"status": { "type": "string", "enum": ["active", "canceled", "past_due", "expired"] },
|
||||
"currentPeriodEnd": { "type": "string", "format": "date-time" },
|
||||
"overageCredits": { "type": "integer" },
|
||||
"adminNote": { "type": "string" },
|
||||
"memo": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": { "description": "Subscription updated" }
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": ["Subscriptions"],
|
||||
"summary": "Soft-delete subscription",
|
||||
"description": "Super admin only. Sets status=expired, tier=free.",
|
||||
"parameters": [
|
||||
{ "name": "userId", "in": "query", "required": true, "schema": { "type": "string", "format": "uuid" } }
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["memo"],
|
||||
"properties": {
|
||||
"memo": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": { "description": "Subscription soft-deleted" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin-payments": {
|
||||
"get": {
|
||||
"tags": ["Payments"],
|
||||
"summary": "Get payment history for a user",
|
||||
"description": "Admin+. Returns DB subscription data + audit logs. Pass source=payple for Payple API history.",
|
||||
"parameters": [
|
||||
{ "name": "userId", "in": "query", "required": true, "schema": { "type": "string", "format": "uuid" } },
|
||||
{ "name": "source", "in": "query", "schema": { "type": "string", "enum": ["db", "payple"] }, "description": "Add 'payple' to also fetch from Payple API" }
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Payment history",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"subscription": { "$ref": "#/components/schemas/Subscription" },
|
||||
"auditLogs": { "type": "array", "items": { "$ref": "#/components/schemas/AuditLog" } },
|
||||
"paypleHistory": { "type": "object", "description": "Payple API response (when source=payple)" },
|
||||
"paypleError": { "type": "string", "description": "Error message if Payple API call failed" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin-audit-log": {
|
||||
"get": {
|
||||
"tags": ["Audit Log"],
|
||||
"summary": "List or get audit log entries",
|
||||
"description": "Admin+. Pass id for single entry detail.",
|
||||
"parameters": [
|
||||
{ "name": "id", "in": "query", "schema": { "type": "integer" }, "description": "Specific log entry ID" },
|
||||
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
||||
{ "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20 } },
|
||||
{ "name": "target_type", "in": "query", "schema": { "type": "string", "enum": ["subscription", "profile"] } },
|
||||
{ "name": "admin_id", "in": "query", "schema": { "type": "string", "format": "uuid" } },
|
||||
{ "name": "target_id", "in": "query", "schema": { "type": "string", "format": "uuid" } },
|
||||
{ "name": "from", "in": "query", "schema": { "type": "string", "format": "date" }, "description": "Start date (YYYY-MM-DD)" },
|
||||
{ "name": "to", "in": "query", "schema": { "type": "string", "format": "date" }, "description": "End date (YYYY-MM-DD)" }
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Audit log list or detail",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"logs": { "type": "array", "items": { "$ref": "#/components/schemas/AuditLog" } },
|
||||
"total": { "type": "integer" },
|
||||
"page": { "type": "integer" },
|
||||
"limit": { "type": "integer" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"log": { "$ref": "#/components/schemas/AuditLog" },
|
||||
"admin": { "$ref": "#/components/schemas/Profile" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -23,7 +23,8 @@
|
|||
"@supabase/supabase-js": "^2.103.0",
|
||||
"next": "^15.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^2.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.0",
|
||||
|
|
|
|||
96
apps/admin/src/app/(admin)/audit-log/[id]/page.tsx
Normal file
96
apps/admin/src/app/(admin)/audit-log/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// apps/admin/src/app/(admin)/audit-log/[id]/page.tsx
|
||||
// 감사로그 상세 — before/after diff 뷰
|
||||
|
||||
import { Box, Grid } from '@mui/material'
|
||||
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
|
||||
import { getSupabaseServerClient } from '@/lib/supabase-server'
|
||||
import { requireAdmin } from '@/lib/admin-guard'
|
||||
import { notFound } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { AuditDiffViewer } from '@/components/audit-diff-viewer'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export default async function AuditLogDetailPage({ params }: PageProps): Promise<React.ReactElement> {
|
||||
await requireAdmin()
|
||||
const { id } = await params
|
||||
const supabase = await getSupabaseServerClient()
|
||||
|
||||
const { data: log } = await supabase
|
||||
.from('audit_log')
|
||||
.select('*')
|
||||
.eq('id', parseInt(id, 10))
|
||||
.maybeSingle()
|
||||
|
||||
if (!log) notFound()
|
||||
const typedLog = log as Record<string, unknown>
|
||||
|
||||
// Admin profile
|
||||
const { data: adminProfile } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, name')
|
||||
.eq('id', typedLog.admin_id as string)
|
||||
.maybeSingle()
|
||||
|
||||
const adminName = (adminProfile as { name: string | null } | null)?.name ?? 'Unknown'
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<PhosphorText variant="title">AUDIT LOG #{id}</PhosphorText>
|
||||
<Link href="/audit-log" style={{ textDecoration: 'none' }}>
|
||||
<PhosphorText variant="dim" sx={{ fontSize: 12 }}>{'<'} Back</PhosphorText>
|
||||
</Link>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<MetalCard>
|
||||
<Box sx={{ p: 1 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>DETAILS</PhosphorText>
|
||||
<Box sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
<Row label="ACTION" value={typedLog.action as string} />
|
||||
<Row label="ADMIN" value={adminName} />
|
||||
<Row label="TARGET TYPE" value={typedLog.target_type as string} />
|
||||
<Row label="TARGET ID" value={typedLog.target_id as string} />
|
||||
<Row label="DATE" value={new Date(typedLog.created_at as string).toLocaleString()} />
|
||||
</Box>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<MetalCard>
|
||||
<Box sx={{ p: 1 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>MEMO</PhosphorText>
|
||||
<PhosphorText variant="body" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||
{typedLog.memo as string}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<MetalCard>
|
||||
<Box sx={{ p: 1 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>CHANGES (DIFF)</PhosphorText>
|
||||
<AuditDiffViewer
|
||||
beforeData={typedLog.before_data as Record<string, unknown> | null}
|
||||
afterData={typedLog.after_data as Record<string, unknown> | null}
|
||||
/>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }): React.ReactElement {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: d3roPalette.text.label }}>{label}</span>
|
||||
<span style={{ color: d3roPalette.text.primary }}>{value}</span>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
137
apps/admin/src/app/(admin)/audit-log/page.tsx
Normal file
137
apps/admin/src/app/(admin)/audit-log/page.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// apps/admin/src/app/(admin)/audit-log/page.tsx
|
||||
// 감사로그 목록
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
|
||||
import { getSupabaseServerClient } from '@/lib/supabase-server'
|
||||
import { requireAdmin } from '@/lib/admin-guard'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ target_type?: string; page?: string }>
|
||||
}
|
||||
|
||||
export default async function AuditLogPage({ searchParams }: PageProps): Promise<React.ReactElement> {
|
||||
await requireAdmin()
|
||||
const params = await searchParams
|
||||
const targetTypeFilter = params.target_type ?? 'all'
|
||||
const page = parseInt(params.page ?? '1', 10)
|
||||
const limit = 20
|
||||
const from = (page - 1) * limit
|
||||
const to = from + limit - 1
|
||||
|
||||
const supabase = await getSupabaseServerClient()
|
||||
|
||||
let query = supabase
|
||||
.from('audit_log')
|
||||
.select('*', { count: 'exact' })
|
||||
|
||||
if (targetTypeFilter !== 'all') {
|
||||
query = query.eq('target_type', targetTypeFilter)
|
||||
}
|
||||
|
||||
const { data: rawLogs, count } = await query
|
||||
.order('created_at', { ascending: false })
|
||||
.range(from, to)
|
||||
|
||||
const logs = (rawLogs ?? []) as Array<Record<string, unknown>>
|
||||
const totalPages = Math.ceil((count ?? 0) / limit)
|
||||
|
||||
// Admin names
|
||||
const adminIds = [...new Set(logs.map(l => l.admin_id as string))]
|
||||
let adminMap: Record<string, string> = {}
|
||||
if (adminIds.length > 0) {
|
||||
const { data: admins } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, name')
|
||||
.in('id', adminIds)
|
||||
if (admins) {
|
||||
adminMap = Object.fromEntries(
|
||||
(admins as Array<{ id: string; name: string | null }>).map(a => [a.id, a.name ?? 'Unknown'])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<PhosphorText variant="title" sx={{ mb: 3 }}>AUDIT LOG</PhosphorText>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||
{['all', 'subscription', 'profile'].map((t) => (
|
||||
<Link key={t} href={`/audit-log?target_type=${t}`} style={{ textDecoration: 'none' }}>
|
||||
<PhosphorText variant="label" sx={{
|
||||
px: 1.5, py: 0.5, borderRadius: 1,
|
||||
bgcolor: targetTypeFilter === t ? d3roPalette.bg.inset : 'transparent',
|
||||
color: targetTypeFilter === t ? d3roPalette.accent.amber : d3roPalette.text.secondary,
|
||||
}}>
|
||||
{t.toUpperCase()}
|
||||
</PhosphorText>
|
||||
</Link>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<MetalCard sx={{ overflow: 'auto' }}>
|
||||
<Box component="table" sx={{
|
||||
width: '100%', borderCollapse: 'collapse', fontFamily: d3roFontMono, fontSize: d3roTypo.small.size,
|
||||
'& th, & td': { py: 0.5, px: 1, textAlign: 'left', borderBottom: `1px solid ${d3roPalette.border.subtle}` },
|
||||
'& th': { color: d3roPalette.text.label, textTransform: 'uppercase' },
|
||||
}}>
|
||||
<thead><tr><th>DATE</th><th>ADMIN</th><th>ACTION</th><th>TARGET</th><th>MEMO</th><th>DETAIL</th></tr></thead>
|
||||
<tbody>
|
||||
{logs.length === 0 ? (
|
||||
<tr><td colSpan={6} style={{ textAlign: 'center', color: d3roPalette.text.muted, padding: 16 }}>No audit logs</td></tr>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<tr key={log.id as number}>
|
||||
<td style={{ color: d3roPalette.text.muted, whiteSpace: 'nowrap' }}>
|
||||
{new Date(log.created_at as string).toLocaleString()}
|
||||
</td>
|
||||
<td>{adminMap[log.admin_id as string] ?? (log.admin_id as string).substring(0, 8)}</td>
|
||||
<td style={{ color: d3roPalette.accent.amber }}>{log.action as string}</td>
|
||||
<td>
|
||||
<Link
|
||||
href={
|
||||
(log.target_type as string) === 'subscription'
|
||||
? `/subscriptions/${log.target_id as string}`
|
||||
: `/users/${log.target_id as string}`
|
||||
}
|
||||
style={{ color: d3roPalette.accent.amber, textDecoration: 'none' }}
|
||||
>
|
||||
{(log.target_id as string).substring(0, 8)}...
|
||||
</Link>
|
||||
</td>
|
||||
<td style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{log.memo as string}
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/audit-log/${log.id as number}`} style={{ color: d3roPalette.accent.amber, textDecoration: 'none' }}>
|
||||
View
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 1, justifyContent: 'center' }}>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).slice(0, 10).map((p) => (
|
||||
<Link key={p} href={`/audit-log?target_type=${targetTypeFilter}&page=${p}`} style={{ textDecoration: 'none' }}>
|
||||
<PhosphorText variant="label" sx={{
|
||||
px: 1, py: 0.25, borderRadius: 0.5,
|
||||
bgcolor: p === page ? d3roPalette.bg.inset : 'transparent',
|
||||
color: p === page ? d3roPalette.accent.amber : d3roPalette.text.secondary,
|
||||
}}>
|
||||
{p}
|
||||
</PhosphorText>
|
||||
</Link>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
95
apps/admin/src/app/(admin)/subscriptions/[id]/client.tsx
Normal file
95
apps/admin/src/app/(admin)/subscriptions/[id]/client.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
'use client'
|
||||
|
||||
// apps/admin/src/app/(admin)/subscriptions/[id]/client.tsx
|
||||
// 구독 수정/삭제 클라이언트 컴포넌트
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Box, Button } from '@mui/material'
|
||||
import { d3roFontMono } from '@d3ro/ui/theme'
|
||||
import { SubscriptionForm } from '@/components/subscription-form'
|
||||
import { MemoDialog } from '@/components/memo-dialog'
|
||||
import { callAdminApi } from '@/lib/admin-api'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
type Tier = 'free' | 'pro' | 'pro_plus'
|
||||
type SubStatus = 'active' | 'canceled' | 'past_due' | 'expired'
|
||||
|
||||
interface SubscriptionDetailClientProps {
|
||||
userId: string
|
||||
hasSub: boolean
|
||||
isSuperAdmin: boolean
|
||||
initialSub?: {
|
||||
tier: Tier
|
||||
status: SubStatus
|
||||
currentPeriodEnd: string | null
|
||||
overageCredits: number
|
||||
adminNote: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export function SubscriptionDetailClient({
|
||||
userId, hasSub, isSuperAdmin, initialSub,
|
||||
}: SubscriptionDetailClientProps): React.ReactElement {
|
||||
const router = useRouter()
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [deleteLoading, setDeleteLoading] = useState(false)
|
||||
|
||||
const handleDelete = async (memo: string): Promise<void> => {
|
||||
setDeleteLoading(true)
|
||||
try {
|
||||
await callAdminApi(`admin-subscriptions?userId=${userId}`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ memo }),
|
||||
})
|
||||
setDeleteOpen(false)
|
||||
router.refresh()
|
||||
} catch {
|
||||
// error handled in dialog
|
||||
} finally {
|
||||
setDeleteLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSuperAdmin) {
|
||||
return <Box />
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{hasSub && initialSub ? (
|
||||
<>
|
||||
<SubscriptionForm
|
||||
mode="edit"
|
||||
userId={userId}
|
||||
initial={initialSub}
|
||||
onSuccess={() => router.refresh()}
|
||||
/>
|
||||
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
sx={{ fontFamily: d3roFontMono }}
|
||||
>
|
||||
Delete Subscription
|
||||
</Button>
|
||||
</Box>
|
||||
<MemoDialog
|
||||
open={deleteOpen}
|
||||
title="DELETE SUBSCRIPTION"
|
||||
description={`This will soft-delete the subscription for user ${userId}. The subscription will be set to expired/free.`}
|
||||
onConfirm={(memo) => void handleDelete(memo)}
|
||||
onCancel={() => setDeleteOpen(false)}
|
||||
loading={deleteLoading}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<SubscriptionForm
|
||||
mode="create"
|
||||
userId={userId}
|
||||
onSuccess={() => router.refresh()}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
140
apps/admin/src/app/(admin)/subscriptions/[id]/page.tsx
Normal file
140
apps/admin/src/app/(admin)/subscriptions/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
// apps/admin/src/app/(admin)/subscriptions/[id]/page.tsx
|
||||
// 구독 상세 + 수정/삭제 (super_admin) — [id]는 user_id
|
||||
|
||||
import { Box, Grid } from '@mui/material'
|
||||
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
|
||||
import { getSupabaseServerClient } from '@/lib/supabase-server'
|
||||
import { requireAdmin } from '@/lib/admin-guard'
|
||||
import { notFound } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { SubscriptionDetailClient } from './client'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export default async function SubscriptionDetailPage({ params }: PageProps): Promise<React.ReactElement> {
|
||||
const { id: userId } = await params
|
||||
const admin = await requireAdmin()
|
||||
const supabase = await getSupabaseServerClient()
|
||||
|
||||
const [subRes, profileRes, auditRes] = await Promise.all([
|
||||
supabase.from('subscriptions').select('*').eq('user_id', userId).maybeSingle(),
|
||||
supabase.from('profiles').select('id, name, tier, role').eq('id', userId).maybeSingle(),
|
||||
supabase.from('audit_log').select('*')
|
||||
.eq('target_id', userId)
|
||||
.eq('target_type', 'subscription')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(20),
|
||||
])
|
||||
|
||||
const sub = subRes.data as Record<string, unknown> | null
|
||||
const profile = profileRes.data as Record<string, unknown> | null
|
||||
|
||||
if (!profile) notFound()
|
||||
|
||||
const auditLogs = (auditRes.data ?? []) as Array<Record<string, unknown>>
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<PhosphorText variant="title">SUBSCRIPTION DETAIL</PhosphorText>
|
||||
<Link href="/subscriptions" style={{ textDecoration: 'none' }}>
|
||||
<PhosphorText variant="dim" sx={{ fontSize: 12 }}>{'<'} Back</PhosphorText>
|
||||
</Link>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<MetalCard>
|
||||
<Box sx={{ p: 1 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>USER</PhosphorText>
|
||||
<Box sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
<Row label="NAME" value={(profile.name as string) ?? '-'} />
|
||||
<Row label="ID" value={userId} />
|
||||
<Row label="ROLE" value={((profile.role as string) ?? 'user').toUpperCase()} />
|
||||
</Box>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<MetalCard>
|
||||
<Box sx={{ p: 1 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>CURRENT SUBSCRIPTION</PhosphorText>
|
||||
{sub ? (
|
||||
<Box sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
<Row label="TIER" value={((sub.tier as string) ?? 'free').toUpperCase()} />
|
||||
<Row label="STATUS" value={((sub.status as string) ?? '-').toUpperCase()} />
|
||||
<Row label="PROVIDER" value={((sub.payment_provider as string) ?? 'none').toUpperCase()} />
|
||||
<Row label="PERIOD END" value={sub.current_period_end ? new Date(sub.current_period_end as string).toLocaleDateString() : '-'} />
|
||||
<Row label="OVERAGE" value={String(sub.overage_credits ?? 0)} />
|
||||
<Row label="NOTE" value={(sub.admin_note as string) ?? '-'} />
|
||||
</Box>
|
||||
) : (
|
||||
<PhosphorText variant="dim">No subscription record</PhosphorText>
|
||||
)}
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Client component for CRUD actions */}
|
||||
<SubscriptionDetailClient
|
||||
userId={userId}
|
||||
hasSub={!!sub}
|
||||
isSuperAdmin={admin.role === 'super_admin'}
|
||||
initialSub={sub ? ({
|
||||
tier: (sub.tier as 'free' | 'pro' | 'pro_plus') ?? 'free',
|
||||
status: (sub.status as 'active' | 'canceled' | 'past_due' | 'expired') ?? 'active',
|
||||
currentPeriodEnd: (sub.current_period_end as string | null) ?? null,
|
||||
overageCredits: (sub.overage_credits as number) ?? 0,
|
||||
adminNote: (sub.admin_note as string | null) ?? null,
|
||||
}) : undefined}
|
||||
/>
|
||||
|
||||
{/* Audit trail */}
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>AUDIT TRAIL</PhosphorText>
|
||||
{auditLogs.length === 0 ? (
|
||||
<PhosphorText variant="dim">No audit records</PhosphorText>
|
||||
) : (
|
||||
<MetalCard sx={{ overflow: 'auto' }}>
|
||||
<Box component="table" sx={{
|
||||
width: '100%', borderCollapse: 'collapse', fontFamily: d3roFontMono, fontSize: d3roTypo.small.size,
|
||||
'& th, & td': { py: 0.5, px: 1, textAlign: 'left', borderBottom: `1px solid ${d3roPalette.border.subtle}` },
|
||||
'& th': { color: d3roPalette.text.label },
|
||||
}}>
|
||||
<thead><tr><th>DATE</th><th>ACTION</th><th>MEMO</th><th>DETAIL</th></tr></thead>
|
||||
<tbody>
|
||||
{auditLogs.map((log) => (
|
||||
<tr key={log.id as number}>
|
||||
<td style={{ color: d3roPalette.text.muted, whiteSpace: 'nowrap' }}>
|
||||
{new Date(log.created_at as string).toLocaleString()}
|
||||
</td>
|
||||
<td style={{ color: d3roPalette.accent.amber }}>{log.action as string}</td>
|
||||
<td>{(log.memo as string).substring(0, 50)}</td>
|
||||
<td>
|
||||
<Link href={`/audit-log/${log.id as number}`} style={{ color: d3roPalette.accent.amber, textDecoration: 'none' }}>
|
||||
View
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }): React.ReactElement {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: d3roPalette.text.label }}>{label}</span>
|
||||
<span style={{ color: d3roPalette.text.primary }}>{value}</span>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
48
apps/admin/src/app/(admin)/subscriptions/new/client.tsx
Normal file
48
apps/admin/src/app/(admin)/subscriptions/new/client.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
'use client'
|
||||
|
||||
// apps/admin/src/app/(admin)/subscriptions/new/client.tsx
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Box, TextField } from '@mui/material'
|
||||
import { PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
|
||||
import { SubscriptionForm } from '@/components/subscription-form'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface NewSubscriptionClientProps {
|
||||
initialUserId: string
|
||||
}
|
||||
|
||||
export function NewSubscriptionClient({ initialUserId }: NewSubscriptionClientProps): React.ReactElement {
|
||||
const [userId, setUserId] = useState(initialUserId)
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ display: 'block', mb: 1 }}>TARGET USER ID</PhosphorText>
|
||||
<TextField
|
||||
fullWidth
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
placeholder="UUID of the user..."
|
||||
sx={{
|
||||
'& .MuiInputBase-root': {
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: 13,
|
||||
color: d3roPalette.text.primary,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{userId && (
|
||||
<SubscriptionForm
|
||||
mode="create"
|
||||
userId={userId}
|
||||
onSuccess={() => router.push(`/subscriptions/${userId}`)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
30
apps/admin/src/app/(admin)/subscriptions/new/page.tsx
Normal file
30
apps/admin/src/app/(admin)/subscriptions/new/page.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// apps/admin/src/app/(admin)/subscriptions/new/page.tsx
|
||||
// 새 구독 생성 (VIP 부여) — super_admin 전용
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import { PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { requireSuperAdmin } from '@/lib/admin-guard'
|
||||
import Link from 'next/link'
|
||||
import { NewSubscriptionClient } from './client'
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ userId?: string }>
|
||||
}
|
||||
|
||||
export default async function NewSubscriptionPage({ searchParams }: PageProps): Promise<React.ReactElement> {
|
||||
await requireSuperAdmin()
|
||||
const params = await searchParams
|
||||
const userId = params.userId ?? ''
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<PhosphorText variant="title">NEW SUBSCRIPTION</PhosphorText>
|
||||
<Link href="/subscriptions" style={{ textDecoration: 'none' }}>
|
||||
<PhosphorText variant="dim" sx={{ fontSize: 12 }}>{'<'} Back</PhosphorText>
|
||||
</Link>
|
||||
</Box>
|
||||
<NewSubscriptionClient initialUserId={userId} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -63,7 +63,18 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
|
|||
|
||||
return (
|
||||
<Box>
|
||||
<PhosphorText variant="title" sx={{ mb: 3 }}>SUBSCRIPTIONS</PhosphorText>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||
<PhosphorText variant="title">SUBSCRIPTIONS</PhosphorText>
|
||||
<Link href="/subscriptions/new" style={{ textDecoration: 'none' }}>
|
||||
<PhosphorText variant="label" sx={{
|
||||
px: 1.5, py: 0.5, borderRadius: 1,
|
||||
bgcolor: d3roPalette.accent.amber,
|
||||
color: d3roPalette.bg.app,
|
||||
}}>
|
||||
+ NEW
|
||||
</PhosphorText>
|
||||
</Link>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||
{['all', 'active', 'canceled', 'past_due', 'expired'].map((s) => (
|
||||
|
|
@ -86,7 +97,7 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
|
|||
'& th': { color: d3roPalette.text.label, textTransform: 'uppercase' },
|
||||
}}>
|
||||
<thead>
|
||||
<tr><th>USER</th><th>TIER</th><th>STATUS</th><th>PROVIDER</th><th>EXPIRES</th><th>CANCEL</th><th>FAILS</th></tr>
|
||||
<tr><th>USER</th><th>TIER</th><th>STATUS</th><th>PROVIDER</th><th>EXPIRES</th><th>CANCEL</th><th>FAILS</th><th>EDIT</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{subs.map((s) => (
|
||||
|
|
@ -106,6 +117,11 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
|
|||
<td style={{ color: d3roPalette.text.muted }}>{s.current_period_end ? new Date(s.current_period_end).toLocaleDateString() : '-'}</td>
|
||||
<td style={{ color: s.cancel_at ? d3roPalette.tag.red : d3roPalette.text.muted }}>{s.cancel_at ? new Date(s.cancel_at).toLocaleDateString() : '-'}</td>
|
||||
<td style={{ color: s.renewal_failures > 0 ? d3roPalette.tag.red : d3roPalette.text.muted }}>{s.renewal_failures}</td>
|
||||
<td>
|
||||
<Link href={`/subscriptions/${s.user_id}`} style={{ color: d3roPalette.accent.amber, textDecoration: 'none' }}>
|
||||
Edit
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
// apps/admin/src/app/(admin)/usage/page.tsx
|
||||
// 사용량 집계 — feature별, 날짜 범위
|
||||
// 사용량 — feature별 차트 + DAU + Top users + 테이블
|
||||
|
||||
import { Box, Grid } from '@mui/material'
|
||||
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
|
||||
import { getSupabaseServerClient } from '@/lib/supabase-server'
|
||||
import Link from 'next/link'
|
||||
import { FeatureUsageChart } from '@/components/charts/feature-usage-chart'
|
||||
import { DauChart } from '@/components/charts/dau-chart'
|
||||
import { TopUsersChart } from '@/components/charts/top-users-chart'
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ days?: string }>
|
||||
|
|
@ -14,16 +18,32 @@ export default async function AdminUsagePage({ searchParams }: PageProps): Promi
|
|||
const params = await searchParams
|
||||
const days = parseInt(params.days ?? '7', 10)
|
||||
const since = new Date(Date.now() - days * 86400000).toISOString().split('T')[0]
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
|
||||
const supabase = await getSupabaseServerClient()
|
||||
const { data: rawData } = await supabase
|
||||
.from('daily_usage')
|
||||
.select('date, feature, count, user_id')
|
||||
.gte('date', since)
|
||||
.order('date', { ascending: false })
|
||||
|
||||
const rows = (rawData ?? []) as Array<{ date: string; feature: string; count: number; user_id: string }>
|
||||
// Fetch all data in parallel
|
||||
// RPC functions are defined in migration but not in Database types — cast via unknown
|
||||
const rpcClient = supabase as unknown as {
|
||||
rpc: (fn: string, params: Record<string, unknown>) => Promise<{ data: unknown[]; error: unknown }>
|
||||
}
|
||||
|
||||
const [featureRes, dauRes, topUsersRes, rawDataRes] = await Promise.all([
|
||||
rpcClient.rpc('admin_usage_by_feature', { p_from: since, p_to: today }),
|
||||
rpcClient.rpc('admin_dau', { p_from: since, p_to: today }),
|
||||
rpcClient.rpc('admin_top_users', { p_from: since, p_to: today, p_limit: 20 }),
|
||||
supabase.from('daily_usage')
|
||||
.select('date, feature, count, user_id')
|
||||
.gte('date', since)
|
||||
.order('date', { ascending: false }),
|
||||
])
|
||||
|
||||
const featureData = (featureRes.data ?? []) as Array<{ date: string; feature: string; total_count: number; unique_users: number }>
|
||||
const dauData = (dauRes.data ?? []) as Array<{ date: string; active_users: number }>
|
||||
const topUsersData = (topUsersRes.data ?? []) as Array<{ user_id: string; name: string | null; total_count: number; feature_count: number }>
|
||||
|
||||
// Summary cards from raw data
|
||||
const rows = (rawDataRes.data ?? []) as Array<{ date: string; feature: string; count: number; user_id: string }>
|
||||
const featureMap = new Map<string, { total: number; users: Set<string> }>()
|
||||
for (const r of rows) {
|
||||
const entry = featureMap.get(r.feature) ?? { total: 0, users: new Set<string>() }
|
||||
|
|
@ -31,41 +51,27 @@ export default async function AdminUsagePage({ searchParams }: PageProps): Promi
|
|||
entry.users.add(r.user_id)
|
||||
featureMap.set(r.feature, entry)
|
||||
}
|
||||
|
||||
const summaries = Array.from(featureMap.entries())
|
||||
.map(([feature, { total, users }]) => ({ feature, total, uniqueUsers: users.size }))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
|
||||
const dailyMap = new Map<string, Map<string, number>>()
|
||||
for (const r of rows) {
|
||||
const dayEntry = dailyMap.get(r.date) ?? new Map<string, number>()
|
||||
dayEntry.set(r.feature, (dayEntry.get(r.feature) ?? 0) + r.count)
|
||||
dailyMap.set(r.date, dayEntry)
|
||||
}
|
||||
|
||||
const dailyRows: Array<{ date: string; feature: string; total: number }> = []
|
||||
for (const [date, features] of dailyMap) {
|
||||
for (const [feature, total] of features) {
|
||||
dailyRows.push({ date, feature, total })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<PhosphorText variant="title" sx={{ mb: 3 }}>USAGE</PhosphorText>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||
{[7, 14, 30].map((d) => (
|
||||
<a key={d} href={`/usage?days=${d}`} style={{ textDecoration: 'none' }}>
|
||||
<Link key={d} href={`/usage?days=${d}`} style={{ textDecoration: 'none' }}>
|
||||
<PhosphorText variant="label" sx={{
|
||||
px: 1.5, py: 0.5, borderRadius: 1,
|
||||
bgcolor: days === d ? d3roPalette.bg.inset : 'transparent',
|
||||
color: days === d ? d3roPalette.accent.amber : d3roPalette.text.secondary,
|
||||
}}>{d}D</PhosphorText>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Summary cards */}
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
{summaries.map((s) => (
|
||||
<Grid size={{ xs: 6, md: 3 }} key={s.feature}>
|
||||
|
|
@ -80,6 +86,49 @@ export default async function AdminUsagePage({ searchParams }: PageProps): Promi
|
|||
))}
|
||||
</Grid>
|
||||
|
||||
{/* Feature usage stacked bar chart */}
|
||||
<MetalCard sx={{ mb: 3 }}>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>FEATURE USAGE (DAILY)</PhosphorText>
|
||||
{featureData.length > 0 ? (
|
||||
<FeatureUsageChart data={featureData} />
|
||||
) : (
|
||||
<PhosphorText variant="dim">No data</PhosphorText>
|
||||
)}
|
||||
</Box>
|
||||
</MetalCard>
|
||||
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
{/* DAU chart */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<MetalCard>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>DAILY ACTIVE USERS</PhosphorText>
|
||||
{dauData.length > 0 ? (
|
||||
<DauChart data={dauData} />
|
||||
) : (
|
||||
<PhosphorText variant="dim">No data</PhosphorText>
|
||||
)}
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Grid>
|
||||
|
||||
{/* Top users chart */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<MetalCard>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>TOP USERS</PhosphorText>
|
||||
{topUsersData.length > 0 ? (
|
||||
<TopUsersChart data={topUsersData} />
|
||||
) : (
|
||||
<PhosphorText variant="dim">No data</PhosphorText>
|
||||
)}
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Daily breakdown table */}
|
||||
<MetalCard sx={{ overflow: 'auto' }}>
|
||||
<Box sx={{ p: 1 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>DAILY BREAKDOWN</PhosphorText>
|
||||
|
|
@ -88,17 +137,19 @@ export default async function AdminUsagePage({ searchParams }: PageProps): Promi
|
|||
'& th, & td': { py: 0.5, px: 1.5, textAlign: 'left', borderBottom: `1px solid ${d3roPalette.border.subtle}` },
|
||||
'& th': { color: d3roPalette.text.label, textTransform: 'uppercase' },
|
||||
}}>
|
||||
<thead><tr><th>DATE</th><th>FEATURE</th><th>CALLS</th></tr></thead>
|
||||
<thead><tr><th>DATE</th><th>FEATURE</th><th>CALLS</th><th>UNIQUE USERS</th></tr></thead>
|
||||
<tbody>
|
||||
{dailyRows.map((r, i) => (
|
||||
<tr key={i}>
|
||||
<td style={{ color: d3roPalette.text.muted }}>{r.date}</td>
|
||||
<td>{r.feature}</td>
|
||||
<td style={{ color: d3roPalette.accent.amber }}>{r.total.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
{dailyRows.length === 0 && (
|
||||
<tr><td colSpan={3} style={{ textAlign: 'center', color: d3roPalette.text.muted }}>No usage data</td></tr>
|
||||
{featureData.length > 0 ? (
|
||||
[...featureData].reverse().map((r, i) => (
|
||||
<tr key={i}>
|
||||
<td style={{ color: d3roPalette.text.muted }}>{r.date}</td>
|
||||
<td>{r.feature}</td>
|
||||
<td style={{ color: d3roPalette.accent.amber }}>{r.total_count.toLocaleString()}</td>
|
||||
<td style={{ color: d3roPalette.text.secondary }}>{r.unique_users}</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr><td colSpan={4} style={{ textAlign: 'center', color: d3roPalette.text.muted, padding: 16 }}>No usage data</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@ import { Box, Grid } from '@mui/material'
|
|||
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
|
||||
import { getSupabaseServerClient } from '@/lib/supabase-server'
|
||||
import { requireAdmin } from '@/lib/admin-guard'
|
||||
import { notFound } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { RoleChangeButton } from './role-change-button'
|
||||
import { PaymentHistory } from '@/components/payment-history'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
|
|
@ -13,6 +17,7 @@ interface PageProps {
|
|||
|
||||
export default async function AdminUserDetailPage({ params }: PageProps): Promise<React.ReactElement> {
|
||||
const { id } = await params
|
||||
const admin = await requireAdmin()
|
||||
const supabase = await getSupabaseServerClient()
|
||||
|
||||
const [profileRes, subRes, usageRes] = await Promise.all([
|
||||
|
|
@ -32,9 +37,19 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
|
|||
const tier = (profile.tier as string) ?? 'free'
|
||||
const tierColor = tier === 'pro_plus' ? d3roPalette.tag.purple : tier === 'pro' ? d3roPalette.tag.green : d3roPalette.accent.amber
|
||||
|
||||
const userRole = ((profile.role as string) ?? 'user') as 'user' | 'admin' | 'super_admin'
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<PhosphorText variant="title" sx={{ mb: 3 }}>USER DETAIL</PhosphorText>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<PhosphorText variant="title">USER DETAIL</PhosphorText>
|
||||
<RoleChangeButton
|
||||
userId={id}
|
||||
userName={(profile.name as string) ?? null}
|
||||
currentRole={userRole}
|
||||
isSuperAdmin={admin.role === 'super_admin'}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
|
|
@ -45,6 +60,7 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
|
|||
<Row label="ID" value={id} />
|
||||
<Row label="NAME" value={(profile.name as string) ?? '-'} />
|
||||
<Row label="TIER" value={tier === 'pro_plus' ? 'PRO+' : tier.toUpperCase()} valueColor={tierColor} />
|
||||
<Row label="ROLE" value={userRole.toUpperCase()} valueColor={userRole === 'super_admin' ? d3roPalette.tag.purple : userRole === 'admin' ? d3roPalette.tag.green : d3roPalette.text.secondary} />
|
||||
<Row label="LOCALE" value={(profile.locale as string) ?? '-'} />
|
||||
<Row label="JOINED" value={new Date(profile.created_at as string).toLocaleDateString()} />
|
||||
</Box>
|
||||
|
|
@ -61,6 +77,11 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
|
|||
<Row label="PROVIDER" value={((sub.payment_provider as string) ?? '-').toUpperCase()} />
|
||||
<Row label="PERIOD END" value={sub.current_period_end ? new Date(sub.current_period_end as string).toLocaleDateString() : '-'} />
|
||||
<Row label="CANCEL AT" value={sub.cancel_at ? new Date(sub.cancel_at as string).toLocaleDateString() : '-'} />
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
<Link href={`/subscriptions/${id}`} style={{ color: d3roPalette.accent.amber, textDecoration: 'none', fontSize: 11 }}>
|
||||
Edit Subscription {'->'}
|
||||
</Link>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<PhosphorText variant="dim">No subscription</PhosphorText>
|
||||
|
|
@ -95,6 +116,12 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
|
|||
)}
|
||||
</Box>
|
||||
</MetalCard>
|
||||
|
||||
{/* Payment History */}
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>PAYMENT HISTORY</PhosphorText>
|
||||
<PaymentHistory userId={id} />
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
50
apps/admin/src/app/(admin)/users/[id]/role-change-button.tsx
Normal file
50
apps/admin/src/app/(admin)/users/[id]/role-change-button.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
'use client'
|
||||
|
||||
// apps/admin/src/app/(admin)/users/[id]/role-change-button.tsx
|
||||
// super_admin만 볼 수 있는 role 변경 버튼
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@mui/material'
|
||||
import { d3roFontMono } from '@d3ro/ui/theme'
|
||||
import { RoleChangeDialog } from '@/components/role-change-dialog'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface RoleChangeButtonProps {
|
||||
userId: string
|
||||
userName: string | null
|
||||
currentRole: 'user' | 'admin' | 'super_admin'
|
||||
isSuperAdmin: boolean
|
||||
}
|
||||
|
||||
export function RoleChangeButton({
|
||||
userId, userName, currentRole, isSuperAdmin,
|
||||
}: RoleChangeButtonProps): React.ReactElement | null {
|
||||
const [open, setOpen] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
if (!isSuperAdmin) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => setOpen(true)}
|
||||
sx={{ fontFamily: d3roFontMono, fontSize: 11 }}
|
||||
>
|
||||
Change Role
|
||||
</Button>
|
||||
<RoleChangeDialog
|
||||
open={open}
|
||||
userId={userId}
|
||||
userName={userName}
|
||||
currentRole={currentRole}
|
||||
onClose={() => setOpen(false)}
|
||||
onSuccess={() => {
|
||||
setOpen(false)
|
||||
router.refresh()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -9,6 +9,8 @@ import DashboardIcon from '@mui/icons-material/Dashboard'
|
|||
import PeopleIcon from '@mui/icons-material/People'
|
||||
import SubscriptionsIcon from '@mui/icons-material/Subscriptions'
|
||||
import BarChartIcon from '@mui/icons-material/BarChart'
|
||||
import HistoryIcon from '@mui/icons-material/History'
|
||||
import ApiIcon from '@mui/icons-material/Api'
|
||||
import LogoutIcon from '@mui/icons-material/Logout'
|
||||
import { PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
|
||||
|
|
@ -19,6 +21,11 @@ const NAV_ITEMS = [
|
|||
{ key: 'users', path: '/users', label: 'Users', icon: <PeopleIcon /> },
|
||||
{ key: 'subscriptions', path: '/subscriptions', label: 'Subscriptions', icon: <SubscriptionsIcon /> },
|
||||
{ key: 'usage', path: '/usage', label: 'Usage', icon: <BarChartIcon /> },
|
||||
{ key: 'audit-log', path: '/audit-log', label: 'Audit Log', icon: <HistoryIcon /> },
|
||||
]
|
||||
|
||||
const EXTERNAL_LINKS = [
|
||||
{ key: 'swagger', href: '/admin-swagger/', label: 'API Docs', icon: <ApiIcon /> },
|
||||
]
|
||||
|
||||
export function AdminSidebar(): React.ReactElement {
|
||||
|
|
@ -78,6 +85,41 @@ export function AdminSidebar(): React.ReactElement {
|
|||
</ListItem>
|
||||
)
|
||||
})}
|
||||
<ListItem disablePadding sx={{ mt: 1 }}>
|
||||
<ListItemText
|
||||
primary="EXTERNAL"
|
||||
primaryTypographyProps={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: 10,
|
||||
color: d3roPalette.text.label,
|
||||
px: 2,
|
||||
pt: 1,
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
{EXTERNAL_LINKS.map((item) => (
|
||||
<ListItem key={item.key} disablePadding>
|
||||
<ListItemButton
|
||||
component="a"
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{ fontFamily: d3roFontMono }}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 36, color: d3roPalette.text.inactive }}>
|
||||
{item.icon}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={item.label}
|
||||
primaryTypographyProps={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: 13,
|
||||
color: d3roPalette.text.secondary,
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Box sx={{ p: 2, borderTop: `1px solid ${d3roPalette.border.default}` }}>
|
||||
|
|
|
|||
128
apps/admin/src/components/audit-diff-viewer.tsx
Normal file
128
apps/admin/src/components/audit-diff-viewer.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
'use client'
|
||||
|
||||
// apps/admin/src/components/audit-diff-viewer.tsx
|
||||
// before/after JSON diff 뷰어
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import { PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
|
||||
|
||||
interface AuditDiffViewerProps {
|
||||
beforeData: Record<string, unknown> | null
|
||||
afterData: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
interface DiffEntry {
|
||||
key: string
|
||||
before: unknown
|
||||
after: unknown
|
||||
type: 'added' | 'removed' | 'changed' | 'unchanged'
|
||||
}
|
||||
|
||||
function computeDiff(
|
||||
before: Record<string, unknown> | null,
|
||||
after: Record<string, unknown> | null
|
||||
): DiffEntry[] {
|
||||
const allKeys = new Set<string>([
|
||||
...Object.keys(before ?? {}),
|
||||
...Object.keys(after ?? {}),
|
||||
])
|
||||
|
||||
const entries: DiffEntry[] = []
|
||||
for (const key of allKeys) {
|
||||
const bVal = before?.[key]
|
||||
const aVal = after?.[key]
|
||||
const bStr = JSON.stringify(bVal)
|
||||
const aStr = JSON.stringify(aVal)
|
||||
|
||||
if (bVal === undefined) {
|
||||
entries.push({ key, before: undefined, after: aVal, type: 'added' })
|
||||
} else if (aVal === undefined) {
|
||||
entries.push({ key, before: bVal, after: undefined, type: 'removed' })
|
||||
} else if (bStr !== aStr) {
|
||||
entries.push({ key, before: bVal, after: aVal, type: 'changed' })
|
||||
} else {
|
||||
entries.push({ key, before: bVal, after: aVal, type: 'unchanged' })
|
||||
}
|
||||
}
|
||||
|
||||
// changed/added/removed first
|
||||
return entries.sort((a, b) => {
|
||||
const order = { changed: 0, added: 1, removed: 2, unchanged: 3 }
|
||||
return order[a.type] - order[b.type]
|
||||
})
|
||||
}
|
||||
|
||||
const typeColors: Record<DiffEntry['type'], string> = {
|
||||
added: d3roPalette.tag.green,
|
||||
removed: d3roPalette.tag.red,
|
||||
changed: d3roPalette.accent.amber,
|
||||
unchanged: d3roPalette.text.muted,
|
||||
}
|
||||
|
||||
const typeLabels: Record<DiffEntry['type'], string> = {
|
||||
added: '+',
|
||||
removed: '-',
|
||||
changed: '~',
|
||||
unchanged: ' ',
|
||||
}
|
||||
|
||||
function formatValue(val: unknown): string {
|
||||
if (val === undefined) return '(none)'
|
||||
if (val === null) return 'null'
|
||||
if (typeof val === 'string') return `"${val}"`
|
||||
return JSON.stringify(val)
|
||||
}
|
||||
|
||||
export function AuditDiffViewer({ beforeData, afterData }: AuditDiffViewerProps): React.ReactElement {
|
||||
if (!beforeData && !afterData) {
|
||||
return <PhosphorText variant="dim">No diff data</PhosphorText>
|
||||
}
|
||||
|
||||
const diff = computeDiff(beforeData, afterData)
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.small.size,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
borderRadius: 1,
|
||||
p: 1.5,
|
||||
overflow: 'auto',
|
||||
}}>
|
||||
{diff.map((entry) => (
|
||||
<Box
|
||||
key={entry.key}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
py: 0.25,
|
||||
opacity: entry.type === 'unchanged' ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ color: typeColors[entry.type], width: 12, flexShrink: 0, textAlign: 'center' }}>
|
||||
{typeLabels[entry.type]}
|
||||
</Box>
|
||||
<Box sx={{ color: d3roPalette.text.label, minWidth: 140, flexShrink: 0 }}>
|
||||
{entry.key}
|
||||
</Box>
|
||||
{entry.type === 'changed' ? (
|
||||
<Box>
|
||||
<Box component="span" sx={{ color: d3roPalette.tag.red, textDecoration: 'line-through' }}>
|
||||
{formatValue(entry.before)}
|
||||
</Box>
|
||||
<Box component="span" sx={{ mx: 0.5, color: d3roPalette.text.muted }}>{'->'}</Box>
|
||||
<Box component="span" sx={{ color: d3roPalette.tag.green }}>
|
||||
{formatValue(entry.after)}
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ color: typeColors[entry.type] }}>
|
||||
{entry.type === 'removed' ? formatValue(entry.before) : formatValue(entry.after)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
60
apps/admin/src/components/charts/dau-chart.tsx
Normal file
60
apps/admin/src/components/charts/dau-chart.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
'use client'
|
||||
|
||||
// apps/admin/src/components/charts/dau-chart.tsx
|
||||
// DAU/활성 유저 추이 — Line chart
|
||||
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
} from 'recharts'
|
||||
import { Box } from '@mui/material'
|
||||
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
|
||||
|
||||
interface DauRow {
|
||||
date: string
|
||||
active_users: number
|
||||
}
|
||||
|
||||
interface DauChartProps {
|
||||
data: DauRow[]
|
||||
}
|
||||
|
||||
export function DauChart({ data }: DauChartProps): React.ReactElement {
|
||||
return (
|
||||
<Box sx={{ width: '100%', height: 250 }}>
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={d3roPalette.border.subtle} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fill: d3roPalette.text.muted, fontFamily: d3roFontMono, fontSize: 10 }}
|
||||
tickFormatter={(val: string) => val.slice(5)}
|
||||
/>
|
||||
<YAxis tick={{ fill: d3roPalette.text.muted, fontFamily: d3roFontMono, fontSize: 10 }} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: d3roPalette.bg.card,
|
||||
border: `1px solid ${d3roPalette.border.default}`,
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: 11,
|
||||
color: d3roPalette.text.primary,
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="active_users"
|
||||
stroke={d3roPalette.accent.amber}
|
||||
strokeWidth={2}
|
||||
dot={{ fill: d3roPalette.accent.amber, r: 3 }}
|
||||
name="Active Users"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
88
apps/admin/src/components/charts/feature-usage-chart.tsx
Normal file
88
apps/admin/src/components/charts/feature-usage-chart.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
'use client'
|
||||
|
||||
// apps/admin/src/components/charts/feature-usage-chart.tsx
|
||||
// feature별 일간 API 호출 — StackedBar
|
||||
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from 'recharts'
|
||||
import { Box } from '@mui/material'
|
||||
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
|
||||
|
||||
interface FeatureUsageRow {
|
||||
date: string
|
||||
feature: string
|
||||
total_count: number
|
||||
unique_users: number
|
||||
}
|
||||
|
||||
interface FeatureUsageChartProps {
|
||||
data: FeatureUsageRow[]
|
||||
}
|
||||
|
||||
const FEATURE_COLORS: Record<string, string> = {
|
||||
llm_haiku: d3roPalette.accent.amber,
|
||||
llm_sonnet: d3roPalette.tag.green,
|
||||
llm_opus: d3roPalette.tag.purple,
|
||||
stt_transcribe: d3roPalette.tag.blue,
|
||||
translate: d3roPalette.tag.blue,
|
||||
}
|
||||
|
||||
const DEFAULT_COLOR = d3roPalette.text.secondary
|
||||
|
||||
export function FeatureUsageChart({ data }: FeatureUsageChartProps): React.ReactElement {
|
||||
// Pivot: group by date, features as columns
|
||||
const features = [...new Set(data.map(d => d.feature))]
|
||||
const dateMap = new Map<string, Record<string, string | number>>()
|
||||
|
||||
for (const row of data) {
|
||||
const existing = dateMap.get(row.date) ?? { date: row.date }
|
||||
existing[row.feature] = row.total_count
|
||||
dateMap.set(row.date, existing)
|
||||
}
|
||||
|
||||
const chartData = [...dateMap.values()].sort((a, b) =>
|
||||
String(a.date).localeCompare(String(b.date))
|
||||
)
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', height: 300 }}>
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={d3roPalette.border.subtle} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fill: d3roPalette.text.muted, fontFamily: d3roFontMono, fontSize: 10 }}
|
||||
tickFormatter={(val: string) => val.slice(5)}
|
||||
/>
|
||||
<YAxis tick={{ fill: d3roPalette.text.muted, fontFamily: d3roFontMono, fontSize: 10 }} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: d3roPalette.bg.card,
|
||||
border: `1px solid ${d3roPalette.border.default}`,
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: 11,
|
||||
color: d3roPalette.text.primary,
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontFamily: d3roFontMono, fontSize: 11 }} />
|
||||
{features.map((feature) => (
|
||||
<Bar
|
||||
key={feature}
|
||||
dataKey={feature}
|
||||
stackId="a"
|
||||
fill={FEATURE_COLORS[feature] ?? DEFAULT_COLOR}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
65
apps/admin/src/components/charts/top-users-chart.tsx
Normal file
65
apps/admin/src/components/charts/top-users-chart.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
'use client'
|
||||
|
||||
// apps/admin/src/components/charts/top-users-chart.tsx
|
||||
// 유저별 사용량 Top 20 — Horizontal bar
|
||||
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
} from 'recharts'
|
||||
import { Box } from '@mui/material'
|
||||
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
|
||||
|
||||
interface TopUserRow {
|
||||
user_id: string
|
||||
name: string | null
|
||||
total_count: number
|
||||
feature_count: number
|
||||
}
|
||||
|
||||
interface TopUsersChartProps {
|
||||
data: TopUserRow[]
|
||||
}
|
||||
|
||||
export function TopUsersChart({ data }: TopUsersChartProps): React.ReactElement {
|
||||
const chartData = data.map(row => ({
|
||||
name: row.name ?? row.user_id.substring(0, 8),
|
||||
total: row.total_count,
|
||||
features: row.feature_count,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', height: Math.max(250, data.length * 28) }}>
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={chartData} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={d3roPalette.border.subtle} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fill: d3roPalette.text.muted, fontFamily: d3roFontMono, fontSize: 10 }}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={100}
|
||||
tick={{ fill: d3roPalette.text.muted, fontFamily: d3roFontMono, fontSize: 10 }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: d3roPalette.bg.card,
|
||||
border: `1px solid ${d3roPalette.border.default}`,
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: 11,
|
||||
color: d3roPalette.text.primary,
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="total" fill={d3roPalette.accent.amber} name="Total Calls" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
93
apps/admin/src/components/memo-dialog.tsx
Normal file
93
apps/admin/src/components/memo-dialog.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
'use client'
|
||||
|
||||
// apps/admin/src/components/memo-dialog.tsx
|
||||
// 메모 입력 다이얼로그 — 감사로그 기록 시 필수
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Button,
|
||||
} from '@mui/material'
|
||||
import { PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
|
||||
|
||||
interface MemoDialogProps {
|
||||
open: boolean
|
||||
title: string
|
||||
description?: string
|
||||
onConfirm: (memo: string) => void
|
||||
onCancel: () => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function MemoDialog({ open, title, description, onConfirm, onCancel, loading }: MemoDialogProps): React.ReactElement {
|
||||
const [memo, setMemo] = useState('')
|
||||
|
||||
const handleConfirm = (): void => {
|
||||
if (memo.trim()) {
|
||||
onConfirm(memo.trim())
|
||||
setMemo('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = (): void => {
|
||||
setMemo('')
|
||||
onCancel()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleCancel}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{ sx: { bgcolor: d3roPalette.bg.card, color: d3roPalette.text.primary } }}
|
||||
>
|
||||
<DialogTitle sx={{ fontFamily: d3roFontMono }}>
|
||||
<PhosphorText variant="heading">{title}</PhosphorText>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{description && (
|
||||
<PhosphorText variant="dim" sx={{ display: 'block', mb: 2 }}>
|
||||
{description}
|
||||
</PhosphorText>
|
||||
)}
|
||||
<TextField
|
||||
autoFocus
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
placeholder="Reason for this action (required)..."
|
||||
value={memo}
|
||||
onChange={(e) => setMemo(e.target.value)}
|
||||
sx={{
|
||||
mt: 1,
|
||||
'& .MuiInputBase-root': {
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: 13,
|
||||
color: d3roPalette.text.primary,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={handleCancel} sx={{ fontFamily: d3roFontMono, color: d3roPalette.text.secondary }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={!memo.trim() || loading}
|
||||
variant="contained"
|
||||
sx={{ fontFamily: d3roFontMono }}
|
||||
>
|
||||
{loading ? 'Processing...' : 'Confirm'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
169
apps/admin/src/components/payment-history.tsx
Normal file
169
apps/admin/src/components/payment-history.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
'use client'
|
||||
|
||||
// apps/admin/src/components/payment-history.tsx
|
||||
// 결제 이력 패널 — DB + Payple 조회
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Box, Button, CircularProgress } from '@mui/material'
|
||||
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
|
||||
import { callAdminApi } from '@/lib/admin-api'
|
||||
|
||||
interface PaymentHistoryProps {
|
||||
userId: string
|
||||
}
|
||||
|
||||
interface AuditLogEntry {
|
||||
id: number
|
||||
action: string
|
||||
memo: string
|
||||
created_at: string
|
||||
before_data: Record<string, unknown> | null
|
||||
after_data: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
interface PaymentData {
|
||||
subscription: Record<string, unknown> | null
|
||||
auditLogs: AuditLogEntry[]
|
||||
paypleHistory?: Record<string, unknown>
|
||||
paypleError?: string
|
||||
}
|
||||
|
||||
export function PaymentHistory({ userId }: PaymentHistoryProps): React.ReactElement {
|
||||
const [data, setData] = useState<PaymentData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [paypleLoading, setPaypleLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const load = async (): Promise<void> => {
|
||||
try {
|
||||
const result = await callAdminApi<PaymentData>(`admin-payments?userId=${userId}`)
|
||||
setData(result)
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
void load()
|
||||
}, [userId])
|
||||
|
||||
const loadPayple = async (): Promise<void> => {
|
||||
setPaypleLoading(true)
|
||||
try {
|
||||
const result = await callAdminApi<PaymentData>(`admin-payments?userId=${userId}&source=payple`)
|
||||
setData(result)
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setPaypleLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress size={24} sx={{ color: d3roPalette.accent.amber }} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <PhosphorText variant="dim">Failed to load payment data</PhosphorText>
|
||||
}
|
||||
|
||||
const sub = data.subscription
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{/* Subscription summary */}
|
||||
{sub && (
|
||||
<MetalCard>
|
||||
<Box sx={{ p: 1 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>PAYMENT INFO</PhosphorText>
|
||||
<Box sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
<Row label="PROVIDER" value={((sub.payment_provider as string) ?? 'none').toUpperCase()} />
|
||||
<Row label="PAYPLE PAYER ID" value={(sub.payple_payer_id as string) ?? '-'} />
|
||||
<Row label="PAYPLE OID" value={(sub.payple_pay_oid as string) ?? '-'} />
|
||||
<Row label="RENEWAL FAILURES" value={String(sub.renewal_failures ?? 0)} />
|
||||
</Box>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
)}
|
||||
|
||||
{/* Payple direct query */}
|
||||
<MetalCard>
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<PhosphorText variant="label">PAYPLE HISTORY</PhosphorText>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => void loadPayple()}
|
||||
disabled={paypleLoading}
|
||||
sx={{ fontFamily: d3roFontMono, fontSize: 11 }}
|
||||
>
|
||||
{paypleLoading ? 'Loading...' : 'Fetch from Payple'}
|
||||
</Button>
|
||||
</Box>
|
||||
{data.paypleHistory ? (
|
||||
<Box sx={{
|
||||
fontFamily: d3roFontMono, fontSize: d3roTypo.small.size,
|
||||
bgcolor: d3roPalette.bg.inset, borderRadius: 1, p: 1, maxHeight: 300, overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-all', color: d3roPalette.text.primary,
|
||||
}}>
|
||||
{JSON.stringify(data.paypleHistory, null, 2)}
|
||||
</Box>
|
||||
) : data.paypleError ? (
|
||||
<PhosphorText variant="dim" sx={{ color: d3roPalette.tag.red }}>{data.paypleError}</PhosphorText>
|
||||
) : (
|
||||
<PhosphorText variant="dim">Click "Fetch from Payple" to query payment history</PhosphorText>
|
||||
)}
|
||||
</Box>
|
||||
</MetalCard>
|
||||
|
||||
{/* Audit log timeline */}
|
||||
<MetalCard>
|
||||
<Box sx={{ p: 1 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>SUBSCRIPTION TIMELINE</PhosphorText>
|
||||
{data.auditLogs.length === 0 ? (
|
||||
<PhosphorText variant="dim">No subscription changes recorded</PhosphorText>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{data.auditLogs.map((log) => (
|
||||
<Box
|
||||
key={log.id}
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.small.size,
|
||||
borderLeft: `2px solid ${d3roPalette.accent.amber}`,
|
||||
pl: 1.5,
|
||||
py: 0.5,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'baseline' }}>
|
||||
<Box component="span" sx={{ color: d3roPalette.text.muted, fontSize: 10 }}>
|
||||
{new Date(log.created_at).toLocaleString()}
|
||||
</Box>
|
||||
<Box component="span" sx={{ color: d3roPalette.accent.amber }}>
|
||||
{log.action}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ color: d3roPalette.text.secondary, mt: 0.25 }}>{log.memo}</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }): React.ReactElement {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: d3roPalette.text.label }}>{label}</span>
|
||||
<span style={{ color: d3roPalette.text.primary }}>{value}</span>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
130
apps/admin/src/components/role-change-dialog.tsx
Normal file
130
apps/admin/src/components/role-change-dialog.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
'use client'
|
||||
|
||||
// apps/admin/src/components/role-change-dialog.tsx
|
||||
// role 변경 확인 다이얼로그 — super_admin 전용
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Button,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
} from '@mui/material'
|
||||
import { PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
|
||||
import { callAdminApi } from '@/lib/admin-api'
|
||||
|
||||
type Role = 'user' | 'admin' | 'super_admin'
|
||||
|
||||
interface RoleChangeDialogProps {
|
||||
open: boolean
|
||||
userId: string
|
||||
userName: string | null
|
||||
currentRole: Role
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export function RoleChangeDialog({
|
||||
open, userId, userName, currentRole, onClose, onSuccess,
|
||||
}: RoleChangeDialogProps): React.ReactElement {
|
||||
const [newRole, setNewRole] = useState<Role>(currentRole)
|
||||
const [memo, setMemo] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleConfirm = async (): Promise<void> => {
|
||||
if (!memo.trim() || newRole === currentRole) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
await callAdminApi('admin-users', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ userId, newRole, memo: memo.trim() }),
|
||||
})
|
||||
setMemo('')
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to change role')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{ sx: { bgcolor: d3roPalette.bg.card, color: d3roPalette.text.primary } }}
|
||||
>
|
||||
<DialogTitle sx={{ fontFamily: d3roFontMono }}>
|
||||
<PhosphorText variant="heading">CHANGE USER ROLE</PhosphorText>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<PhosphorText variant="dim" sx={{ display: 'block', mb: 2 }}>
|
||||
{userName ?? userId.substring(0, 8)} : {currentRole}
|
||||
</PhosphorText>
|
||||
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<InputLabel sx={{ fontFamily: d3roFontMono, color: d3roPalette.text.label }}>New Role</InputLabel>
|
||||
<Select
|
||||
value={newRole}
|
||||
onChange={(e) => setNewRole(e.target.value as Role)}
|
||||
label="New Role"
|
||||
sx={{ fontFamily: d3roFontMono, color: d3roPalette.text.primary }}
|
||||
>
|
||||
<MenuItem value="user">user</MenuItem>
|
||||
<MenuItem value="admin">admin</MenuItem>
|
||||
<MenuItem value="super_admin">super_admin</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
placeholder="Reason for role change (required)..."
|
||||
value={memo}
|
||||
onChange={(e) => setMemo(e.target.value)}
|
||||
sx={{
|
||||
'& .MuiInputBase-root': {
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: 13,
|
||||
color: d3roPalette.text.primary,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<PhosphorText variant="dim" sx={{ display: 'block', mt: 1, color: d3roPalette.tag.red }}>
|
||||
{error}
|
||||
</PhosphorText>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={onClose} sx={{ fontFamily: d3roFontMono, color: d3roPalette.text.secondary }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleConfirm()}
|
||||
disabled={!memo.trim() || newRole === currentRole || loading}
|
||||
variant="contained"
|
||||
color={newRole === 'super_admin' ? 'error' : 'primary'}
|
||||
sx={{ fontFamily: d3roFontMono }}
|
||||
>
|
||||
{loading ? 'Changing...' : 'Change Role'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
187
apps/admin/src/components/subscription-form.tsx
Normal file
187
apps/admin/src/components/subscription-form.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
'use client'
|
||||
|
||||
// apps/admin/src/components/subscription-form.tsx
|
||||
// 구독 생성/수정 공용 폼
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
Button,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Alert,
|
||||
} from '@mui/material'
|
||||
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
|
||||
import { callAdminApi } from '@/lib/admin-api'
|
||||
|
||||
type Tier = 'free' | 'pro' | 'pro_plus'
|
||||
type SubStatus = 'active' | 'canceled' | 'past_due' | 'expired'
|
||||
|
||||
interface SubscriptionFormProps {
|
||||
mode: 'create' | 'edit'
|
||||
userId: string
|
||||
initial?: {
|
||||
tier: Tier
|
||||
status: SubStatus
|
||||
currentPeriodEnd: string | null
|
||||
overageCredits: number
|
||||
adminNote: string | null
|
||||
}
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export function SubscriptionForm({ mode, userId, initial, onSuccess }: SubscriptionFormProps): React.ReactElement {
|
||||
const [tier, setTier] = useState<Tier>(initial?.tier ?? 'free')
|
||||
const [status, setStatus] = useState<SubStatus>(initial?.status ?? 'active')
|
||||
const [periodEnd, setPeriodEnd] = useState(initial?.currentPeriodEnd?.split('T')[0] ?? '')
|
||||
const [overageCredits, setOverageCredits] = useState(initial?.overageCredits ?? 0)
|
||||
const [adminNote, setAdminNote] = useState(initial?.adminNote ?? '')
|
||||
const [memo, setMemo] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
if (!memo.trim()) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setSuccess(false)
|
||||
|
||||
try {
|
||||
if (mode === 'create') {
|
||||
await callAdminApi('admin-subscriptions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
userId,
|
||||
tier,
|
||||
status,
|
||||
currentPeriodEnd: periodEnd ? new Date(periodEnd).toISOString() : undefined,
|
||||
adminNote: adminNote || undefined,
|
||||
memo: memo.trim(),
|
||||
}),
|
||||
})
|
||||
} else {
|
||||
await callAdminApi(`admin-subscriptions?userId=${userId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
tier,
|
||||
status,
|
||||
currentPeriodEnd: periodEnd ? new Date(periodEnd).toISOString() : undefined,
|
||||
overageCredits,
|
||||
adminNote: adminNote || undefined,
|
||||
memo: memo.trim(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
setSuccess(true)
|
||||
setMemo('')
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Operation failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const inputSx = {
|
||||
'& .MuiInputBase-root': {
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: 13,
|
||||
color: d3roPalette.text.primary,
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<MetalCard>
|
||||
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<PhosphorText variant="label">
|
||||
{mode === 'create' ? 'CREATE SUBSCRIPTION' : 'EDIT SUBSCRIPTION'}
|
||||
</PhosphorText>
|
||||
|
||||
<FormControl fullWidth sx={inputSx}>
|
||||
<InputLabel sx={{ fontFamily: d3roFontMono }}>Tier</InputLabel>
|
||||
<Select value={tier} onChange={(e) => setTier(e.target.value as Tier)} label="Tier">
|
||||
<MenuItem value="free">FREE</MenuItem>
|
||||
<MenuItem value="pro">PRO</MenuItem>
|
||||
<MenuItem value="pro_plus">PRO+</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth sx={inputSx}>
|
||||
<InputLabel sx={{ fontFamily: d3roFontMono }}>Status</InputLabel>
|
||||
<Select value={status} onChange={(e) => setStatus(e.target.value as SubStatus)} label="Status">
|
||||
<MenuItem value="active">ACTIVE</MenuItem>
|
||||
<MenuItem value="canceled">CANCELED</MenuItem>
|
||||
<MenuItem value="past_due">PAST DUE</MenuItem>
|
||||
<MenuItem value="expired">EXPIRED</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Period End"
|
||||
type="date"
|
||||
value={periodEnd}
|
||||
onChange={(e) => setPeriodEnd(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
sx={inputSx}
|
||||
/>
|
||||
|
||||
{mode === 'edit' && (
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Overage Credits"
|
||||
type="number"
|
||||
value={overageCredits}
|
||||
onChange={(e) => setOverageCredits(parseInt(e.target.value, 10) || 0)}
|
||||
sx={inputSx}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Admin Note"
|
||||
multiline
|
||||
rows={2}
|
||||
value={adminNote}
|
||||
onChange={(e) => setAdminNote(e.target.value)}
|
||||
sx={inputSx}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Memo (required for audit log)"
|
||||
multiline
|
||||
rows={2}
|
||||
value={memo}
|
||||
onChange={(e) => setMemo(e.target.value)}
|
||||
placeholder="Reason for this action..."
|
||||
sx={{
|
||||
...inputSx,
|
||||
'& .MuiInputBase-root': {
|
||||
...inputSx['& .MuiInputBase-root'],
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{error && <Alert severity="error" sx={{ fontFamily: d3roFontMono }}>{error}</Alert>}
|
||||
{success && <Alert severity="success" sx={{ fontFamily: d3roFontMono }}>Operation successful</Alert>}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={!memo.trim() || loading}
|
||||
sx={{ fontFamily: d3roFontMono, alignSelf: 'flex-end' }}
|
||||
>
|
||||
{loading ? 'Processing...' : mode === 'create' ? 'Create' : 'Update'}
|
||||
</Button>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
)
|
||||
}
|
||||
39
apps/admin/src/lib/admin-api.ts
Normal file
39
apps/admin/src/lib/admin-api.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// apps/admin/src/lib/admin-api.ts
|
||||
// Edge Function 호출 헬퍼 — 클라이언트 컴포넌트용
|
||||
|
||||
import { getSupabaseBrowserClient } from './supabase-browser'
|
||||
|
||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''
|
||||
|
||||
interface AdminApiOptions extends Omit<RequestInit, 'headers'> {
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
export async function callAdminApi<T = Record<string, unknown>>(
|
||||
path: string,
|
||||
options: AdminApiOptions = {}
|
||||
): Promise<T> {
|
||||
const supabase = getSupabaseBrowserClient()
|
||||
const { data: { session } } = await supabase.auth.getSession()
|
||||
|
||||
if (!session?.access_token) {
|
||||
throw new Error('Not authenticated')
|
||||
}
|
||||
|
||||
const response = await fetch(`${SUPABASE_URL}/functions/v1/${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
|
||||
const data = await response.json() as T & { error?: string }
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error ?? `API error: ${response.status}`)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
|
@ -1,15 +1,19 @@
|
|||
// apps/admin/src/lib/admin-guard.ts
|
||||
// RSC용 admin 가드 — app_metadata.role='admin' 체크
|
||||
// RSC용 admin 가드 — app_metadata.role = 'admin' | 'super_admin'
|
||||
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSupabaseServerClient } from './supabase-server'
|
||||
|
||||
export type AdminRole = 'admin' | 'super_admin'
|
||||
|
||||
export interface AdminUser {
|
||||
id: string
|
||||
email: string | null
|
||||
name: string | null
|
||||
role: AdminRole
|
||||
}
|
||||
|
||||
/** admin 이상 (admin, super_admin) */
|
||||
export async function requireAdmin(): Promise<AdminUser> {
|
||||
const supabase = await getSupabaseServerClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
|
@ -18,13 +22,11 @@ export async function requireAdmin(): Promise<AdminUser> {
|
|||
redirect('/login')
|
||||
}
|
||||
|
||||
// app_metadata.role 체크 (JWT에 포함, RLS 재귀 없음)
|
||||
const role = (user.app_metadata as Record<string, unknown>)?.role as string | undefined
|
||||
if (role !== 'admin') {
|
||||
if (role !== 'admin' && role !== 'super_admin') {
|
||||
redirect('/unauthorized')
|
||||
}
|
||||
|
||||
// profile 이름 조회 (자기 자신은 기존 RLS로 접근 가능)
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('name')
|
||||
|
|
@ -35,5 +37,20 @@ export async function requireAdmin(): Promise<AdminUser> {
|
|||
id: user.id,
|
||||
email: user.email ?? null,
|
||||
name: (profile as { name: string | null } | null)?.name ?? null,
|
||||
role: role as AdminRole,
|
||||
}
|
||||
}
|
||||
|
||||
/** super_admin 전용 */
|
||||
export async function requireSuperAdmin(): Promise<AdminUser> {
|
||||
const adminUser = await requireAdmin()
|
||||
if (adminUser.role !== 'super_admin') {
|
||||
redirect('/unauthorized')
|
||||
}
|
||||
return adminUser
|
||||
}
|
||||
|
||||
/** role이 super_admin인지 체크 */
|
||||
export function isSuperAdmin(user: AdminUser): boolean {
|
||||
return user.role === 'super_admin'
|
||||
}
|
||||
|
|
|
|||
94
package-lock.json
generated
94
package-lock.json
generated
|
|
@ -40,7 +40,8 @@
|
|||
"@supabase/supabase-js": "^2.103.0",
|
||||
"next": "^15.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^2.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.0",
|
||||
|
|
@ -48,6 +49,63 @@
|
|||
"@types/react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"apps/admin/node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"apps/admin/node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"apps/admin/node_modules/recharts": {
|
||||
"version": "2.15.4",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
|
||||
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0",
|
||||
"eventemitter3": "^4.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"react-is": "^18.3.1",
|
||||
"react-smooth": "^4.0.4",
|
||||
"recharts-scale": "^0.4.4",
|
||||
"tiny-invariant": "^1.3.1",
|
||||
"victory-vendor": "^36.6.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"apps/admin/node_modules/victory-vendor": {
|
||||
"version": "36.9.2",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
||||
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"apps/desktop": {
|
||||
"name": "@d3ro/desktop",
|
||||
"version": "1.0.0",
|
||||
|
|
@ -9879,6 +9937,15 @@
|
|||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
|
||||
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-json-stable-stringify": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
|
|
@ -11903,7 +11970,6 @@
|
|||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
|
|
@ -15260,6 +15326,21 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-smooth": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
||||
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-equals": "^5.0.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react-transition-group": "^4.4.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
|
|
@ -15419,6 +15500,15 @@
|
|||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts-scale": {
|
||||
"version": "0.4.5",
|
||||
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
|
||||
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decimal.js-light": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
|
|
|
|||
|
|
@ -123,5 +123,17 @@ verify_jwt = true
|
|||
[functions.search-knowledge]
|
||||
verify_jwt = true
|
||||
|
||||
[functions.admin-users]
|
||||
verify_jwt = true
|
||||
|
||||
[functions.admin-subscriptions]
|
||||
verify_jwt = true
|
||||
|
||||
[functions.admin-payments]
|
||||
verify_jwt = true
|
||||
|
||||
[functions.admin-audit-log]
|
||||
verify_jwt = true
|
||||
|
||||
[analytics]
|
||||
enabled = false
|
||||
|
|
|
|||
45
server/supabase/functions/_shared/admin-auth.ts
Normal file
45
server/supabase/functions/_shared/admin-auth.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// server/supabase/functions/_shared/admin-auth.ts
|
||||
// Admin/Super-admin 권한 검증 — requireUser 확장
|
||||
|
||||
// @ts-expect-error — Deno 런타임 import
|
||||
import type { User } from 'https://esm.sh/@supabase/supabase-js@2.39.7'
|
||||
import { requireUser, type AuthError } from './auth.ts'
|
||||
|
||||
export type AdminRole = 'admin' | 'super_admin'
|
||||
|
||||
/**
|
||||
* admin 이상 권한 필요 (admin, super_admin).
|
||||
* 실패 시 AuthError throw.
|
||||
*/
|
||||
export async function requireAdmin(req: Request): Promise<User> {
|
||||
const user = await requireUser(req)
|
||||
const role = (user.app_metadata as Record<string, unknown>)?.role as string | undefined
|
||||
if (role !== 'admin' && role !== 'super_admin') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
||||
throw { status: 403, message: 'Admin access required' } as AuthError
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
/**
|
||||
* super_admin 전용 권한 필요.
|
||||
* 실패 시 AuthError throw.
|
||||
*/
|
||||
export async function requireSuperAdmin(req: Request): Promise<User> {
|
||||
const user = await requireUser(req)
|
||||
const role = (user.app_metadata as Record<string, unknown>)?.role as string | undefined
|
||||
if (role !== 'super_admin') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
||||
throw { status: 403, message: 'Super admin access required' } as AuthError
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 유저의 admin role 반환. admin이 아니면 null.
|
||||
*/
|
||||
export function getAdminRole(user: User): AdminRole | null {
|
||||
const role = (user.app_metadata as Record<string, unknown>)?.role as string | undefined
|
||||
if (role === 'admin' || role === 'super_admin') return role
|
||||
return null
|
||||
}
|
||||
37
server/supabase/functions/_shared/audit.ts
Normal file
37
server/supabase/functions/_shared/audit.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// server/supabase/functions/_shared/audit.ts
|
||||
// 감사로그 기록 유틸리티
|
||||
|
||||
// @ts-expect-error — Deno 런타임 import
|
||||
import type { SupabaseClient } from 'https://esm.sh/@supabase/supabase-js@2.39.7'
|
||||
|
||||
export interface AuditLogEntry {
|
||||
adminId: string
|
||||
action: string // 'subscription.create', 'subscription.update', 'subscription.delete', 'user.role_change'
|
||||
targetType: string // 'subscription', 'profile'
|
||||
targetId: string
|
||||
beforeData: Record<string, unknown> | null
|
||||
afterData: Record<string, unknown> | null
|
||||
memo: string
|
||||
}
|
||||
|
||||
/**
|
||||
* audit_log 테이블에 감사 기록을 삽입한다.
|
||||
* service_role 클라이언트를 사용해야 RLS를 우회한다.
|
||||
*/
|
||||
export async function writeAuditLog(
|
||||
supabase: SupabaseClient,
|
||||
entry: AuditLogEntry
|
||||
): Promise<void> {
|
||||
const { error } = await supabase.from('audit_log').insert({
|
||||
admin_id: entry.adminId,
|
||||
action: entry.action,
|
||||
target_type: entry.targetType,
|
||||
target_id: entry.targetId,
|
||||
before_data: entry.beforeData,
|
||||
after_data: entry.afterData,
|
||||
memo: entry.memo,
|
||||
})
|
||||
if (error) {
|
||||
throw new Error(`Failed to write audit log: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ export const corsHeaders: Record<string, string> = {
|
|||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers':
|
||||
'authorization, x-client-info, apikey, content-type',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS'
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS'
|
||||
}
|
||||
|
||||
export function handleCorsPreflightRequest(req: Request): Response | null {
|
||||
|
|
|
|||
113
server/supabase/functions/admin-audit-log/index.ts
Normal file
113
server/supabase/functions/admin-audit-log/index.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// server/supabase/functions/admin-audit-log/index.ts
|
||||
// 감사로그 조회 — admin 이상
|
||||
|
||||
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
||||
import { authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
||||
import { requireAdmin } from '../_shared/admin-auth.ts'
|
||||
import { createServiceRoleClient } from '../_shared/quota.ts'
|
||||
|
||||
function jsonResponse(body: Record<string, unknown> | Record<string, unknown>[], status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
// @ts-expect-error — Deno 런타임 전역
|
||||
Deno.serve(async (req: Request) => {
|
||||
const preflight = handleCorsPreflightRequest(req)
|
||||
if (preflight) return preflight
|
||||
|
||||
if (req.method !== 'GET') {
|
||||
return jsonResponse({ error: 'Method not allowed' }, 405)
|
||||
}
|
||||
|
||||
try {
|
||||
await requireAdmin(req)
|
||||
const url = new URL(req.url)
|
||||
const serviceClient = createServiceRoleClient()
|
||||
|
||||
// 단건 상세
|
||||
const logId = url.searchParams.get('id')
|
||||
if (logId) {
|
||||
const { data: log, error } = await serviceClient
|
||||
.from('audit_log')
|
||||
.select('*')
|
||||
.eq('id', parseInt(logId, 10))
|
||||
.maybeSingle()
|
||||
|
||||
if (error) return jsonResponse({ error: error.message }, 500)
|
||||
if (!log) return jsonResponse({ error: 'Audit log not found' }, 404)
|
||||
|
||||
// admin 프로필 정보 함께
|
||||
const { data: adminProfile } = await serviceClient
|
||||
.from('profiles')
|
||||
.select('id, name, avatar_url')
|
||||
.eq('id', (log as Record<string, unknown>).admin_id)
|
||||
.maybeSingle()
|
||||
|
||||
return jsonResponse({
|
||||
log: log as unknown as Record<string, unknown>,
|
||||
admin: adminProfile as unknown as Record<string, unknown>,
|
||||
})
|
||||
}
|
||||
|
||||
// 목록 조회
|
||||
const page = parseInt(url.searchParams.get('page') ?? '1', 10)
|
||||
const limit = parseInt(url.searchParams.get('limit') ?? '20', 10)
|
||||
const targetType = url.searchParams.get('target_type') ?? ''
|
||||
const adminId = url.searchParams.get('admin_id') ?? ''
|
||||
const fromDate = url.searchParams.get('from') ?? ''
|
||||
const toDate = url.searchParams.get('to') ?? ''
|
||||
const targetId = url.searchParams.get('target_id') ?? ''
|
||||
const rangeFrom = (page - 1) * limit
|
||||
const rangeTo = rangeFrom + limit - 1
|
||||
|
||||
let query = serviceClient
|
||||
.from('audit_log')
|
||||
.select('*', { count: 'exact' })
|
||||
|
||||
if (targetType) query = query.eq('target_type', targetType)
|
||||
if (adminId) query = query.eq('admin_id', adminId)
|
||||
if (targetId) query = query.eq('target_id', targetId)
|
||||
if (fromDate) query = query.gte('created_at', `${fromDate}T00:00:00Z`)
|
||||
if (toDate) query = query.lte('created_at', `${toDate}T23:59:59Z`)
|
||||
|
||||
const { data, count, error } = await query
|
||||
.order('created_at', { ascending: false })
|
||||
.range(rangeFrom, rangeTo)
|
||||
|
||||
if (error) return jsonResponse({ error: error.message }, 500)
|
||||
|
||||
// admin 이름 매핑
|
||||
const logs = (data ?? []) as unknown as Record<string, unknown>[]
|
||||
const adminIds = [...new Set(logs.map(l => l.admin_id as string))]
|
||||
|
||||
let adminMap: Record<string, string> = {}
|
||||
if (adminIds.length > 0) {
|
||||
const { data: admins } = await serviceClient
|
||||
.from('profiles')
|
||||
.select('id, name')
|
||||
.in('id', adminIds)
|
||||
|
||||
if (admins) {
|
||||
adminMap = Object.fromEntries(
|
||||
(admins as Array<{ id: string; name: string | null }>).map(a => [a.id, a.name ?? 'Unknown'])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const enrichedLogs = logs.map(log => ({
|
||||
...log,
|
||||
admin_name: adminMap[log.admin_id as string] ?? 'Unknown',
|
||||
}))
|
||||
|
||||
return jsonResponse({ logs: enrichedLogs, total: count ?? 0, page, limit })
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && 'status' in err && 'message' in err) {
|
||||
return authErrorResponse(err as AuthError, corsHeaders)
|
||||
}
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
return jsonResponse({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
95
server/supabase/functions/admin-payments/index.ts
Normal file
95
server/supabase/functions/admin-payments/index.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// server/supabase/functions/admin-payments/index.ts
|
||||
// 결제 이력 조회 — DB 기반 + Payple API 직접 조회
|
||||
|
||||
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
||||
import { authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
||||
import { requireAdmin } from '../_shared/admin-auth.ts'
|
||||
import { createServiceRoleClient } from '../_shared/quota.ts'
|
||||
import { getPaypleConfig, paypleAuth } from '../_shared/payple.ts'
|
||||
|
||||
function jsonResponse(body: Record<string, unknown> | Record<string, unknown>[], status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
// @ts-expect-error — Deno 런타임 전역
|
||||
Deno.serve(async (req: Request) => {
|
||||
const preflight = handleCorsPreflightRequest(req)
|
||||
if (preflight) return preflight
|
||||
|
||||
if (req.method !== 'GET') {
|
||||
return jsonResponse({ error: 'Method not allowed' }, 405)
|
||||
}
|
||||
|
||||
try {
|
||||
await requireAdmin(req)
|
||||
const url = new URL(req.url)
|
||||
const userId = url.searchParams.get('userId')
|
||||
const source = url.searchParams.get('source') // 'db' | 'payple' | null(=db)
|
||||
const serviceClient = createServiceRoleClient()
|
||||
|
||||
if (!userId) {
|
||||
return jsonResponse({ error: 'userId query param required' }, 400)
|
||||
}
|
||||
|
||||
// ── DB 조회 (기본) ──
|
||||
// 구독 현황
|
||||
const { data: sub } = await serviceClient
|
||||
.from('subscriptions')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle()
|
||||
|
||||
// 감사로그에서 구독 관련 액션만
|
||||
const { data: auditLogs } = await serviceClient
|
||||
.from('audit_log')
|
||||
.select('*')
|
||||
.eq('target_id', userId)
|
||||
.eq('target_type', 'subscription')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50)
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
subscription: sub,
|
||||
auditLogs: auditLogs ?? [],
|
||||
}
|
||||
|
||||
// ── Payple API 직접 조회 (요청 시) ──
|
||||
if (source === 'payple' && sub?.payple_payer_id) {
|
||||
try {
|
||||
const config = getPaypleConfig()
|
||||
const auth = await paypleAuth(config, { payWork: 'TSRCH' })
|
||||
|
||||
// Payple 결제 내역 조회
|
||||
const paypleResponse = await fetch(`${config.baseUrl}/php/PayCardListAct.php`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
PCD_CST_ID: auth.PCD_CST_ID,
|
||||
PCD_CUST_KEY: auth.PCD_CUST_KEY,
|
||||
PCD_AUTH_KEY: auth.PCD_AUTH_KEY,
|
||||
PCD_PAYER_ID: sub.payple_payer_id,
|
||||
PCD_PAY_YEAR: new Date().getFullYear().toString(),
|
||||
PCD_PAY_MONTH: '',
|
||||
}),
|
||||
})
|
||||
|
||||
const paypleData = await paypleResponse.json()
|
||||
result.paypleHistory = paypleData
|
||||
} catch (paypleErr) {
|
||||
const errMsg = paypleErr instanceof Error ? paypleErr.message : 'Payple API error'
|
||||
result.paypleError = errMsg
|
||||
}
|
||||
}
|
||||
|
||||
return jsonResponse(result)
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && 'status' in err && 'message' in err) {
|
||||
return authErrorResponse(err as AuthError, corsHeaders)
|
||||
}
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
return jsonResponse({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
273
server/supabase/functions/admin-subscriptions/index.ts
Normal file
273
server/supabase/functions/admin-subscriptions/index.ts
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
// server/supabase/functions/admin-subscriptions/index.ts
|
||||
// 구독 CRUD — admin: 조회 / super_admin: 생성/수정/삭제
|
||||
|
||||
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
||||
import { authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
||||
import { requireAdmin, requireSuperAdmin } from '../_shared/admin-auth.ts'
|
||||
import { createServiceRoleClient } from '../_shared/quota.ts'
|
||||
import { writeAuditLog } from '../_shared/audit.ts'
|
||||
|
||||
function jsonResponse(body: Record<string, unknown> | Record<string, unknown>[], status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
interface CreateBody {
|
||||
userId: string
|
||||
tier: 'free' | 'pro' | 'pro_plus'
|
||||
status: 'active' | 'canceled' | 'past_due' | 'expired'
|
||||
currentPeriodEnd?: string
|
||||
adminNote?: string
|
||||
memo: string
|
||||
}
|
||||
|
||||
interface UpdateBody {
|
||||
tier?: 'free' | 'pro' | 'pro_plus'
|
||||
status?: 'active' | 'canceled' | 'past_due' | 'expired'
|
||||
currentPeriodEnd?: string
|
||||
overageCredits?: number
|
||||
adminNote?: string
|
||||
memo: string
|
||||
}
|
||||
|
||||
interface DeleteBody {
|
||||
memo: string
|
||||
}
|
||||
|
||||
// @ts-expect-error — Deno 런타임 전역
|
||||
Deno.serve(async (req: Request) => {
|
||||
const preflight = handleCorsPreflightRequest(req)
|
||||
if (preflight) return preflight
|
||||
|
||||
try {
|
||||
const url = new URL(req.url)
|
||||
const serviceClient = createServiceRoleClient()
|
||||
|
||||
// ── GET: 목록/상세 ──
|
||||
if (req.method === 'GET') {
|
||||
await requireAdmin(req)
|
||||
|
||||
const userId = url.searchParams.get('userId')
|
||||
|
||||
if (userId) {
|
||||
const { data: sub, error } = await serviceClient
|
||||
.from('subscriptions')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) return jsonResponse({ error: error.message }, 500)
|
||||
if (!sub) return jsonResponse({ error: 'Subscription not found' }, 404)
|
||||
|
||||
// 해당 유저 프로필도 함께
|
||||
const { data: profile } = await serviceClient
|
||||
.from('profiles')
|
||||
.select('id, name, tier, role')
|
||||
.eq('id', userId)
|
||||
.maybeSingle()
|
||||
|
||||
return jsonResponse({ subscription: sub, profile })
|
||||
}
|
||||
|
||||
// 목록
|
||||
const page = parseInt(url.searchParams.get('page') ?? '1', 10)
|
||||
const limit = parseInt(url.searchParams.get('limit') ?? '20', 10)
|
||||
const statusFilter = url.searchParams.get('status') ?? ''
|
||||
const tierFilter = url.searchParams.get('tier') ?? ''
|
||||
const from = (page - 1) * limit
|
||||
const to = from + limit - 1
|
||||
|
||||
let query = serviceClient
|
||||
.from('subscriptions')
|
||||
.select('*, profiles!subscriptions_user_id_fkey(name, avatar_url)', { count: 'exact' })
|
||||
|
||||
if (statusFilter) query = query.eq('status', statusFilter)
|
||||
if (tierFilter) query = query.eq('tier', tierFilter)
|
||||
|
||||
const { data, count, error } = await query
|
||||
.order('updated_at', { ascending: false })
|
||||
.range(from, to)
|
||||
|
||||
if (error) return jsonResponse({ error: error.message }, 500)
|
||||
|
||||
return jsonResponse({ subscriptions: data ?? [], total: count ?? 0, page, limit })
|
||||
}
|
||||
|
||||
// ── POST: 생성 (super_admin) ──
|
||||
if (req.method === 'POST') {
|
||||
const admin = await requireSuperAdmin(req)
|
||||
const body = (await req.json()) as CreateBody
|
||||
|
||||
if (!body.userId || !body.tier || !body.memo) {
|
||||
return jsonResponse({ error: 'userId, tier, memo are required' }, 400)
|
||||
}
|
||||
|
||||
// 기존 구독 확인
|
||||
const { data: existing } = await serviceClient
|
||||
.from('subscriptions')
|
||||
.select('id')
|
||||
.eq('user_id', body.userId)
|
||||
.maybeSingle()
|
||||
|
||||
if (existing) {
|
||||
return jsonResponse({ error: 'Subscription already exists for this user. Use PATCH to update.' }, 409)
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const newSub = {
|
||||
user_id: body.userId,
|
||||
tier: body.tier,
|
||||
status: body.status ?? 'active',
|
||||
payment_provider: 'none',
|
||||
current_period_start: now,
|
||||
current_period_end: body.currentPeriodEnd ?? null,
|
||||
admin_note: body.adminNote ?? null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
|
||||
const { data: created, error } = await serviceClient
|
||||
.from('subscriptions')
|
||||
.insert(newSub)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) return jsonResponse({ error: error.message }, 500)
|
||||
|
||||
// profiles.tier 동기화
|
||||
await serviceClient
|
||||
.from('profiles')
|
||||
.update({ tier: body.tier, updated_at: now })
|
||||
.eq('id', body.userId)
|
||||
|
||||
await writeAuditLog(serviceClient, {
|
||||
adminId: admin.id,
|
||||
action: 'subscription.create',
|
||||
targetType: 'subscription',
|
||||
targetId: body.userId,
|
||||
beforeData: null,
|
||||
afterData: created as unknown as Record<string, unknown>,
|
||||
memo: body.memo,
|
||||
})
|
||||
|
||||
return jsonResponse({ success: true, subscription: created as unknown as Record<string, unknown> }, 201)
|
||||
}
|
||||
|
||||
// ── PATCH: 수정 (super_admin) ──
|
||||
if (req.method === 'PATCH') {
|
||||
const admin = await requireSuperAdmin(req)
|
||||
const userId = url.searchParams.get('userId')
|
||||
if (!userId) return jsonResponse({ error: 'userId query param required' }, 400)
|
||||
|
||||
const body = (await req.json()) as UpdateBody
|
||||
if (!body.memo) return jsonResponse({ error: 'memo is required' }, 400)
|
||||
|
||||
// before 스냅샷
|
||||
const { data: before } = await serviceClient
|
||||
.from('subscriptions')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle()
|
||||
|
||||
if (!before) return jsonResponse({ error: 'Subscription not found' }, 404)
|
||||
|
||||
// 업데이트 페이로드
|
||||
const updates: Record<string, unknown> = { updated_at: new Date().toISOString() }
|
||||
if (body.tier !== undefined) updates.tier = body.tier
|
||||
if (body.status !== undefined) updates.status = body.status
|
||||
if (body.currentPeriodEnd !== undefined) updates.current_period_end = body.currentPeriodEnd
|
||||
if (body.overageCredits !== undefined) updates.overage_credits = body.overageCredits
|
||||
if (body.adminNote !== undefined) updates.admin_note = body.adminNote
|
||||
|
||||
const { data: after, error } = await serviceClient
|
||||
.from('subscriptions')
|
||||
.update(updates)
|
||||
.eq('user_id', userId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) return jsonResponse({ error: error.message }, 500)
|
||||
|
||||
// profiles.tier 동기화
|
||||
if (body.tier) {
|
||||
await serviceClient
|
||||
.from('profiles')
|
||||
.update({ tier: body.tier, updated_at: new Date().toISOString() })
|
||||
.eq('id', userId)
|
||||
}
|
||||
|
||||
await writeAuditLog(serviceClient, {
|
||||
adminId: admin.id,
|
||||
action: 'subscription.update',
|
||||
targetType: 'subscription',
|
||||
targetId: userId,
|
||||
beforeData: before as unknown as Record<string, unknown>,
|
||||
afterData: after as unknown as Record<string, unknown>,
|
||||
memo: body.memo,
|
||||
})
|
||||
|
||||
return jsonResponse({ success: true, subscription: after as unknown as Record<string, unknown> })
|
||||
}
|
||||
|
||||
// ── DELETE: 소프트 삭제 (super_admin) ──
|
||||
if (req.method === 'DELETE') {
|
||||
const admin = await requireSuperAdmin(req)
|
||||
const userId = url.searchParams.get('userId')
|
||||
if (!userId) return jsonResponse({ error: 'userId query param required' }, 400)
|
||||
|
||||
const body = (await req.json()) as DeleteBody
|
||||
if (!body.memo) return jsonResponse({ error: 'memo is required' }, 400)
|
||||
|
||||
const { data: before } = await serviceClient
|
||||
.from('subscriptions')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle()
|
||||
|
||||
if (!before) return jsonResponse({ error: 'Subscription not found' }, 404)
|
||||
|
||||
// 소프트 삭제: status = 'expired', tier = 'free'
|
||||
const now = new Date().toISOString()
|
||||
const { error } = await serviceClient
|
||||
.from('subscriptions')
|
||||
.update({
|
||||
status: 'expired',
|
||||
tier: 'free',
|
||||
cancel_at: now,
|
||||
updated_at: now,
|
||||
admin_note: `[DELETED] ${body.memo}`,
|
||||
})
|
||||
.eq('user_id', userId)
|
||||
|
||||
if (error) return jsonResponse({ error: error.message }, 500)
|
||||
|
||||
// profiles.tier → free
|
||||
await serviceClient
|
||||
.from('profiles')
|
||||
.update({ tier: 'free', updated_at: now })
|
||||
.eq('id', userId)
|
||||
|
||||
await writeAuditLog(serviceClient, {
|
||||
adminId: admin.id,
|
||||
action: 'subscription.delete',
|
||||
targetType: 'subscription',
|
||||
targetId: userId,
|
||||
beforeData: before as unknown as Record<string, unknown>,
|
||||
afterData: { status: 'expired', tier: 'free', cancel_at: now },
|
||||
memo: body.memo,
|
||||
})
|
||||
|
||||
return jsonResponse({ success: true, message: 'Subscription soft-deleted' })
|
||||
}
|
||||
|
||||
return jsonResponse({ error: 'Method not allowed' }, 405)
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && 'status' in err && 'message' in err) {
|
||||
return authErrorResponse(err as AuthError, corsHeaders)
|
||||
}
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
return jsonResponse({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
149
server/supabase/functions/admin-users/index.ts
Normal file
149
server/supabase/functions/admin-users/index.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
// server/supabase/functions/admin-users/index.ts
|
||||
// Admin: 유저 목록/상세 조회 + super_admin: role 변경
|
||||
|
||||
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
||||
import { authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
||||
import { requireAdmin, requireSuperAdmin } from '../_shared/admin-auth.ts'
|
||||
import { createServiceRoleClient } from '../_shared/quota.ts'
|
||||
import { writeAuditLog } from '../_shared/audit.ts'
|
||||
|
||||
// @ts-expect-error — Deno 런타임 import
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.7'
|
||||
|
||||
function jsonResponse(body: Record<string, unknown> | Record<string, unknown>[], status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
interface RoleChangeBody {
|
||||
userId: string
|
||||
newRole: 'user' | 'admin' | 'super_admin'
|
||||
memo: string
|
||||
}
|
||||
|
||||
// @ts-expect-error — Deno 런타임 전역
|
||||
Deno.serve(async (req: Request) => {
|
||||
const preflight = handleCorsPreflightRequest(req)
|
||||
if (preflight) return preflight
|
||||
|
||||
try {
|
||||
const url = new URL(req.url)
|
||||
|
||||
// ── GET: 유저 목록/상세 ──
|
||||
if (req.method === 'GET') {
|
||||
const admin = await requireAdmin(req)
|
||||
const serviceClient = createServiceRoleClient()
|
||||
|
||||
const userId = url.searchParams.get('userId')
|
||||
|
||||
if (userId) {
|
||||
// 유저 상세
|
||||
const { data: profile, error } = await serviceClient
|
||||
.from('profiles')
|
||||
.select('id, name, avatar_url, locale, tier, role, created_at, updated_at')
|
||||
.eq('id', userId)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) return jsonResponse({ error: error.message }, 500)
|
||||
if (!profile) return jsonResponse({ error: 'User not found' }, 404)
|
||||
|
||||
const { data: sub } = await serviceClient
|
||||
.from('subscriptions')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle()
|
||||
|
||||
return jsonResponse({ profile, subscription: sub })
|
||||
}
|
||||
|
||||
// 유저 목록
|
||||
const page = parseInt(url.searchParams.get('page') ?? '1', 10)
|
||||
const limit = parseInt(url.searchParams.get('limit') ?? '20', 10)
|
||||
const search = url.searchParams.get('search') ?? ''
|
||||
const roleFilter = url.searchParams.get('role') ?? ''
|
||||
const from = (page - 1) * limit
|
||||
const to = from + limit - 1
|
||||
|
||||
let query = serviceClient
|
||||
.from('profiles')
|
||||
.select('id, name, avatar_url, tier, role, created_at', { count: 'exact' })
|
||||
|
||||
if (search) {
|
||||
query = query.ilike('name', `%${search}%`)
|
||||
}
|
||||
if (roleFilter) {
|
||||
query = query.eq('role', roleFilter)
|
||||
}
|
||||
|
||||
const { data: profiles, count, error } = await query
|
||||
.order('created_at', { ascending: false })
|
||||
.range(from, to)
|
||||
|
||||
if (error) return jsonResponse({ error: error.message }, 500)
|
||||
|
||||
return jsonResponse({ profiles: profiles ?? [], total: count ?? 0, page, limit })
|
||||
}
|
||||
|
||||
// ── PATCH: role 변경 (super_admin 전용) ──
|
||||
if (req.method === 'PATCH') {
|
||||
const admin = await requireSuperAdmin(req)
|
||||
const body = (await req.json()) as RoleChangeBody
|
||||
const serviceClient = createServiceRoleClient()
|
||||
|
||||
if (!body.userId || !body.newRole || !body.memo) {
|
||||
return jsonResponse({ error: 'userId, newRole, memo are required' }, 400)
|
||||
}
|
||||
|
||||
const validRoles = ['user', 'admin', 'super_admin']
|
||||
if (!validRoles.includes(body.newRole)) {
|
||||
return jsonResponse({ error: `Invalid role. Must be one of: ${validRoles.join(', ')}` }, 400)
|
||||
}
|
||||
|
||||
// 현재 프로필 조회 (before 스냅샷)
|
||||
const { data: before } = await serviceClient
|
||||
.from('profiles')
|
||||
.select('id, name, role')
|
||||
.eq('id', body.userId)
|
||||
.maybeSingle()
|
||||
|
||||
if (!before) return jsonResponse({ error: 'User not found' }, 404)
|
||||
|
||||
// 1. auth.users.raw_app_meta_data.role 변경
|
||||
const { error: authError } = await serviceClient.auth.admin.updateUserById(body.userId, {
|
||||
app_metadata: { role: body.newRole },
|
||||
})
|
||||
if (authError) return jsonResponse({ error: `Auth update failed: ${authError.message}` }, 500)
|
||||
|
||||
// 2. profiles.role 동기화
|
||||
const { error: profileError } = await serviceClient
|
||||
.from('profiles')
|
||||
.update({ role: body.newRole, updated_at: new Date().toISOString() })
|
||||
.eq('id', body.userId)
|
||||
|
||||
if (profileError) return jsonResponse({ error: `Profile update failed: ${profileError.message}` }, 500)
|
||||
|
||||
// 3. 감사로그
|
||||
await writeAuditLog(serviceClient, {
|
||||
adminId: admin.id,
|
||||
action: 'user.role_change',
|
||||
targetType: 'profile',
|
||||
targetId: body.userId,
|
||||
beforeData: { role: before.role },
|
||||
afterData: { role: body.newRole },
|
||||
memo: body.memo,
|
||||
})
|
||||
|
||||
return jsonResponse({ success: true, userId: body.userId, newRole: body.newRole })
|
||||
}
|
||||
|
||||
return jsonResponse({ error: 'Method not allowed' }, 405)
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && 'status' in err && 'message' in err) {
|
||||
return authErrorResponse(err as AuthError, corsHeaders)
|
||||
}
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
return jsonResponse({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
138
server/supabase/migrations/20260413000004_admin_enhancement.sql
Normal file
138
server/supabase/migrations/20260413000004_admin_enhancement.sql
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
-- ============================================================================
|
||||
-- Phase V2-6: Admin CRM 고도화 — 권한 확장 + 감사로그 + admin_note
|
||||
-- ============================================================================
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 1. profiles.role CHECK 확장: super_admin 추가
|
||||
-- ----------------------------------------------------------------------------
|
||||
ALTER TABLE public.profiles DROP CONSTRAINT IF EXISTS profiles_role_check;
|
||||
ALTER TABLE public.profiles
|
||||
ADD CONSTRAINT profiles_role_check
|
||||
CHECK (role IN ('user', 'admin', 'super_admin'));
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 2. audit_log 테이블 — 관리자 작업 감사 기록 (before/after diff 포함)
|
||||
-- ----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.audit_log (
|
||||
id bigserial PRIMARY KEY,
|
||||
admin_id uuid NOT NULL REFERENCES auth.users(id),
|
||||
action text NOT NULL,
|
||||
target_type text NOT NULL,
|
||||
target_id uuid NOT NULL,
|
||||
before_data jsonb,
|
||||
after_data jsonb,
|
||||
memo text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_target ON public.audit_log(target_type, target_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_admin ON public.audit_log(admin_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_date ON public.audit_log(created_at DESC);
|
||||
|
||||
-- audit_log RLS
|
||||
ALTER TABLE public.audit_log ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "admin_read_audit_log" ON public.audit_log
|
||||
FOR SELECT TO authenticated
|
||||
USING ((auth.jwt() -> 'app_metadata' ->> 'role') IN ('admin', 'super_admin'));
|
||||
|
||||
-- INSERT는 service_role만 (Edge Function에서 기록)
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 3. subscriptions.admin_note 컬럼
|
||||
-- ----------------------------------------------------------------------------
|
||||
ALTER TABLE public.subscriptions
|
||||
ADD COLUMN IF NOT EXISTS admin_note text;
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 4. 기존 RLS 정책 업데이트 — admin OR super_admin
|
||||
-- ----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "admin_read_all_profiles" ON public.profiles;
|
||||
CREATE POLICY "admin_read_all_profiles" ON public.profiles
|
||||
FOR SELECT TO authenticated
|
||||
USING ((auth.jwt() -> 'app_metadata' ->> 'role') IN ('admin', 'super_admin'));
|
||||
|
||||
DROP POLICY IF EXISTS "admin_read_all_subscriptions" ON public.subscriptions;
|
||||
CREATE POLICY "admin_read_all_subscriptions" ON public.subscriptions
|
||||
FOR SELECT TO authenticated
|
||||
USING ((auth.jwt() -> 'app_metadata' ->> 'role') IN ('admin', 'super_admin'));
|
||||
|
||||
DROP POLICY IF EXISTS "admin_read_all_daily_usage" ON public.daily_usage;
|
||||
CREATE POLICY "admin_read_all_daily_usage" ON public.daily_usage
|
||||
FOR SELECT TO authenticated
|
||||
USING ((auth.jwt() -> 'app_metadata' ->> 'role') IN ('admin', 'super_admin'));
|
||||
|
||||
-- subscriptions: super_admin 쓰기 정책
|
||||
DROP POLICY IF EXISTS "super_admin_write_subscriptions" ON public.subscriptions;
|
||||
CREATE POLICY "super_admin_write_subscriptions" ON public.subscriptions
|
||||
FOR ALL TO authenticated
|
||||
USING ((auth.jwt() -> 'app_metadata' ->> 'role') = 'super_admin')
|
||||
WITH CHECK ((auth.jwt() -> 'app_metadata' ->> 'role') = 'super_admin');
|
||||
|
||||
-- profiles: super_admin이 role 컬럼 수정 가능
|
||||
DROP POLICY IF EXISTS "super_admin_update_profiles" ON public.profiles;
|
||||
CREATE POLICY "super_admin_update_profiles" ON public.profiles
|
||||
FOR UPDATE TO authenticated
|
||||
USING ((auth.jwt() -> 'app_metadata' ->> 'role') = 'super_admin')
|
||||
WITH CHECK ((auth.jwt() -> 'app_metadata' ->> 'role') = 'super_admin');
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 5. 통계 RPC 함수 — admin 전용
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
-- 5.1 일별 feature 집계
|
||||
CREATE OR REPLACE FUNCTION public.admin_usage_by_feature(
|
||||
p_from date, p_to date
|
||||
) RETURNS TABLE(date date, feature text, total_count bigint, unique_users bigint)
|
||||
LANGUAGE sql SECURITY DEFINER STABLE
|
||||
SET search_path = public
|
||||
AS $$
|
||||
SELECT du.date, du.feature,
|
||||
SUM(du.count)::bigint AS total_count,
|
||||
COUNT(DISTINCT du.user_id)::bigint AS unique_users
|
||||
FROM public.daily_usage du
|
||||
WHERE du.date BETWEEN p_from AND p_to
|
||||
GROUP BY du.date, du.feature
|
||||
ORDER BY du.date, du.feature;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.admin_usage_by_feature(date, date) FROM public;
|
||||
GRANT EXECUTE ON FUNCTION public.admin_usage_by_feature(date, date) TO authenticated;
|
||||
|
||||
-- 5.2 유저별 사용량 랭킹
|
||||
CREATE OR REPLACE FUNCTION public.admin_top_users(
|
||||
p_from date, p_to date, p_limit integer DEFAULT 20
|
||||
) RETURNS TABLE(user_id uuid, name text, total_count bigint, feature_count bigint)
|
||||
LANGUAGE sql SECURITY DEFINER STABLE
|
||||
SET search_path = public
|
||||
AS $$
|
||||
SELECT du.user_id, p.name,
|
||||
SUM(du.count)::bigint AS total_count,
|
||||
COUNT(DISTINCT du.feature)::bigint AS feature_count
|
||||
FROM public.daily_usage du
|
||||
JOIN public.profiles p ON p.id = du.user_id
|
||||
WHERE du.date BETWEEN p_from AND p_to
|
||||
GROUP BY du.user_id, p.name
|
||||
ORDER BY total_count DESC
|
||||
LIMIT p_limit;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.admin_top_users(date, date, integer) FROM public;
|
||||
GRANT EXECUTE ON FUNCTION public.admin_top_users(date, date, integer) TO authenticated;
|
||||
|
||||
-- 5.3 DAU 추이
|
||||
CREATE OR REPLACE FUNCTION public.admin_dau(
|
||||
p_from date, p_to date
|
||||
) RETURNS TABLE(date date, active_users bigint)
|
||||
LANGUAGE sql SECURITY DEFINER STABLE
|
||||
SET search_path = public
|
||||
AS $$
|
||||
SELECT du.date, COUNT(DISTINCT du.user_id)::bigint AS active_users
|
||||
FROM public.daily_usage du
|
||||
WHERE du.date BETWEEN p_from AND p_to
|
||||
GROUP BY du.date
|
||||
ORDER BY du.date;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.admin_dau(date, date) FROM public;
|
||||
GRANT EXECUTE ON FUNCTION public.admin_dau(date, date) TO authenticated;
|
||||
Loading…
Add table
Add a link
Reference in a new issue