← back to Norma

app/api/settings/credentials/route.ts

113 lines

import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';

const SETTINGS_KEY = 'api_credentials';

/** Mask a secret: show first 4 chars + dots, or empty if blank */
function maskSecret(val: string | undefined): string {
  if (!val || !val.trim()) return '';
  if (val.length <= 6) return '••••••••';
  return val.slice(0, 4) + '••••••••';
}

/**
 * GET /api/settings/credentials
 * Return stored API credentials with values MASKED.
 * Use ?reveal=serviceId to get unmasked values for one service.
 */
export async function GET(request: NextRequest) {
  const auth = requireRole(request, 'admin');
  if (auth instanceof NextResponse) return auth;

  try {
    const result = await query(
      `SELECT value FROM app_settings WHERE key = $1`,
      [SETTINGS_KEY]
    );

    if (result.rows.length === 0) {
      return NextResponse.json({ credentials: {}, has_values: {} });
    }

    const credentials = typeof result.rows[0].value === 'string'
      ? JSON.parse(result.rows[0].value)
      : result.rows[0].value;

    const revealService = new URL(request.url).searchParams.get('reveal');

    // Mask all credential values except the revealed service
    const masked: Record<string, Record<string, string>> = {};
    const hasValues: Record<string, Record<string, boolean>> = {};

    for (const [serviceId, serviceCreds] of Object.entries(credentials as Record<string, Record<string, string>>)) {
      masked[serviceId] = {};
      hasValues[serviceId] = {};
      for (const [key, val] of Object.entries(serviceCreds || {})) {
        hasValues[serviceId][key] = !!val?.trim();
        masked[serviceId][key] = (revealService === serviceId) ? val : maskSecret(val);
      }
    }

    return NextResponse.json({ credentials: masked, has_values: hasValues });
  } catch (err) {
    console.error('[api/settings/credentials] GET error:', (err as Error).message);
    return NextResponse.json({ error: 'Failed to fetch credentials' }, { status: 500 });
  }
}

/**
 * PATCH /api/settings/credentials
 * Update credentials for a single service.
 * Body: { serviceId: string, credentials: { key: value, ... } }
 * Skips masked placeholder values (containing ••) to prevent overwriting real secrets.
 */
export async function PATCH(request: NextRequest) {
  const auth = requireRole(request, 'admin');
  if (auth instanceof NextResponse) return auth;

  try {
    const body = await request.json();
    const { serviceId, credentials: serviceCreds } = body;

    if (!serviceId || !serviceCreds) {
      return NextResponse.json({ error: 'Missing serviceId or credentials' }, { status: 400 });
    }

    // Fetch existing credentials
    const result = await query(
      `SELECT value FROM app_settings WHERE key = $1`,
      [SETTINGS_KEY]
    );

    let allCredentials: Record<string, Record<string, string>> = {};
    if (result.rows.length > 0) {
      allCredentials = typeof result.rows[0].value === 'string'
        ? JSON.parse(result.rows[0].value)
        : result.rows[0].value;
    }

    // Merge service credentials — skip masked placeholders
    const existing = allCredentials[serviceId] || {};
    for (const [k, v] of Object.entries(serviceCreds as Record<string, string>)) {
      if (v && !v.includes('••')) {
        existing[k] = v;
      }
    }
    allCredentials[serviceId] = existing;

    // Save back
    await query(
      `INSERT INTO app_settings (key, value, updated_at)
       VALUES ($1, $2, now())
       ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = now()`,
      [SETTINGS_KEY, JSON.stringify(allCredentials)]
    );

    return NextResponse.json({ success: true, serviceId });
  } catch (err) {
    console.error('[api/settings/credentials] PATCH error:', (err as Error).message);
    return NextResponse.json({ error: 'Failed to save credentials' }, { status: 500 });
  }
}