diff --git a/.github/workflows/daily-crawler.yml b/.github/workflows/daily-crawler.yml index 7945853518..4e38cf22a3 100644 --- a/.github/workflows/daily-crawler.yml +++ b/.github/workflows/daily-crawler.yml @@ -55,7 +55,7 @@ jobs: runs-on: ubuntu-latest # [PERF] 안전 타임아웃: 최대 90분 (월 3000분 한도 보호) timeout-minutes: 90 - if: ${{ github.event_name != 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch }} + if: ${{ (github.event_name != 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch) && vars.G037_WRITE_FREEZE != 'active' }} env: MAX_CONTEXT_VIDEOS: ${{ github.event.inputs.max_videos || '-1' }} steps: diff --git a/.github/workflows/gdrive-frame-backfill.yml b/.github/workflows/gdrive-frame-backfill.yml index caf798525f..14f7951295 100644 --- a/.github/workflows/gdrive-frame-backfill.yml +++ b/.github/workflows/gdrive-frame-backfill.yml @@ -45,7 +45,7 @@ jobs: # Public standard runners do not consume the Pro 3000-minute quota, and # catch-up backfills can legitimately exceed the former budget guard. timeout-minutes: 360 - if: ${{ (github.event_name != 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch) && (github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == github.event.repository.default_branch && github.event.workflow_run.event == 'schedule')) }} + if: ${{ (github.event_name != 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch) && (github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == github.event.repository.default_branch && github.event.workflow_run.event == 'schedule')) && vars.G037_WRITE_FREEZE != 'active' }} env: GDRIVE_FRAMES_PATH: "gdrive:04_빠른공유/tzudong_tzuyang_data/frames" GDRIVE_STATUS_PATH: "gdrive:04_빠른공유/tzudong_tzuyang_data/status" diff --git a/apps/web/.env.example b/apps/web/.env.example index a90411110b..d51b72bfbb 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -100,3 +100,15 @@ THUMBNAIL_AGENT_ROOT=../../backend/thumbnail-agent THUMBNAIL_AGENT_PYTHON=python3 THUMBNAIL_AGENT_RUNTIME=local_graph THUMBNAIL_AGENT_TIMEOUT_MS=120000 +# Device-location release gate (server-only; no NEXT_PUBLIC_* variants) +# 기본값: 차단. 누락/오류/형식 불일치이면 위치 맵 네트워크 준비 불가로 동작합니다. +DEVICE_LOCATION_RELEASE_DECISION= +# 외부 상태는 다음 값만 허용됨: not_applicable_verified | filing_receipt_verified +DEVICE_LOCATION_EXTERNAL_STATUS= +# 아래 4개 증거는 SHA-256 64자 hex 문자열이어야 합니다(대소문자 무관). +DEVICE_LOCATION_OPERATOR_EVIDENCE_HASH= +DEVICE_LOCATION_PROVIDER_EVIDENCE_HASH= +DEVICE_LOCATION_EXTERNAL_EVIDENCE_HASH= +DEVICE_LOCATION_LEGAL_EVIDENCE_HASH= +# ISO-8601 등 파싱 가능한 시간 문자열(예: 2026-07-12T00:00:00Z) +DEVICE_LOCATION_RELEASE_CONFIRMED_AT= diff --git a/apps/web/app/admin/banners/page.tsx b/apps/web/app/admin/banners/page.tsx index 6f6f29b3de..8c2995fa3c 100644 --- a/apps/web/app/admin/banners/page.tsx +++ b/apps/web/app/admin/banners/page.tsx @@ -476,7 +476,7 @@ function BannerManagementPage({ embedded }: Required +
{!embedded && (
-
+
{!embedded && (
)} -
+
{pendingQueueSummaryContent}
@@ -2909,7 +2913,7 @@ function AdminEvaluationPage({ selectedStatuses={selectedStatuses} onSelectStatuses={setSelectedStatuses} > -
+
{canSwitchEvaluationView && ( <>
-
+
{pendingRecordAction && (
= { + account_deletion_reauth_proof_invalid_claims: { error: 'Fresh self authentication is required.', reasonCode: 'account_deletion_reauth_proof_invalid_claims', status: 403 }, + account_deletion_reauth_proof_not_available: { error: 'Account deletion reauthentication has expired or was already used.', reasonCode: 'account_deletion_reauth_proof_not_available', status: 409 }, + account_deletion_reauth_proof_password_reauthentication_required: { error: 'Fresh self authentication is required.', reasonCode: 'account_deletion_reauth_proof_password_reauthentication_required', status: 403 }, + account_deletion_reauthentication_required: { error: 'Fresh self authentication is required.', reasonCode: 'account_deletion_reauthentication_required', status: 403 }, + account_deletion_apply_not_started: { error: 'Account deletion could not be started.', reasonCode: 'account_deletion_apply_not_started', status: 409 }, +}; + +function failureResponse(value: unknown) { + if (typeof value !== 'string' || !Object.hasOwn(deletionFailureResponses, value)) { + return NextResponse.json({ error: 'Server error.', reasonCode: 'account_deletion_server_error' }, { status: 500 }); + } + const failure = deletionFailureResponses[value as DeletionFailureCode]; + return NextResponse.json({ error: failure.error, reasonCode: failure.reasonCode }, { status: failure.status }); +} + +function rpcFailureCode(error: unknown): string | null { + if (!error || typeof error !== 'object') return null; + const details = error as { message?: unknown; code?: unknown }; + return typeof details.message === 'string' + ? details.message + : typeof details.code === 'string' + ? details.code + : null; +} + +function readRequiredString(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function hasExactKeys(body: unknown, keys: string[]): body is Record { + return !!body && typeof body === 'object' && !Array.isArray(body) && + Object.keys(body).length === keys.length && keys.every((key) => Object.hasOwn(body, key)); +} + +function parseBearerToken(request: NextRequest): string | null { + const value = request.headers.get('Authorization'); + if (!value?.startsWith('Bearer ')) return null; + return readRequiredString(value.slice('Bearer '.length)); +} + +function isSameOrigin(request: NextRequest): boolean { + return request.headers.get('Origin') === request.nextUrl.origin; +} + +function isAuthoritativeBeginRow(value: unknown, requestId: string): value is DeletionBeginRow { + if (!value || typeof value !== 'object') return false; + const row = value as DeletionBeginRow; + return row.request_id === requestId && typeof row.status === 'string' && + typeof row.db_readback_passed === 'boolean' && typeof row.storage_readback_passed === 'boolean' && + typeof row.session_readback_passed === 'boolean' && typeof row.auth_readback_passed === 'boolean'; +} + +function createBearerClient(bearerToken: string) { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + if (!supabaseUrl || !supabaseAnonKey) throw new Error('Supabase environment variables are missing.'); + return createClient(supabaseUrl, supabaseAnonKey, { + auth: { persistSession: false, autoRefreshToken: false }, + global: { headers: { Authorization: `Bearer ${bearerToken}` } }, + }); +} + +function parseApplyInput(body: DeletionApplyInput) { + const userId = readRequiredString(body.userId); + const proofId = readRequiredString(body.proofId); + const requestId = readRequiredString(body.requestId); + const previewHash = readRequiredString(body.previewHash); + const confirmationText = readRequiredString(body.confirmationText); + const idempotencyKey = readRequiredString(body.idempotencyKey); + const sourceManifestHash = readRequiredString(body.sourceManifestHash); + return userId && proofId && requestId && previewHash && confirmationText && idempotencyKey && sourceManifestHash + ? { userId, proofId, requestId, previewHash, confirmationText, idempotencyKey, sourceManifestHash } + : null; +} + +async function verifySelfBearer(bearerToken: string, targetUserId: string) { + const supabaseAdmin = createSupabaseServiceRoleClient(); + const [{ data: userData, error: userError }, { data: claimsData, error: claimsError }] = await Promise.all([ + supabaseAdmin.auth.getUser(bearerToken), + supabaseAdmin.auth.getClaims(bearerToken), + ]); + const sub = claimsData?.claims?.sub; + const user = userData.user; + return !userError && !claimsError && user?.id === targetUserId && sub === targetUserId ? user : null; +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => null); + if (!isSameOrigin(request) || !hasExactKeys(body, ['targetUserId'])) { + return NextResponse.json({ error: 'Invalid account deletion preview request.', reasonCode: 'account_deletion_preview_request_invalid' }, { status: 400 }); + } + const targetUserId = readRequiredString(body.targetUserId); + const bearerToken = parseBearerToken(request); + if (!targetUserId || !bearerToken) return NextResponse.json({ error: 'Authentication is required.', reasonCode: 'account_deletion_authentication_required' }, { status: 401 }); + + const verifiedUser = await verifySelfBearer(bearerToken, targetUserId); + if (!verifiedUser?.last_sign_in_at) return NextResponse.json({ error: 'Fresh self authentication is required.', reasonCode: 'account_deletion_reauthentication_required' }, { status: 403 }); + + const supabaseAdmin = createSupabaseServiceRoleClient(); + const { data, error } = await supabaseAdmin.rpc('preview_account_deletion', { + p_actor_user_id: targetUserId, + p_target_user_id: targetUserId, + p_reauthenticated_at: verifiedUser.last_sign_in_at, + }); + const preview = parseAccountDeletionPreview(asSingleRow(data)); + if (error || !preview) return NextResponse.json({ error: 'Account deletion preview could not be created.', reasonCode: 'account_deletion_preview_not_available' }, { status: 409 }); + + return NextResponse.json({ preview }, { headers: { 'Cache-Control': 'no-store' } }); + } catch { + return NextResponse.json({ error: 'Server error.', reasonCode: 'account_deletion_server_error' }, { status: 500 }); + } +} + export async function DELETE(request: NextRequest) { - try { - const supabase = await createServerClient(); - const supabaseAdmin = createSupabaseServiceRoleClient(); - const { data: { user }, error: authError } = await supabase.auth.getUser(); - - if (authError || !user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const body = await request.json().catch(() => null); - const requestedUserId = typeof body?.userId === 'string' ? body.userId : null; - const targetUserId = (requestedUserId || user.id).trim(); - - if (!targetUserId) { - return NextResponse.json({ error: '사용자 ID가 필요합니다' }, { status: 400 }); - } - - // Only allow deleting a different user if the requester is admin. - if (targetUserId !== user.id) { - const auth = await requireAdmin(); - if (!auth.ok) return auth.response; - } else { - await revokeCurrentUserSessions({ supabase, supabaseAdmin, request }); - } - - // 1. 프로필 익명화 - const { error: profileError } = await supabaseAdmin - .from('profiles') - .update({ nickname: '탈퇴한 사용자' }) - .eq('user_id', targetUserId); - - if (profileError) { - console.warn('프로필 익명화 실패:', profileError); - } - - // 2. user_stats 삭제 - const { error: statsError } = await supabaseAdmin - .from('user_stats') - .delete() - .eq('user_id', targetUserId); - - if (statsError) { - console.warn('통계 정보 삭제 실패:', statsError); - } - - // 3. user_roles 삭제 - const { error: rolesError } = await supabaseAdmin - .from('user_roles') - .delete() - .eq('user_id', targetUserId); - - if (rolesError) { - console.warn('역할 정보 삭제 실패:', rolesError); - } - - // 4. 북마크 삭제 - const { error: bookmarksError } = await supabaseAdmin - .from('user_bookmarks') - .delete() - .eq('user_id', targetUserId); - - if (bookmarksError) { - console.warn('북마크 삭제 실패:', bookmarksError); - } - - // 5. 알림 삭제 - const { error: notificationsError } = await supabaseAdmin - .from('notifications') - .delete() - .eq('user_id', targetUserId); - - if (notificationsError) { - console.warn('알림 삭제 실패:', notificationsError); - } - - // 6. Supabase Auth에서 완전 삭제 - const { error: deleteError } = await supabaseAdmin.auth.admin.deleteUser(targetUserId); - - if (deleteError) { - console.error('사용자 삭제 실패:', deleteError); - return NextResponse.json( - { error: '사용자 계정 삭제에 실패했습니다' }, - { status: 500 } - ); - } - - return NextResponse.json({ success: true }); - } catch (error) { - console.error('계정 삭제 중 오류:', error); - return NextResponse.json( - { error: '서버 오류가 발생했습니다' }, - { status: 500 } - ); + try { + const body = await request.json().catch(() => null); + if (!isSameOrigin(request) || !hasExactKeys(body, ['userId', 'proofId', 'requestId', 'previewHash', 'confirmationText', 'idempotencyKey', 'sourceManifestHash'])) { + return NextResponse.json({ error: 'Invalid account deletion request.', reasonCode: 'account_deletion_request_invalid' }, { status: 400 }); + } + const input = parseApplyInput(body); + const bearerToken = parseBearerToken(request); + if (!input || !bearerToken) return NextResponse.json({ error: 'Authentication is required.', reasonCode: 'account_deletion_authentication_required' }, { status: 401 }); + if (!await verifySelfBearer(bearerToken, input.userId)) { + return NextResponse.json({ error: 'Self account deletion is required.', reasonCode: 'account_deletion_self_required' }, { status: 403 }); + } + + const supabase = createBearerClient(bearerToken); + const { data, error } = await supabase.rpc('begin_account_deletion_apply_with_reauth', { + p_proof_id: input.proofId, + p_actor_user_id: input.userId, + p_target_user_id: input.userId, + p_request_id: input.requestId, + p_preview_hash: input.previewHash, + p_confirmation_text: input.confirmationText, + p_idempotency_key: input.idempotencyKey, + p_source_manifest_hash: input.sourceManifestHash, + }); + const begin = asSingleRow(data); + if (error) return failureResponse(rpcFailureCode(error)); + if (!isAuthoritativeBeginRow(begin, input.requestId)) { + return NextResponse.json({ error: 'Server error.', reasonCode: 'account_deletion_server_error' }, { status: 500 }); + } + if (begin.status !== 'APPLY_STARTED') { + return failureResponse('account_deletion_apply_not_started'); } + return NextResponse.json({ success: true, begin }, { headers: { 'Cache-Control': 'no-store' } }); + } catch { + return NextResponse.json({ error: 'Server error.', reasonCode: 'account_deletion_server_error' }, { status: 500 }); + } } diff --git a/apps/web/app/api/privacy/location-readiness/route.ts b/apps/web/app/api/privacy/location-readiness/route.ts new file mode 100644 index 0000000000..385a3a814b --- /dev/null +++ b/apps/web/app/api/privacy/location-readiness/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from 'next/server'; + +import { resolveDeviceLocationReadiness } from '@/lib/privacy/location-readiness'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export function GET() { + const readiness = resolveDeviceLocationReadiness(process.env); + + return NextResponse.json(readiness, { + headers: { + 'Cache-Control': 'no-store', + }, + }); +} diff --git a/apps/web/app/api/shorten/route.ts b/apps/web/app/api/shorten/route.ts index 231e826147..f0db85815b 100644 --- a/apps/web/app/api/shorten/route.ts +++ b/apps/web/app/api/shorten/route.ts @@ -1,40 +1,145 @@ import { NextRequest, NextResponse } from 'next/server'; import { createClient as createSupabaseClient } from '@supabase/supabase-js'; -import { randomInt } from 'node:crypto'; +import { createHmac, randomInt } from 'node:crypto'; +import { isIP } from 'node:net'; +import type { Database } from '@/integrations/supabase/types'; -// 환경변수에서 Supabase URL과 Service Role Key 가져오기 const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const DEFAULT_SITE_ORIGIN = 'https://www.tzudong.app'; -const SHORTEN_RATE_LIMIT_WINDOW_MS = 60_000; -const SHORTEN_RATE_LIMIT_MAX_REQUESTS = 20; -const shortenRateLimitBuckets = new Map(); +const MAX_SHORTEN_BODY_BYTES = 4 * 1024; +const MAX_TARGET_URL_LENGTH = 2_048; +const SHORT_CODE_CANDIDATE_COUNT = 5; +const SHORT_CODE_LENGTH = 6; +const SHORT_CODE_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; +const NO_STORE_HEADERS = { 'Cache-Control': 'no-store' }; + +const SHORTEN_ERRORS = { + unsupportedMediaType: 'JSON 형식의 요청만 허용됩니다.', + bodyTooLarge: '요청 본문이 너무 큽니다.', + invalidBody: '요청 본문이 올바르지 않습니다.', + targetRequired: '대상 URL이 필요합니다.', + invalidTarget: '허용되지 않는 단축 URL 대상입니다.', + storageUnavailable: '단축 URL 저장소가 설정되지 않았습니다.', + reviewNotFound: '존재하지 않는 리뷰입니다.', + rateLimited: '요청이 너무 많습니다. 잠시 후 다시 시도해주세요.', + codeGenerationFailed: '단축 코드 생성에 실패했습니다. 다시 시도해주세요.', + storageFailed: '단축 URL 저장에 실패했습니다.', + serverError: '서버 오류가 발생했습니다.', +} as const; + +type ShortenBodyResult = + | { kind: 'ok'; targetUrl: string } + | { kind: 'unsupported-media-type' } + | { kind: 'too-large' } + | { kind: 'invalid' }; export const runtime = 'nodejs'; -function getRequesterKey(request: NextRequest) { - const forwardedFor = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim(); - const realIp = request.headers.get('x-real-ip')?.trim(); - return forwardedFor || realIp || 'unknown'; +function noStoreJson(body: object, status = 200, headers?: Record) { + return NextResponse.json(body, { + status, + headers: { + ...NO_STORE_HEADERS, + ...headers, + }, + }); +} + +function shortenError(error: string, status: number, headers?: Record) { + return noStoreJson({ error }, status, headers); } -function isRateLimited(request: NextRequest) { - const now = Date.now(); - const key = getRequesterKey(request); - const bucket = shortenRateLimitBuckets.get(key); +async function readShortenBody(request: NextRequest): Promise { + const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase(); + if (mediaType !== 'application/json') { + return { kind: 'unsupported-media-type' }; + } + + if (!request.body) { + return { kind: 'invalid' }; + } + + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + + totalBytes += value.byteLength; + if (totalBytes > MAX_SHORTEN_BODY_BYTES) { + return { kind: 'too-large' }; + } + chunks.push(value); + } + } catch { + return { kind: 'invalid' }; + } finally { + reader.releaseLock(); + } + + const bytes = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); + } catch { + return { kind: 'invalid' }; + } + + if ( + typeof parsed !== 'object' + || parsed === null + || Array.isArray(parsed) + || Object.getPrototypeOf(parsed) !== Object.prototype + ) { + return { kind: 'invalid' }; + } + + const payload = parsed as Record; + const keys = Object.keys(payload); + if ( + keys.length !== 1 + || keys[0] !== 'targetUrl' + || typeof payload.targetUrl !== 'string' + || payload.targetUrl.length > MAX_TARGET_URL_LENGTH + ) { + return { kind: 'invalid' }; + } + + return { kind: 'ok', targetUrl: payload.targetUrl }; +} - if (!bucket || bucket.resetAt <= now) { - shortenRateLimitBuckets.set(key, { count: 1, resetAt: now + SHORTEN_RATE_LIMIT_WINDOW_MS }); - return false; +function getRequesterBucket(request: NextRequest) { + const privacyHashKey = process.env.PRIVACY_AUDIT_HASH_KEY?.trim(); + if ( + process.env.VERCEL !== '1' + || !privacyHashKey + || Buffer.byteLength(privacyHashKey, 'utf8') < 32 + ) { + return 'unknown'; } - bucket.count += 1; - if (bucket.count > SHORTEN_RATE_LIMIT_MAX_REQUESTS) { - return true; + const vercelForwardedFor = request.headers.get('x-vercel-forwarded-for')?.trim(); + if ( + !vercelForwardedFor + || vercelForwardedFor.includes(',') + || isIP(vercelForwardedFor) === 0 + ) { + return 'unknown'; } - return false; + return `ip:${createHmac('sha256', privacyHashKey).update(vercelForwardedFor).digest('hex')}`; } function getShortUrlOrigin(request: NextRequest) { @@ -67,12 +172,14 @@ function getAllowedShortUrlTarget(targetUrl: string, request: NextRequest) { const target = new URL(trimmedTargetUrl, origin); const reviewId = target.searchParams.get('review'); - if (target.origin !== new URL(origin).origin) return null; - if (target.pathname !== '/' || !isValidReviewId(reviewId)) return null; + if (target.origin !== new URL(origin).origin || target.pathname !== '/' || !isValidReviewId(reviewId)) { + return null; + } + const canonicalReviewId = reviewId.toLowerCase(); return { - canonicalTargetUrl: `/?review=${encodeURIComponent(reviewId)}`, - reviewId, + canonicalTargetUrl: `/?review=${canonicalReviewId}`, + reviewId: canonicalReviewId, }; } catch { return null; @@ -88,7 +195,7 @@ function createSupabasePublicClient() { return null; } - return createSupabaseClient(supabaseUrl, supabaseAnonKey, { + return createSupabaseClient(supabaseUrl, supabaseAnonKey, { auth: { persistSession: false, autoRefreshToken: false, @@ -101,7 +208,7 @@ function createSupabaseAdminClient() { return null; } - return createSupabaseClient(supabaseUrl, supabaseServiceKey, { + return createSupabaseClient(supabaseUrl, supabaseServiceKey, { auth: { persistSession: false, autoRefreshToken: false, @@ -109,54 +216,46 @@ function createSupabaseAdminClient() { }); } -// 6자리 영숫자 코드 생성 function generateShortCode(): string { - const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; let code = ''; - for (let i = 0; i < 6; i++) { - code += chars.charAt(randomInt(chars.length)); + for (let index = 0; index < SHORT_CODE_LENGTH; index += 1) { + code += SHORT_CODE_ALPHABET.charAt(randomInt(SHORT_CODE_ALPHABET.length)); } return code; } +function generateShortCodeCandidates() { + return Array.from({ length: SHORT_CODE_CANDIDATE_COUNT }, generateShortCode); +} + export async function POST(request: NextRequest) { try { - if (isRateLimited(request)) { - return NextResponse.json( - { error: '요청이 너무 많습니다. 잠시 후 다시 시도해주세요.' }, - { status: 429 } - ); + const body = await readShortenBody(request); + if (body.kind === 'unsupported-media-type') { + return shortenError(SHORTEN_ERRORS.unsupportedMediaType, 415); } - - const body = await request.json(); - const { targetUrl } = body; - - if (!targetUrl) { - return NextResponse.json( - { error: '대상 URL이 필요합니다.' }, - { status: 400 } - ); + if (body.kind === 'too-large') { + return shortenError(SHORTEN_ERRORS.bodyTooLarge, 413); + } + if (body.kind === 'invalid') { + return shortenError(SHORTEN_ERRORS.invalidBody, 400); + } + if (!body.targetUrl.trim()) { + return shortenError(SHORTEN_ERRORS.targetRequired, 400); } - const allowedTarget = getAllowedShortUrlTarget(targetUrl, request); + const allowedTarget = getAllowedShortUrlTarget(body.targetUrl, request); if (!allowedTarget) { - return NextResponse.json( - { error: '허용되지 않는 단축 URL 대상입니다.' }, - { status: 400 } - ); + return shortenError(SHORTEN_ERRORS.invalidTarget, 400); } const supabasePublic = createSupabasePublicClient(); - const supabaseAdmin = createSupabaseAdminClient(); - if (!supabasePublic || !supabaseAdmin) { - return NextResponse.json( - { error: '단축 URL 저장소가 설정되지 않았습니다.' }, - { status: 500 } - ); + if (!supabasePublic) { + return shortenError(SHORTEN_ERRORS.storageUnavailable, 500); } // 공개 리뷰 검증은 anon/RLS 경로로 먼저 수행한다. service-role은 검증된 - // 대상의 short_urls 저장에만 사용해 공개 API의 데이터 노출면을 줄인다. + // 대상의 원자적 할당 RPC에만 사용해 공개 API의 데이터 노출면을 줄인다. const { data: review, error: reviewError } = await supabasePublic .from('reviews') .select('id, restaurant_id, is_verified') @@ -165,83 +264,47 @@ export async function POST(request: NextRequest) { .maybeSingle(); if (reviewError || !review) { - return NextResponse.json( - { error: '존재하지 않는 리뷰입니다.' }, - { status: 404 } - ); + return shortenError(SHORTEN_ERRORS.reviewNotFound, 404); } - // 동일한 targetUrl이 이미 존재하는지 확인 - const { data: existing } = await supabaseAdmin - .from('short_urls') - .select('code') - .eq('target_url', allowedTarget.canonicalTargetUrl) - .single(); - - if (existing) { - // 이미 존재하면 기존 코드 반환 - const origin = getShortUrlOrigin(request); - return NextResponse.json({ - shortUrl: `${origin}/s/${existing.code}`, - code: existing.code, - isExisting: true, - }); + const supabaseAdmin = createSupabaseAdminClient(); + if (!supabaseAdmin) { + return shortenError(SHORTEN_ERRORS.storageUnavailable, 500); } - // 새 코드 생성 (충돌 방지를 위해 최대 5회 시도) - let code = ''; - let attempts = 0; - const maxAttempts = 5; - - while (attempts < maxAttempts) { - code = generateShortCode(); - const { data: codeExists } = await supabaseAdmin - .from('short_urls') - .select('id') - .eq('code', code) - .single(); - - if (!codeExists) break; - attempts++; + const { data: allocation, error: allocationError } = await supabaseAdmin + .rpc('allocate_short_url', { + p_target_url: allowedTarget.canonicalTargetUrl, + p_restaurant_id: review.restaurant_id, + p_review_id: allowedTarget.reviewId, + p_client_bucket: getRequesterBucket(request), + p_candidate_codes: generateShortCodeCandidates(), + }) + .maybeSingle(); + + if (allocationError || !allocation) { + return shortenError(SHORTEN_ERRORS.storageFailed, 500); } - if (attempts >= maxAttempts) { - return NextResponse.json( - { error: '단축 코드 생성에 실패했습니다. 다시 시도해주세요.' }, - { status: 500 } + if (allocation.rate_limited) { + return shortenError( + SHORTEN_ERRORS.rateLimited, + 429, + { 'Retry-After': String(Math.max(1, allocation.retry_after_seconds)) } ); } - // 단축 URL 저장 - const { error: insertError } = await supabaseAdmin - .from('short_urls') - .insert({ - code, - target_url: allowedTarget.canonicalTargetUrl, - restaurant_id: review.restaurant_id, - restaurant_name: null, - }); - - if (insertError) { - console.error('단축 URL 저장 실패:', insertError); - return NextResponse.json( - { error: '단축 URL 저장에 실패했습니다.' }, - { status: 500 } - ); + if (allocation.allocation_failed || !allocation.code) { + return shortenError(SHORTEN_ERRORS.codeGenerationFailed, 500); } const origin = getShortUrlOrigin(request); - return NextResponse.json({ - shortUrl: `${origin}/s/${code}`, - code, - isExisting: false, + return noStoreJson({ + shortUrl: `${origin}/s/${allocation.code}`, + code: allocation.code, + isExisting: allocation.is_existing, }); - - } catch (error) { - console.error('단축 URL 생성 오류:', error); - return NextResponse.json( - { error: '서버 오류가 발생했습니다.' }, - { status: 500 } - ); + } catch { + return shortenError(SHORTEN_ERRORS.serverError, 500); } } diff --git a/apps/web/app/app-globals.css b/apps/web/app/app-globals.css index c5f3608a01..b933f8a22e 100644 --- a/apps/web/app/app-globals.css +++ b/apps/web/app/app-globals.css @@ -432,7 +432,7 @@ /* Storyboard admin layout is fixed by data-attribute CSS so local Turbopack dev and production builds do not diverge on Tailwind arbitrary utilities. */ [data-admin-storyboard-generator="true"][data-storyboard-viewport-fit="bounded"] { - height: calc(var(--full-height, 100vh) - 2rem); + height: 100%; max-height: 100%; min-height: 0; } @@ -614,11 +614,42 @@ overflow-y: auto !important; } + [data-admin-console-content="true"][data-admin-console-active-module="storyboard"] + [data-admin-console-inline-module-frame="true"][data-admin-console-inline-module-id="storyboard"] { + height: auto; + min-height: 100%; + max-height: none; + overflow: visible; + } + + [data-admin-console-content="true"][data-admin-console-active-module="storyboard"] + [data-admin-console-inline-module-frame="true"][data-admin-console-inline-module-id="storyboard"] + > [data-admin-console-inline-module-panel="true"] { + flex: none; + height: auto; + min-height: auto; + max-height: none; + overflow: visible; + } + [data-admin-storyboard-generator="true"][data-storyboard-viewport-fit="bounded"] { height: auto !important; max-height: none; overflow: visible; } + [data-admin-embedded-module-id="storyboard"] { + height: auto; + max-height: none; + overflow: visible; + } + + [data-admin-embedded-module-id="storyboard"] + > [data-admin-module-content="bounded"] { + flex: none; + height: auto; + max-height: none; + overflow: visible; + } [data-storyboard-desktop-split-layout="inline-grid"] { --storyboard-split-columns: minmax(0, 1fr); diff --git a/apps/web/app/auth/callback/route.ts b/apps/web/app/auth/callback/route.ts index d3c7179f7f..95c9529390 100644 --- a/apps/web/app/auth/callback/route.ts +++ b/apps/web/app/auth/callback/route.ts @@ -26,6 +26,23 @@ function getTrustedRedirectOrigin(requestOrigin: string) { } +function isPrivacyProfileStatusAllowed(status: unknown) { + return status === "eligible" || status === "guardian_verified"; +} + +const isPrivacyProfileCompleteForUser = async (supabase: Awaited>, userId: string) => { + const { data, error } = await (supabase + .from('privacy_age_profiles' as never) + .select('status') + .eq('owner', userId) + .maybeSingle() as unknown as Promise<{ data: { status: string | null } | null; error: unknown }>); + + if (error) return false; + + const status = data?.status ?? null; + return isPrivacyProfileStatusAllowed(status); +}; + export async function GET(request: Request) { const { searchParams, origin } = new URL(request.url); const code = searchParams.get('code'); @@ -33,9 +50,19 @@ export async function GET(request: Request) { if (code) { const supabase = await createClient(); - const { error } = await supabase.auth.exchangeCodeForSession(code); - if (!error) { - return NextResponse.redirect(`${getTrustedRedirectOrigin(origin)}${next}`); + try { + const { error } = await supabase.auth.exchangeCodeForSession(code); + if (!error) { + const { data: { user }, error: userError } = await supabase.auth.getUser(); + + if (!userError && user && await isPrivacyProfileCompleteForUser(supabase, user.id)) { + return NextResponse.redirect(`${getTrustedRedirectOrigin(origin)}${next}`); + } + } + + await supabase.auth.signOut({ scope: 'local' }); + } catch { + await supabase.auth.signOut({ scope: 'local' }).catch(() => {}); } } diff --git a/apps/web/app/home-client.tsx b/apps/web/app/home-client.tsx index 2d1900ae94..c16f542998 100644 --- a/apps/web/app/home-client.tsx +++ b/apps/web/app/home-client.tsx @@ -30,6 +30,13 @@ import { resolveDeviceLocationStateUpdatePlan, type DeviceMapLocation, } from "@/lib/device-location-map"; +import { + DEVICE_LOCATION_OPERATOR_EVIDENCE_REQUIRED, + DEVICE_LOCATION_OPERATOR_EVIDENCE_VERIFIED, + LOCATION_READINESS_STATUS_AVAILABLE, + LOCATION_READINESS_STATUS_UNAVAILABLE, + type DeviceLocationReadiness, +} from "@/lib/privacy/location-readiness"; import type { HomeMapContextualRestaurantsPayload } from "@/lib/home-map-contextual-restaurants"; import { buildHomeDetailState, @@ -124,12 +131,77 @@ const requestDesktopDetailReturnCapture = () => { window.dispatchEvent(new Event(HOME_DESKTOP_DETAIL_RETURN_CAPTURE_EVENT)); }; +const DEVICE_LOCATION_READINESS_BLOCKED_TOAST = + "현재 위치 기능은 운영자 근거가 확보될 때까지 활성화되지 않습니다. 지도 표시를 일시 중단합니다."; +const LOCATION_READINESS_ENDPOINT = "/api/privacy/location-readiness"; +const LOCATION_READINESS_TIMEOUT_MS = 1200; +const DEVICE_LOCATION_DISCLOSURE = + "현재 위치는 지도에 표시하기 위해 이 기기 메모리에서만 사용하며, 지도 SDK 화면 이동·타일 로딩은 지도 제공자 네트워크 경계를 통해 처리됩니다. 브라우저 위치 권한을 요청할까요?"; +const DEVICE_LOCATION_DISCLOSURE_CANCELLED = + "위치 권한을 요청하지 않았어요. 지도는 계속 이용할 수 있어요."; + + +const isDeviceLocationReadinessResponse = ( + value: unknown, +): value is DeviceLocationReadiness => { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + + const candidate = value as Record; + if (Object.keys(candidate).length !== 2) return false; + + const status = candidate.status; + const reasonCode = candidate.reasonCode; + return ( + (status === LOCATION_READINESS_STATUS_AVAILABLE || + status === LOCATION_READINESS_STATUS_UNAVAILABLE) && + (reasonCode === DEVICE_LOCATION_OPERATOR_EVIDENCE_VERIFIED || + reasonCode === DEVICE_LOCATION_OPERATOR_EVIDENCE_REQUIRED) + ); +}; + +const buildUnavailableDeviceLocationReadiness = (): DeviceLocationReadiness => ({ + status: LOCATION_READINESS_STATUS_UNAVAILABLE, + reasonCode: DEVICE_LOCATION_OPERATOR_EVIDENCE_REQUIRED, +}); + +const fetchDeviceLocationReadiness = async (): Promise => { + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), LOCATION_READINESS_TIMEOUT_MS); + + try { + const response = await fetch(LOCATION_READINESS_ENDPOINT, { + method: "GET", + cache: "no-store", + credentials: "same-origin", + headers: { + Accept: "application/json", + "Cache-Control": "no-store", + }, + signal: controller.signal, + }); + + if (!response.ok) { + return buildUnavailableDeviceLocationReadiness(); + } + + const payload: unknown = await response.json().catch(() => null); + if (!isDeviceLocationReadinessResponse(payload)) { + return buildUnavailableDeviceLocationReadiness(); + } + + return payload; + } catch { + return buildUnavailableDeviceLocationReadiness(); + } finally { + clearTimeout(timeout); + } +}; + const HOME_INITIAL_SHELL_INTENT_KEY = "tzudong:home-initial-intent"; const DEVICE_LOCATION_ENABLE_TOAST = "위치 서비스(GPS) 기능을 켜주세요."; function clearAnnouncementPanelUrl() { if (typeof window === "undefined") return; - const currentUrl = new URL(window.location.href); if ( currentUrl.pathname !== "/" || @@ -204,6 +276,7 @@ export default function HomeClient() { const deviceLocationFocusRequestIdRef = useRef(0); const deviceLocationWatchIdRef = useRef(null); const deviceOrientationCleanupRef = useRef<(() => void) | null>(null); + const isDeviceLocationModeActiveRef = useRef(false); const openPanelRef = useRef<(panel: PanelType) => void>(() => {}); const openDetailPanelRef = useRef< (restaurant: Restaurant, focusZoom?: number, options?: HomeDetailOpenOptions) => void @@ -715,7 +788,14 @@ export default function HomeClient() { mode: "position" | "heading", options: { shouldFocus: boolean }, ) => { + if (!isDeviceLocationModeActiveRef.current) return; + const nextHeading = resolveGeolocationHeading(position.coords.heading); + const latitude = position.coords.latitude; + const longitude = position.coords.longitude; + + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return; + const nextFocusRequestId = options.shouldFocus ? deviceLocationFocusRequestIdRef.current + 1 : deviceLocationFocusRequestIdRef.current; @@ -726,8 +806,8 @@ export default function HomeClient() { setDeviceLocation((previous) => { const nextLocation: DeviceMapLocation = { - lat: position.coords.latitude, - lng: position.coords.longitude, + lat: latitude, + lng: longitude, accuracy: Number.isFinite(position.coords.accuracy) ? position.coords.accuracy : null, @@ -760,8 +840,26 @@ export default function HomeClient() { deviceOrientationCleanupRef.current = null; }, []); + const clearDeviceLocationRuntimeState = useCallback(() => { + isDeviceLocationModeActiveRef.current = false; + stopDeviceHeadingWatchers(); + }, [stopDeviceHeadingWatchers]); + + const deactivateDeviceLocationSession = useCallback(() => { + clearDeviceLocationRuntimeState(); + setIsDeviceHeadingMode(false); + setDeviceLocation(null); + setIsDeviceLocationPending(false); + deviceLocationFocusRequestIdRef.current = 0; + }, [clearDeviceLocationRuntimeState]); + const startDeviceOrientationTracking = useCallback(async () => { - if (typeof window === "undefined") return false; + if ( + typeof window === "undefined" || + !isDeviceLocationModeActiveRef.current + ) { + return false; + } const OrientationEvent = window.DeviceOrientationEvent as | (typeof DeviceOrientationEvent & { @@ -794,6 +892,8 @@ export default function HomeClient() { const handleOrientation = ( event: DeviceOrientationEvent & { webkitCompassHeading?: number | null }, ) => { + if (!isDeviceLocationModeActiveRef.current) return; + const heading = resolveDeviceOrientationHeading(event); if (heading === null) return; @@ -830,6 +930,7 @@ export default function HomeClient() { const startDeviceLocationWatch = useCallback( (mode: "position" | "heading") => { if ( + !isDeviceLocationModeActiveRef.current || typeof navigator === "undefined" || !navigator.geolocation || deviceLocationWatchIdRef.current !== null @@ -854,21 +955,40 @@ export default function HomeClient() { return; } - const nextMode: "position" | "heading" = deviceLocation + const isSessionActive = isDeviceLocationModeActiveRef.current && Boolean(deviceLocation); + if (isSessionActive && isDeviceHeadingMode) { + deactivateDeviceLocationSession(); + return; + } + + const nextMode: "position" | "heading" = isSessionActive ? "heading" : "position"; + setIsDeviceLocationPending(true); + const preserveSession = isSessionActive; try { - const position = await new Promise( - (resolve, reject) => { - navigator.geolocation.getCurrentPosition(resolve, reject, { - enableHighAccuracy: true, - maximumAge: 3000, - timeout: 12000, - }); - }, - ); + const readiness = await fetchDeviceLocationReadiness(); + if ( + readiness.status !== LOCATION_READINESS_STATUS_AVAILABLE || + readiness.reasonCode !== DEVICE_LOCATION_OPERATOR_EVIDENCE_VERIFIED + ) { + toast.error(DEVICE_LOCATION_READINESS_BLOCKED_TOAST); + return; + } + + if (!preserveSession) { + isDeviceLocationModeActiveRef.current = true; + } + + const position = await new Promise((resolve, reject) => { + navigator.geolocation.getCurrentPosition(resolve, reject, { + enableHighAccuracy: true, + maximumAge: 3000, + timeout: 12000, + }); + }); if (nextMode === "heading") { setIsDeviceHeadingMode(true); @@ -883,23 +1003,43 @@ export default function HomeClient() { applyDevicePosition(position, nextMode, { shouldFocus: true }); } catch { + if (!preserveSession) { + deactivateDeviceLocationSession(); + } toast.error(DEVICE_LOCATION_ENABLE_TOAST); } finally { setIsDeviceLocationPending(false); } }, [ applyDevicePosition, + deactivateDeviceLocationSession, deviceLocation, + isDeviceHeadingMode, startDeviceLocationWatch, startDeviceOrientationTracking, stopDeviceHeadingWatchers, ]); + useEffect(() => { + if (typeof document === "undefined") return; + + const handleVisibilityChange = () => { + if (document.visibilityState === "hidden") { + deactivateDeviceLocationSession(); + } + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [deactivateDeviceLocationSession]); + useEffect( () => () => { - stopDeviceHeadingWatchers(); + clearDeviceLocationRuntimeState(); }, - [stopDeviceHeadingWatchers], + [clearDeviceLocationRuntimeState], ); const shouldRenderSidePanels = Boolean( diff --git a/apps/web/app/insights/insights-client.tsx b/apps/web/app/insights/insights-client.tsx index 826a0fded2..980a1c1225 100644 --- a/apps/web/app/insights/insights-client.tsx +++ b/apps/web/app/insights/insights-client.tsx @@ -1,15 +1,18 @@ 'use client'; -import { memo, useCallback, useEffect, useMemo, useRef, useState, type MouseEvent, useTransition } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState, type MouseEvent, type ReactNode, useTransition } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useRouter } from 'next/navigation'; import { hierarchy, treemap, treemapResquarify, type HierarchyRectangularNode } from 'd3-hierarchy'; +import { Lightbulb } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { AdminEmbeddedModuleShell } from '@/components/admin/AdminEmbeddedModuleShell'; import { Card, CardContent } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; import { useAuth } from '@/contexts/AuthContext'; import type { InsightTreemapPeriod, InsightTreemapResponse, InsightTreemapVideoRow } from '@/lib/public-insights/treemap'; import { useDeviceType } from '@/hooks/useDeviceType'; +import { cn } from '@/lib/utils'; type ViewMode = 'all' | 'category' | 'change'; @@ -1204,6 +1207,8 @@ export default function InsightsClient({ embedded = false }: { embedded?: boolea ? '개별 영상' : `${formatClusterValueByMode(metricMode, clusterStep)} 단위 클러스터`; const treemapContextText = `트리맵 기준: ${metricLabel} · ${periodLabel} · ${selectedCount.toLocaleString()}개 영상 · ${modeLabel} · ${clusterContextText}`; + const moduleSummaryText = `${selectedCount.toLocaleString()}개 영상 · ${metricLabel} · ${periodLabel} · ${modeLabel} · ${clusterContextText}`; + const treemapLegendText = viewMode === 'change' ? `색상 범례: ${periodLabel} 대비 ${metricLabel} 증감률이 높을수록 밝은 초록색입니다.` : `색상 범례: 전체 ${metricLabel} 비중이 높을수록 밝은 초록색입니다.`; @@ -1258,11 +1263,31 @@ export default function InsightsClient({ embedded = false }: { embedded?: boolea }, [treemapQuery]); + const renderEmbeddedShell = (children: ReactNode, summary: ReactNode = moduleSummaryText) => ( + + {children} + + ); + if (isLoading && !canRender) { + if (embedded) { + return renderEmbeddedShell( + , + '데이터를 불러오는 중입니다.', + ); + } + return ; } if (treemapQuery.isError || !treemapQuery.data) { - return ( + const errorContent = (
⚠️
@@ -1290,16 +1315,20 @@ export default function InsightsClient({ embedded = false }: { embedded?: boolea
); + + if (embedded) { + return renderEmbeddedShell(errorContent, '데이터를 불러오지 못했습니다.'); + } + + return errorContent; } - return ( -
-
+ const insightsContent = (
-
-
+
+

전체 {selectedCount.toLocaleString()}개

@@ -1486,6 +1515,16 @@ export default function InsightsClient({ embedded = false }: { embedded?: boolea
+ ); + + if (embedded) { + return renderEmbeddedShell(insightsContent); + } + + return ( +
+
+ {insightsContent}
); diff --git a/apps/web/app/mypage/profile/page.tsx b/apps/web/app/mypage/profile/page.tsx index e477051d9f..0517a0b09a 100644 --- a/apps/web/app/mypage/profile/page.tsx +++ b/apps/web/app/mypage/profile/page.tsx @@ -53,6 +53,12 @@ import { useUserProfile, } from "@/hooks/useUserProfile"; import { cn } from "@/lib/utils"; +import { + createAccountDeletionReauthenticationSession, + issueAccountDeletionReauthenticationProof, + parseAccountDeletionPreview, + type AccountDeletionPreview, +} from "@/lib/privacy/account-deletion-reauth"; interface Profile { nickname: string; @@ -61,6 +67,41 @@ interface Profile { const PROFILE_SELECT = "nickname, avatar_url"; +function isAuthoritativeAccountDeletionBeginReceipt( + receipt: unknown, + requestId: string, +): receipt is { success: true; begin: Record } { + if (!receipt || typeof receipt !== "object") return false; + const response = receipt as { success?: unknown; begin?: unknown }; + const begin = response.begin; + return ( + response.success === true && + begin !== null && + typeof begin === "object" && + (begin as Record).request_id === requestId && + typeof (begin as Record).status === "string" && + typeof (begin as Record).db_readback_passed === "boolean" && + typeof (begin as Record).storage_readback_passed === "boolean" && + typeof (begin as Record).session_readback_passed === "boolean" && + typeof (begin as Record).auth_readback_passed === "boolean" + ); +} +const accountDeletionFailureMessages: Record = { + account_deletion_reauth_proof_invalid_claims: "현재 비밀번호를 다시 확인해주세요.", + account_deletion_reauth_proof_password_reauthentication_required: "현재 비밀번호를 다시 확인해주세요.", + account_deletion_reauthentication_required: "현재 비밀번호를 다시 확인해주세요.", + account_deletion_reauth_proof_not_available: "재인증 증명이 만료되었거나 이미 사용되었습니다. 비밀번호를 다시 확인해주세요.", + account_deletion_apply_not_started: "계정 삭제 요청을 시작하지 못했습니다.", +}; + +function accountDeletionFailureMessage(receipt: unknown, fallback: string): string { + if (!receipt || typeof receipt !== "object") return fallback; + const reasonCode = (receipt as { reasonCode?: unknown }).reasonCode; + return typeof reasonCode === "string" ? accountDeletionFailureMessages[reasonCode] ?? fallback : fallback; +} + + + export default function ProfilePage() { const { user, profileNickname } = useAuth(); const router = useRouter(); @@ -88,6 +129,16 @@ export default function ProfilePage() { // 계정 완전 삭제 const [deleteConfirmationEmail, setDeleteConfirmationEmail] = useState(""); + const [deletePassword, setDeletePassword] = useState(""); + const [deletionSession, setDeletionSession] = useState<{ + preview: AccountDeletionPreview; + proofId: string; + expiresAt: string; + bearerToken: string; + } | null>(null); + useEffect(() => () => { + setDeletionSession(null); + }, []); const loadProfile = useCallback(async () => { if (!user) return; @@ -377,53 +428,102 @@ export default function ProfilePage() { } }; - // 계정 완전 삭제 (Supabase Auth에서 삭제) + // 계정 삭제는 서버의 G014 durable worker가 처리한다. const handleAccountPermanentDelete = async () => { - if (!user) return; - - if (deleteConfirmationEmail !== user.email) { - toast.error("이메일이 일치하지 않습니다"); + if (!user || !user.email) return; + if (deleteConfirmationEmail !== user.email || (!deletionSession && !deletePassword)) { + toast.error("이메일과 현재 비밀번호를 확인해주세요"); return; } setLoading(true); try { + if (!deletionSession) { + const reauthentication = await createAccountDeletionReauthenticationSession({ + userId: user.id, + email: user.email, + password: deletePassword, + }); + const previewResponse = await fetch("/api/account/delete", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${reauthentication.bearerToken}`, + }, + body: JSON.stringify({ targetUserId: user.id }), + }); + const previewReceipt = await previewResponse.json().catch(() => null); + const preview = parseAccountDeletionPreview(previewReceipt?.preview); + if ( + !previewResponse.ok || + !preview || + typeof preview.previewHash !== "string" || + typeof preview.sourceManifestHash !== "string" + ) { + throw new Error("계정 삭제 미리보기를 만들지 못했습니다"); + } + + const proof = await issueAccountDeletionReauthenticationProof({ userId: user.id }); + setDeletionSession({ + preview, + proofId: proof.proofId, + expiresAt: proof.expiresAt, + bearerToken: reauthentication.bearerToken, + }); + setDeletePassword(""); + toast.warning("삭제 범위를 확인했습니다. 영구 삭제를 한 번 더 확인해주세요."); + return; + } + + if (Date.parse(deletionSession.expiresAt) <= Date.now()) { + setDeletionSession(null); + throw new Error("재인증 증명이 만료되었습니다. 비밀번호를 다시 확인해주세요."); + } + + const bindings = { + requestId: crypto.randomUUID(), + previewHash: deletionSession.preview.previewHash, + idempotencyKey: crypto.randomUUID(), + sourceManifestHash: deletionSession.preview.sourceManifestHash, + }; const response = await fetch("/api/account/delete", { method: "DELETE", headers: { "Content-Type": "application/json", + Authorization: `Bearer ${deletionSession.bearerToken}`, }, - body: JSON.stringify({ userId: user.id }), + body: JSON.stringify({ + userId: user.id, + proofId: deletionSession.proofId, + requestId: bindings.requestId, + previewHash: bindings.previewHash, + confirmationText: deleteConfirmationEmail, + idempotencyKey: bindings.idempotencyKey, + sourceManifestHash: bindings.sourceManifestHash, + }), }); - - if (!response.ok) { - const data = await response.json(); - throw new Error(data.error || "계정 삭제에 실패했습니다"); + const serverReceipt = await response.json().catch(() => null); + if ( + !response.ok || + !isAuthoritativeAccountDeletionBeginReceipt(serverReceipt, bindings.requestId) + ) { + throw new Error( + accountDeletionFailureMessage(serverReceipt, "계정 삭제 요청을 시작하지 못했습니다."), + ); } - toast.success( - "계정이 영구적으로 삭제되었습니다. 잠시 후 홈으로 이동합니다.", - ); - - // 세션 정리 - await supabase.auth.signOut(); - - // localStorage에서 Supabase 관련 항목 정리 - Object.keys(localStorage).forEach((key) => { - if (key.startsWith("sb-") || key.startsWith("supabase")) { - localStorage.removeItem(key); - } - }); - - setTimeout(() => { - window.location.href = "/"; - }, 2000); + setDeletionSession(null); + setDeleteConfirmationEmail(""); + setDeletePassword(""); + toast.success("계정 삭제 요청이 접수되었습니다. 처리 상태를 확인해 주세요."); + router.refresh(); } catch (error) { - const errorMessage = + setDeletionSession(null); + toast.error( error instanceof Error ? error.message - : "계정 삭제 중 오류가 발생했습니다"; - toast.error(errorMessage); + : "계정 삭제 요청 처리에 실패했습니다. 잠시 후 다시 시도해주세요.", + ); } finally { setLoading(false); } @@ -1303,6 +1403,16 @@ export default function ProfilePage() { name="delete-confirmation-email" autoComplete="email" /> + setDeletePassword(e.target.value)} + placeholder="현재 비밀번호" + type="password" + className="mt-3" + aria-label="계정 영구 삭제 현재 비밀번호" + name="delete-current-password" + autoComplete="current-password" + /> {deleteConfirmationEmail && deleteConfirmationEmail !== user.email && (

@@ -1312,15 +1422,22 @@ export default function ProfilePage() {

setDeleteConfirmationEmail("")} + onClick={() => { + setDeleteConfirmationEmail(""); + setDeletePassword(""); + setDeletionSession(null); + }} > 취소 - {loading ? ( @@ -1328,10 +1445,12 @@ export default function ProfilePage() { 삭제 중… + ) : deletionSession ? ( + "영구 삭제 다시 확인" ) : ( - "영구 삭제" + "삭제 범위 확인" )} - + diff --git a/apps/web/app/mypage/submissions/edit/page.tsx b/apps/web/app/mypage/submissions/edit/page.tsx index 4c9b98b4bd..6a2eee2590 100644 --- a/apps/web/app/mypage/submissions/edit/page.tsx +++ b/apps/web/app/mypage/submissions/edit/page.tsx @@ -146,7 +146,7 @@ export default function EditSubmissionsPage() { .range(pageParam, pageParam + PAGE_SIZE - 1); if (error) throw error; - const typedSubmissions = (submissions ?? []) as Submission[]; + const typedSubmissions = (submissions ?? []) as unknown as Submission[]; if (typedSubmissions.length === 0) { return { data: [], nextCursor: null }; } @@ -161,7 +161,7 @@ export default function EditSubmissionsPage() { .order("created_at", { ascending: true }); if (itemsError) throw itemsError; - const typedItems = (items ?? []) as SubmissionItem[]; + const typedItems = (items ?? []) as unknown as SubmissionItem[]; // 아이템에서 target_restaurant_id 추출 const targetRestaurantIds = [ diff --git a/apps/web/app/mypage/submissions/new/page.tsx b/apps/web/app/mypage/submissions/new/page.tsx index 743598eb4b..c9adb7066b 100644 --- a/apps/web/app/mypage/submissions/new/page.tsx +++ b/apps/web/app/mypage/submissions/new/page.tsx @@ -139,7 +139,8 @@ export default function NewSubmissionsPage() { return { data: [], nextCursor: null }; } - const submissionIds = submissions.map((s: { id: string }) => s.id); + const typedSubmissions = submissions as unknown as Submission[]; + const submissionIds = typedSubmissions.map((submission) => submission.id); const { data: items, error: itemsError } = await supabase .from("restaurant_submission_items") @@ -149,8 +150,7 @@ export default function NewSubmissionsPage() { if (itemsError) throw itemsError; - const typedSubmissions = submissions as Submission[]; - const typedItems = (items || []) as SubmissionItem[]; + const typedItems = (items || []) as unknown as SubmissionItem[]; const submissionsWithItems: Submission[] = typedSubmissions.map( (submission) => ({ diff --git a/apps/web/bun.lock b/apps/web/bun.lock index 5a3f9d3d84..280b2028e0 100644 --- a/apps/web/bun.lock +++ b/apps/web/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "web", @@ -35,8 +36,8 @@ "@radix-ui/react-toggle": "^1.1.9", "@radix-ui/react-toggle-group": "^1.1.8", "@radix-ui/react-tooltip": "^1.2.7", - "@supabase/ssr": "^0.8.0", - "@supabase/supabase-js": "^2.101.1", + "@supabase/ssr": "^0.10.3", + "@supabase/supabase-js": "^2.106.2", "@tanstack/react-query": "^5.83.0", "@vercel/speed-insights": "^1.3.1", "browser-image-compression": "^2.0.2", @@ -55,9 +56,9 @@ "next": "^16.2.6", "next-themes": "^0.3.0", "openai": "^6.10.0", - "react": "^19.0.0", - "react-day-picker": "^8.10.1", - "react-dom": "^19.0.0", + "react": "^19.2.6", + "react-day-picker": "^9.14.0", + "react-dom": "^19.2.6", "react-error-boundary": "^6.0.0", "react-hook-form": "^7.61.1", "react-markdown": "^10.1.0", @@ -74,6 +75,7 @@ "zod": "^3.25.76", }, "devDependencies": { + "@babel/core": "^7.29.6", "@next/bundle-analyzer": "^16.2.6", "@playwright/test": "^1.57.0", "@types/d3-cloud": "^1.2.9", @@ -89,6 +91,7 @@ "eslint": "^9.32.0", "eslint-config-next": "^16.2.6", "postcss": "^8.5.15", + "supabase": "^2.101.0", "tailwindcss": "^3.4.17", "typescript": "^5.8.3", }, @@ -101,42 +104,46 @@ "packages": { "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], - "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], - "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], - "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], - "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], - "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], - "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], - "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], - "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], - "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], "@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], - "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], - "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], - "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@date-fns/tz": ["@date-fns/tz@1.5.0", "", {}, "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg=="], "@discoveryjs/json-ext": ["@discoveryjs/json-ext@0.5.7", "", {}, "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw=="], + "@ecies/ciphers": ["@ecies/ciphers@0.2.6", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g=="], + "@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="], "@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], @@ -293,6 +300,12 @@ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA=="], + "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], + + "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], + + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -417,24 +430,42 @@ "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], - "@supabase/auth-js": ["@supabase/auth-js@2.103.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-6zAanO6c+6gpHOlt5Lb9TlBBkJdZiUWkWCJKAxzkywBDcwaHlLJKXnjQGX6GyVCyKRR1e7sTq4re/yRTH6U/9A=="], + "@supabase/auth-js": ["@supabase/auth-js@2.110.8", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-TQ5neTUDX2C2WmyYa03yGhLMkhdE/SkHXtK8/qxO/APUy3rsymsJCBP48p4jcN6iO2G0ow6RRexQd2mX+dSyJg=="], + + "@supabase/cli-darwin-arm64": ["@supabase/cli-darwin-arm64@2.109.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tkn8tfunyqIL7RE+7DVjg6Ql2cJLPkGgh9cPafp2LbXI0qDgds0TaS+UOTHQEjci8JQXXe2wS00+122ko2QI8A=="], + + "@supabase/cli-darwin-x64": ["@supabase/cli-darwin-x64@2.109.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-0Q5ZAoWhOyIv4ZzQOU8QbjjdB2JmznnDNyJ3VrIeuLMWoifVfUWaLZhHBNYzr4xXoxBpKrggomuAT8BaEhY3lg=="], + + "@supabase/cli-linux-arm64": ["@supabase/cli-linux-arm64@2.109.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MS1djJjq5laD99+jYJUoARfSZhzRX9aXmo5piZ1yl2JXb9IXTuhiTD5olkFKQcgNckRcAZqMf4fucQGTYC767A=="], + + "@supabase/cli-linux-arm64-musl": ["@supabase/cli-linux-arm64-musl@2.109.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-cXNOsSU7MS+jmslGQATTD4DfWBEIHiFi7LaBWyBxHmR7tzIiZXimUXgN26ZiQjfAxU+RZq52C3k0qhi7vmqXQw=="], + + "@supabase/cli-linux-x64": ["@supabase/cli-linux-x64@2.109.1", "", { "os": "linux", "cpu": "x64" }, "sha512-svFmamF/vIq4/oinwY50jDi869itC9/GWrPaGtsHFkK4NUBcQtl1T37WWIivGsXwbBKNC4FjZD3dGqjL7bfW1g=="], + + "@supabase/cli-linux-x64-musl": ["@supabase/cli-linux-x64-musl@2.109.1", "", { "os": "linux", "cpu": "x64" }, "sha512-zeD9MrZpEKJrjkSPcYp4nZeGJ9FQt0i/kiRij4ZprPP6R5l+SLi6Pk20ln+Vj/GZuWTVxX7IHJ11pgViaReAWw=="], + + "@supabase/cli-windows-arm64": ["@supabase/cli-windows-arm64@2.109.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-NeKzgWAOpglnLwMTggTp5t44fbkfxACLkQNlCRx0q332HMM3WqsKQUsHv1MHc6ZbEc5bcMwaYjqPNI/aOWnP5g=="], + + "@supabase/cli-windows-x64": ["@supabase/cli-windows-x64@2.109.1", "", { "os": "win32", "cpu": "x64" }, "sha512-L9/pDLM+4IR8646aT69ZDVcnJeg1lZZsB26ZgI775S3f8jOHP41+1UH9Oq1g9CvKiAQviuaLgtwkuvi0I/cc/A=="], - "@supabase/functions-js": ["@supabase/functions-js@2.103.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-YrneV2NjskUkkmkZ2Jt2n3elBgbWzV4Y1M9MM370z2Zd5ZPFqFbY8KIoPwuNjtAGE9YrpKBxnbZqeF07BiN9Og=="], + "@supabase/functions-js": ["@supabase/functions-js@2.110.8", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-5yB9TLYzvv2oSQxwb0gamEvIAsuH66pVt7AM/pz03S7wN6ehD34GNgbShrccetqPedXQSz7e/1hAJ9NeEhoZVg=="], - "@supabase/phoenix": ["@supabase/phoenix@0.4.0", "", {}, "sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw=="], + "@supabase/phoenix": ["@supabase/phoenix@0.4.5", "", {}, "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw=="], - "@supabase/postgrest-js": ["@supabase/postgrest-js@2.103.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-rC3sRxYdPZymkp2CZR1MiNQgbOleD01bGsW8VxEKRR5nMkLZ1NgAS1QTQf78Wh30czFyk505ZYr9Od8/mWT2TA=="], + "@supabase/postgrest-js": ["@supabase/postgrest-js@2.110.8", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-QeRROxl1PpOZw5Jzi7BwdN9icsycMrLlCCvsjS0hYLW+nZoaT46zdagz/glJirj8jHF4jSd5Jyipuae2cBClCw=="], - "@supabase/realtime-js": ["@supabase/realtime-js@2.103.0", "", { "dependencies": { "@supabase/phoenix": "^0.4.0", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" } }, "sha512-gcPtXzZ6izyyBVf2of7K3dEt8CScPJn8VcSlQq6oWL9QoE1kqfQl0oFrOMHd5qrcADewxI7OxxosLB8W4XqtIQ=="], + "@supabase/realtime-js": ["@supabase/realtime-js@2.110.8", "", { "dependencies": { "@supabase/phoenix": "0.4.5", "tslib": "2.8.1" } }, "sha512-mwX7ituX6O31fLf+0g65rpLlNxqgnMaPltPsQwzox6jfmbfVl3tCxXrfr3HEsQcCRjpjuJG1+A0vFzP1yVjKHA=="], - "@supabase/ssr": ["@supabase/ssr@0.8.0", "", { "dependencies": { "cookie": "^1.0.2" }, "peerDependencies": { "@supabase/supabase-js": "^2.76.1" } }, "sha512-/PKk8kNFSs8QvvJ2vOww1mF5/c5W8y42duYtXvkOSe+yZKRgTTZywYG2l41pjhNomqESZCpZtXuWmYjFRMV+dw=="], + "@supabase/ssr": ["@supabase/ssr@0.10.3", "", { "dependencies": { "cookie": "^1.0.2" }, "peerDependencies": { "@supabase/supabase-js": "^2.105.3" } }, "sha512-ux2CJgX89h0Fz2lY7ZNafNG2SkXpyRc5dz77K9eKeBLPdtywQixKwIuetDeIViAJBp/buOUVmgj8PVesOklNpw=="], - "@supabase/storage-js": ["@supabase/storage-js@2.103.0", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-DHmlvdAXwtOmZNbkIZi4lkobPR3XjIzoOgzoz5duMf6G+sDeY015YrzMJCnqdccuYr7X5x4yYuSwF//RoN2dvQ=="], + "@supabase/storage-js": ["@supabase/storage-js@2.110.8", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-CcfhkZFBLxsthgUabZKxwfsoXdrikIGsL3LsGoV3FZTqCMx/s1y49taT4jT/oya5+1IuB0sFFHw6pF0o0iJniQ=="], - "@supabase/supabase-js": ["@supabase/supabase-js@2.103.0", "", { "dependencies": { "@supabase/auth-js": "2.103.0", "@supabase/functions-js": "2.103.0", "@supabase/postgrest-js": "2.103.0", "@supabase/realtime-js": "2.103.0", "@supabase/storage-js": "2.103.0" } }, "sha512-j/6q5+LtXbR/YOLSLhy7Na74RD1cV2v+KwIIuuqMEjk1JpLEEyu0ynwDHpGoxMncDQl+R5FogaVqZm+85lZvtw=="], + "@supabase/supabase-js": ["@supabase/supabase-js@2.110.8", "", { "dependencies": { "@supabase/auth-js": "2.110.8", "@supabase/functions-js": "2.110.8", "@supabase/postgrest-js": "2.110.8", "@supabase/realtime-js": "2.110.8", "@supabase/storage-js": "2.110.8" } }, "sha512-E5qzoe74zhJRv4wRcbO9eMYzeQDb/+h6c603pL8shcxLGBjTKsIF7XXj05IcNj23TLDgJN1WkMw7mwAPyu5dZg=="], "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], + "@tabby_ai/hijri-converter": ["@tabby_ai/hijri-converter@1.0.5", "", {}, "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ=="], + "@tanstack/query-core": ["@tanstack/query-core@5.90.11", "", {}, "sha512-f9z/nXhCgWDF4lHqgIE30jxLe4sYv15QodfdPDKYAk7nAEjNcndy4dHz3ezhdUaR23BpWa4I2EH4/DZ0//Uf8A=="], "@tanstack/react-query": ["@tanstack/react-query@5.90.11", "", { "dependencies": { "@tanstack/query-core": "5.90.11" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-3uyzz01D1fkTLXuxF3JfoJoHQMU2fxsfJwE+6N5hHy0dVNoZOvwKP8Z2k7k1KDeD54N20apcJnG75TBAStIrBA=="], @@ -495,8 +526,6 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], - "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.58.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/type-utils": "8.58.1", "@typescript-eslint/utils": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.58.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.58.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", "@typescript-eslint/typescript-estree": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw=="], @@ -717,6 +746,8 @@ "date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="], + "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="], + "debounce": ["debounce@1.2.1", "", {}, "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -755,6 +786,8 @@ "earcut": ["earcut@3.0.2", "", {}, "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ=="], + "eciesjs": ["eciesjs@0.5.0", "", { "dependencies": { "@ecies/ciphers": "^0.2.6", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" } }, "sha512-s0J9SEVYAEPg7J63GFMApLYzPH9VNIQIyC6s15JpnqVc0TqcKWdbgFlnAweEBRyMmko2dcs2sfC83Hj4J43tuA=="], + "electron-to-chromium": ["electron-to-chromium@1.5.262", "", {}, "sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ=="], "embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="], @@ -1007,6 +1040,8 @@ "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + "jose": ["jose@6.2.4", "", {}, "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -1021,7 +1056,7 @@ "json-stringify-pretty-compact": ["json-stringify-pretty-compact@4.0.0", "", {}, "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q=="], - "json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], @@ -1267,11 +1302,11 @@ "quickselect": ["quickselect@3.0.0", "", {}, "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g=="], - "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], + "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], - "react-day-picker": ["react-day-picker@8.10.1", "", { "peerDependencies": { "date-fns": "^2.28.0 || ^3.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA=="], + "react-day-picker": ["react-day-picker@9.14.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="], - "react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="], + "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], "react-error-boundary": ["react-error-boundary@6.0.0", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "react": ">=16.13.1" } }, "sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA=="], @@ -1335,7 +1370,7 @@ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], @@ -1395,6 +1430,8 @@ "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], + "supabase": ["supabase@2.109.1", "", { "dependencies": { "eciesjs": "^0.5.0", "jose": "^6.2.3" }, "optionalDependencies": { "@supabase/cli-darwin-arm64": "2.109.1", "@supabase/cli-darwin-x64": "2.109.1", "@supabase/cli-linux-arm64": "2.109.1", "@supabase/cli-linux-arm64-musl": "2.109.1", "@supabase/cli-linux-x64": "2.109.1", "@supabase/cli-linux-x64-musl": "2.109.1", "@supabase/cli-windows-arm64": "2.109.1", "@supabase/cli-windows-x64": "2.109.1" }, "bin": { "supabase": "dist/supabase.js" } }, "sha512-N2yP2MHTxOxXBWhfn3poudpJn4pkPosAUo7J/46FTou/l7wOwFi9tox8NSN6HljWkfM0zhwPRimNNGC9XBMoxQ=="], + "supercluster": ["supercluster@8.0.1", "", { "dependencies": { "kdbush": "^4.0.2" } }, "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -1515,12 +1552,6 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@babel/core/json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - - "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], @@ -1557,12 +1588,12 @@ "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@supabase/realtime-js/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], - "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "@typescript-eslint/utils/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], @@ -1579,32 +1610,78 @@ "eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], - "eslint-plugin-import/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "eslint-plugin-react/resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="], - "eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "eslint-plugin-react-hooks/@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + + "eslint-plugin-react-hooks/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "fdir/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "is-bun-module/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "react-day-picker/date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="], + + "sharp/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], "tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], + "@next/eslint-plugin-next/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], "@typescript-eslint/utils/@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "eslint-plugin-react-hooks/@babel/core/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "eslint-plugin-react-hooks/@babel/parser/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/helper-compilation-targets/@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/helper-module-transforms/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/traverse/@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "eslint-plugin-react-hooks/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "eslint-plugin-react-hooks/@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], } } diff --git a/apps/web/components/admin/AdminConsoleOverview.tsx b/apps/web/components/admin/AdminConsoleOverview.tsx index 0c0e9d0d42..10ce59faf3 100644 --- a/apps/web/components/admin/AdminConsoleOverview.tsx +++ b/apps/web/components/admin/AdminConsoleOverview.tsx @@ -129,6 +129,7 @@ import { type AdminConsoleRouteModuleId, } from "@/lib/admin/admin-module-routing"; import { TrendProposalQueue } from "@/components/admin/TrendProposalQueue"; +import { AdminEmbeddedModuleShell } from "@/components/admin/AdminEmbeddedModuleShell"; type AdminModuleId = AdminConsoleRouteModuleId; type ConsoleModuleId = Exclude; @@ -9008,83 +9009,67 @@ function GuardedApplyCard() { function LlmSessionWorkspace() { return ( -
-
-
-
-

읽기 전용 보조

-

- 자동 운영 보조 -

-

- 선택한 운영 모듈의 상황을 요약하고 다음 검수 순서를 제안하는 읽기 - 전용 보조 화면입니다. 실제 승인·삭제·공개 변경은 관리자 확인과 - 상태 재확인 이후에만 진행됩니다. -

-
- - 읽기 전용 - + +
+
+ {[ + [ + "현재 화면 요약", + "선택한 모듈의 대기 건수, 실패 상태, 위험 액션 후보를 한 문단으로 요약합니다.", + ], + [ + "다음 검수 추천", + "오래된 제보, 지오코딩 실패, 미승인 리뷰, 배너 공개 변경을 우선순위로 정리합니다.", + ], + [ + "위험 액션 체크리스트", + "삭제·반려·공개 배너 변경 전 미리보기 → 확인 → 적용 → 재확인 → 감사 기록 순서를 확인합니다.", + ], + ].map(([title, description]) => ( + + + {title} + + +

+ {description} +

+
+
+ ))}
-
-
- {[ - [ - "현재 화면 요약", - "선택한 모듈의 대기 건수, 실패 상태, 위험 액션 후보를 한 문단으로 요약합니다.", - ], - [ - "다음 검수 추천", - "오래된 제보, 지오코딩 실패, 미승인 리뷰, 배너 공개 변경을 우선순위로 정리합니다.", - ], - [ - "위험 액션 체크리스트", - "삭제·반려·공개 배너 변경 전 미리보기 → 확인 → 적용 → 재확인 → 감사 기록 순서를 확인합니다.", - ], - ].map(([title, description]) => ( - +
+ + - {title} + + - -

- {description} + +

1. 자동 운영 보조는 읽기 전용 제안 화면으로 유지합니다.

+

+ 2. 데이터 변경, 권한 정책, 데이터 구조 변경은 이 화면에서 직접 + 수행하지 않습니다. +

+

+ 3. 위험 작업은 반드시 관리자 UI의 명시적 확인과 상태 재확인을 + 거칩니다.

- ))} -
- -
- - - - - - - -

1. 자동 운영 보조는 읽기 전용 제안 화면으로 유지합니다.

-

- 2. 데이터 변경, 권한 정책, 데이터 구조 변경은 이 화면에서 직접 - 수행하지 않습니다. -

-

- 3. 위험 작업은 반드시 관리자 UI의 명시적 확인과 상태 재확인을 - 거칩니다. -

-
-
-
-
+
+
+ ); } @@ -9147,39 +9132,15 @@ function AuditPlaceholder() { const adminAuditLoginHref = "/?auth=login&reason=admin&next=%2Fadmin%3Fmodule%3Daudit"; return ( - - -
-
-

실데이터 연결

- - 감사 로그 - -

- 사용자 관리 감사 이벤트는 admin_audit_events 기준의 부분/도메인별 - 범위에서 읽습니다. 맛집 추천 검토 감사는 restaurant_request_review_audit의 - 별도 도메인별 읽기 경로로 구분하며, 전체 운영 변경을 포괄하는 - 범용 감사 로그처럼 표시하지 않습니다. -

-
- - {coverageBadgeLabel} - -
-
- + +
admin_audit_events는 사용자 관리 감사의 현재 1차 피드이며, 맛집 추천 검토 감사는 restaurant_request_review_audit의 별도 도메인별 경로입니다. + 전체 운영 변경을 포괄하는 범용 감사 로그처럼 표시하지 않습니다.

@@ -9328,8 +9290,8 @@ function AuditPlaceholder() { ))} ) : null} - - +
+
); } @@ -9364,35 +9326,24 @@ function AdminMapOverlayOperationsModule() { ADMIN_MAP_OVERLAY_TABS[0]; return ( -
- - -
-
-

- 관리자 지도 오버레이 작업대 -

- - 지도 오버레이 - -
- - 승인 데이터: admin_restaurant_map_overlays - -
-

- 수동 오버레이, 트렌드 제안, 트렌드 실행 요청을 한 모듈에서 - 연결합니다. 승인된 지도 표시는 명시적 수동/RPC 흐름으로만 - 변경됩니다. -

-
- +
+
+ + 승인 데이터: admin_restaurant_map_overlays +
)}
- - -
+
+ + ); } function InlineModulePanel({ @@ -9558,6 +9509,8 @@ function InlineModulePanel({
(null); + const activeDescendantScrollTopRef = useRef(0); const previousMobileHeaderScrollTopRef = useRef(0); const lastAdminMobileChromeHideAtRef = useRef(0); const mobileChromeRevealTimeoutRef = useRef(null); @@ -10660,6 +10614,8 @@ export function AdminConsoleOverview({ (moduleId: AdminModuleId) => { const nextHref = buildCanonicalAdminModuleHref(moduleId); setActiveModuleId(moduleId); + activeDescendantScrollTopRef.current = 0; + previousMobileHeaderScrollTopRef.current = 0; setIsMobileHeaderVisible(true); router.replace(nextHref, { @@ -10704,6 +10660,8 @@ export function AdminConsoleOverview({ if (previousRequestedModuleIdRef.current !== nextModuleId) { previousRequestedModuleIdRef.current = nextModuleId; + activeDescendantScrollTopRef.current = 0; + previousMobileHeaderScrollTopRef.current = 0; setIsMobileHeaderVisible(true); } @@ -10722,7 +10680,11 @@ export function AdminConsoleOverview({ document.body.scrollTop, ); - return Math.max(canvasScrollTop, pageScrollTop); + return Math.max( + canvasScrollTop, + activeDescendantScrollTopRef.current, + pageScrollTop, + ); }, []); const updateMobileHeaderVisibility = useCallback(() => { @@ -10745,6 +10707,23 @@ export function AdminConsoleOverview({ }); }, [getAdminConsoleScrollTop]); + const updateActiveDescendantScrollTop = useCallback((eventTarget: EventTarget | null) => { + const canvasElement = canvasRef.current; + if (!canvasElement || !(eventTarget instanceof Element)) { + activeDescendantScrollTopRef.current = 0; + return; + } + + if (eventTarget === canvasElement) { + activeDescendantScrollTopRef.current = 0; + return; + } + + if (canvasElement.contains(eventTarget)) { + activeDescendantScrollTopRef.current = eventTarget.scrollTop; + } + }, []); + const adminBottomNavAutoHide = useMobileBottomNavAutoHide({ scrollRef: canvasRef, source: "admin-console", @@ -10811,12 +10790,20 @@ export function AdminConsoleOverview({ isAdminMobileViewport || window.matchMedia("(max-width: 767px)").matches; - if (isMobileViewportNow && currentScrollTop > 24) { - previousMobileHeaderScrollTopRef.current = currentScrollTop; if (mobileChromeHadDownScrollRef.current) { + previousMobileHeaderScrollTopRef.current = currentScrollTop; setAdminMobileChromeHidden(true); + return; } + + if (activeDescendantScrollTopRef.current > 0) { + updateMobileHeaderVisibility(); + adminBottomNavAutoHide.onScroll(); + return; + } + + previousMobileHeaderScrollTopRef.current = currentScrollTop; return; } @@ -10953,12 +10940,25 @@ export function AdminConsoleOverview({ ], ); + const handleAdminCapturedScroll = useCallback( + (event: Event) => { + updateActiveDescendantScrollTop(event.target); + handleAdminCanvasScroll(); + }, + [handleAdminCanvasScroll, updateActiveDescendantScrollTop], + ); + useEffect(() => { const canvasElement = canvasRef.current; const handleScroll = () => handleAdminCanvasScroll(); + const handleCapturedScroll = (event: Event) => + handleAdminCapturedScroll(event); window.addEventListener("scroll", handleScroll, { passive: true }); - canvasElement?.addEventListener("scroll", handleScroll, { passive: true }); + canvasElement?.addEventListener("scroll", handleCapturedScroll, { + capture: true, + passive: true, + }); window.addEventListener("wheel", handleAdminWindowWheel, { passive: true }); document.addEventListener("wheel", handleAdminWindowWheel, { capture: true, @@ -10970,13 +10970,13 @@ export function AdminConsoleOverview({ window.removeEventListener("scroll", handleScroll); window.removeEventListener("wheel", handleAdminWindowWheel); document.removeEventListener("wheel", handleAdminWindowWheel, true); - canvasElement?.removeEventListener("scroll", handleScroll); + canvasElement?.removeEventListener("scroll", handleCapturedScroll, true); if (mobileChromeRevealTimeoutRef.current !== null) { window.clearTimeout(mobileChromeRevealTimeoutRef.current); mobileChromeRevealTimeoutRef.current = null; } }; - }, [handleAdminCanvasScroll, handleAdminWindowWheel]); + }, [handleAdminCanvasScroll, handleAdminCapturedScroll, handleAdminWindowWheel]); useEffect(() => { const mobileHeaderElement = document.querySelector( '[data-admin-console-mobile-header="true"]', @@ -10999,6 +10999,11 @@ export function AdminConsoleOverview({ const isAdminCanvasBootstrapping = isShellBootstrapping || !loadedModuleIds.has(activeModuleId); + const overviewModuleSummary = statsLoading + ? "KPI 데이터를 불러오는 중입니다." + : statsHasError + ? "일부 KPI를 확인하지 못했습니다." + : `대기 ${formatNumber(stats.pendingTotal)}건 · 맛집 ${formatNumber(stats.totalRestaurants)}곳 · 영상 ${formatNumber(stats.totalVideos)}개`; return (
+ + + ) : activeModuleId === "routes" ? ( - + + + ) : activeModuleId === "llm" ? ( ) : activeModule ? ( diff --git a/apps/web/components/admin/AdminEmbeddedModuleShell.tsx b/apps/web/components/admin/AdminEmbeddedModuleShell.tsx new file mode 100644 index 0000000000..54729fa866 --- /dev/null +++ b/apps/web/components/admin/AdminEmbeddedModuleShell.tsx @@ -0,0 +1,91 @@ +"use client"; + +import type { ReactNode } from "react"; +import type { LucideIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import type { AdminConsoleRouteModuleId } from "@/lib/admin/admin-module-routing"; + +type AdminEmbeddedModuleShellProps = { + moduleId: AdminConsoleRouteModuleId; + titleId: string; + title: string; + icon: LucideIcon; + summary: ReactNode; + actions?: ReactNode; + children: ReactNode; + className?: string; + headerClassName?: string; + contentClassName?: string; + scrollOwner?: string; +}; + +export function AdminEmbeddedModuleShell({ + moduleId, + titleId, + title, + icon: Icon, + summary, + actions, + children, + className, + headerClassName, + contentClassName, + scrollOwner, +}: AdminEmbeddedModuleShellProps) { + return ( +
+
+
+
+
+
+
+ {summary} +
+
+ {actions ? ( +
+ {actions} +
+ ) : null} +
+
+
+ {children} +
+
+ ); +} diff --git a/apps/web/components/admin/AdminOverviewDashboard.tsx b/apps/web/components/admin/AdminOverviewDashboard.tsx index b006746f0c..a443a97aa5 100644 --- a/apps/web/components/admin/AdminOverviewDashboard.tsx +++ b/apps/web/components/admin/AdminOverviewDashboard.tsx @@ -1238,7 +1238,7 @@ function AdminMapInfoPanel({

{selectedRestaurant.name}

@@ -1249,7 +1249,7 @@ function AdminMapInfoPanel({

- + {selectedRestaurant.category ?? "카테고리 확인"} @@ -1258,7 +1258,7 @@ function AdminMapInfoPanel({ {selectedCoordinateText} - + {selectedRestaurant.videoId ?? "영상 ID 확인"}
@@ -1298,7 +1298,7 @@ function AdminMapInfoPanel({
-

+

{restaurant.name}

-

+

{restaurant.address ?? "주소 미입력"} ·{" "} {restaurant.category ?? "카테고리 확인"}

@@ -1565,7 +1565,7 @@ function AdminMapInfoPanel({ 후보 readback과 Directions providerCache를 포함해 재현 가능한 동선 패키지로 복사할 수 있습니다.

-
+            
               {routePlainTextExport}