02 - Frontend com Nextjs 16 e Supabase para a aplicação Farsoft Markdown

Dependências

pnpm install @supabase/supabase-js @supabase/auth-helpers-nextjs

lib/supabaseServer.ts (para autenticação normal com cookies)

import { cookies } from 'next/headers';
import { createServerClient } from '@supabase/auth-helpers-nextjs';

export function supabaseServer() {
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    { cookies }
  );
}

lib/supabaseAdmin.ts

Para trabalharmos em tabelas de admin, sem sofrer com RLS.

import { createClient } from '@supabase/supabase-js';

export const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!, // SÓ NO SERVER
  {
    auth: {
      autoRefreshToken: false,
      persistSession: false,
    },
  }
);

No .env.local

NEXT_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOi...
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOi...   # não expor no client

Página /admin/groups com server actions

app/admin/groups/page.tsx

import { supabaseServer } from '@/lib/supabaseServer';
import { supabaseAdmin } from '@/lib/supabaseAdmin';
import { redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache';

// Server action para criar nova pasta para um grupo
async function addFolder(formData: FormData) {
  'use server';

  const groupId = formData.get('group_id') as string;
  const folderPrefix = (formData.get('folder_prefix') as string || '').trim();

  if (!groupId || !folderPrefix) {
    return;
  }

  // garantir que termine com /
  const normalizedPrefix = folderPrefix.endsWith('/')
    ? folderPrefix
    : folderPrefix + '/';

  await supabaseAdmin
    .from('app_group_folders')
    .insert({ group_id: groupId, folder_prefix: normalizedPrefix });

  revalidatePath('/admin/groups');
}

export default async function AdminGroupsPage() {
  const supabase = supabaseServer();

  // 1) garantir que está logado
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) {
    redirect('/login');
  }

  // 👉 AQUI você pode limitar por email pra garantir que só você acesse
  const allowedEmails = ['seu-email@dominio.com']; // ajuste
  if (!allowedEmails.includes(user.email ?? '')) {
    redirect('/'); // ou página 403
  }

  // 2) buscar grupos e pastas via supabaseAdmin (bypassa RLS)
  const { data: groups } = await supabaseAdmin
    .from('app_groups')
    .select('id, name, slug')
    .order('name', { ascending: true });

  const { data: folders } = await supabaseAdmin
    .from('app_group_folders')
    .select('id, group_id, folder_prefix')
    .order('folder_prefix', { ascending: true });

  const groupsMap = (groups ?? []).map((g) => ({
    ...g,
    folders: (folders ?? []).filter((f) => f.group_id === g.id),
  }));

  return (
    <main className="max-w-3xl mx-auto p-8 space-y-8">
      <h1 className="text-2xl font-bold">Administração de Grupos & Pastas</h1>

      {groupsMap.length === 0 && (
        <p>Nenhum grupo cadastrado ainda.</p>
      )}

      {groupsMap.map((group) => (
        <section
          key={group.id}
          className="border rounded-lg p-4 space-y-4"
        >
          <header className="flex items-center justify-between">
            <div>
              <h2 className="font-semibold">{group.name}</h2>
              <p className="text-sm text-gray-500">slug: {group.slug}</p>
            </div>
          </header>

          <div>
            <h3 className="font-medium mb-2">Pastas com acesso:</h3>
            {group.folders.length === 0 ? (
              <p className="text-sm text-gray-500">Nenhuma pasta ainda.</p>
            ) : (
              <ul className="list-disc list-inside text-sm space-y-1">
                {group.folders.map((f: any) => (
                  <li key={f.id}>
                    <code>{f.folder_prefix}</code>
                  </li>
                ))}
              </ul>
            )}
          </div>

          <form action={addFolder} className="flex gap-2 items-center">
            <input type="hidden" name="group_id" value={group.id} />
            <input
              name="folder_prefix"
              placeholder="ex: financeiro/"
              className="border rounded px-2 py-1 text-sm flex-1"
            />
            <button
              type="submit"
              className="px-3 py-1 text-sm rounded bg-blue-600 text-white"
            >
              Adicionar pasta
            </button>
          </form>
        </section>
      ))}
    </main>
  );
}

Funções do Painel:

Assim você passa a gerenciar as permissões de pasta por interface web, sem precisar ficar rodando SQL direto.


Página /admin/docs : vincular DOC x GRUPO

Esta página vai:

importante
  • SQL já deve existir, tabelas usadas:
    • app_groups
    • app_docs
    • app_group_docs

Página app/admin/docs/page.tsx

import { supabaseServer } from '@/lib/supabaseServer';
import { supabaseAdmin } from '@/lib/supabaseAdmin';
import { redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache';

async function addDocGroup(formData: FormData) {
  'use server';

  const groupId = formData.get('group_id') as string;
  const docId = formData.get('doc_id') as string;

  if (!groupId || !docId) return;

  await supabaseAdmin
    .from('app_group_docs')
    .insert({ group_id: groupId, doc_id: docId })
    .then(({ error }) => {
      if (error) console.error(error);
    });

  revalidatePath('/admin/docs');
}

export default async function AdminDocsPage() {
  const supabase = supabaseServer();

  // 1) garantir login
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) {
    redirect('/login');
  }

  // 2) limitar a você (ou lista de admins)
  const allowedEmails = ['seu-email@dominio.com']; // ajuste
  if (!allowedEmails.includes(user.email ?? '')) {
    redirect('/');
  }

  // 3) buscar docs, grupos e vínculos via admin (sem RLS)
  const { data: docs } = await supabaseAdmin
    .from('app_docs')
    .select('id, slug, title')
    .order('slug', { ascending: true });

  const { data: groups } = await supabaseAdmin
    .from('app_groups')
    .select('id, name, slug')
    .order('name', { ascending: true });

  const { data: docGroups } = await supabaseAdmin
    .from('app_group_docs')
    .select('group_id, doc_id');

  const groupsById = new Map(
    (groups ?? []).map((g) => [g.id, g] as const)
  );

  return (
    <main className="max-w-4xl mx-auto p-8 space-y-8">
      <h1 className="text-2xl font-bold">Administração de Permissões por Documento</h1>

      <p className="text-sm text-gray-600">
        Aqui você define quais <strong>grupos</strong> têm acesso explícito a cada documento.
        Lembrando que ainda existem as permissões por <strong>pasta</strong> (prefixo do slug) via <code>app_group_folders</code>.
      </p>

      <section className="space-y-4">
        {(docs ?? []).length === 0 && (
          <p>Nenhum documento cadastrado em <code>app_docs</code>.</p>
        )}

        {(docs ?? []).map((doc) => {
          const linkedGroups = (docGroups ?? [])
            .filter((dg) => dg.doc_id === doc.id)
            .map((dg) => groupsById.get(dg.group_id))
            .filter(Boolean);

          return (
            <div
              key={doc.id}
              className="border rounded-lg p-4 space-y-4"
            >
              <div className="flex justify-between items-start gap-4">
                <div>
                  <h2 className="font-semibold">{doc.title}</h2>
                  <p className="text-xs text-gray-500">slug: <code>{doc.slug}</code></p>
                </div>
              </div>

              <div>
                <h3 className="text-sm font-medium mb-1">Grupos com acesso explícito a este doc:</h3>
                {linkedGroups.length === 0 ? (
                  <p className="text-xs text-gray-500">Nenhum grupo vinculado diretamente (pode haver acesso via pasta).</p>
                ) : (
                  <ul className="flex flex-wrap gap-2 text-xs">
                    {linkedGroups.map((g: any) => (
                      <li
                        key={g.id}
                        className="px-2 py-1 rounded bg-gray-200"
                      >
                        {g.name} <span className="text-[10px] text-gray-600">({g.slug})</span>
                      </li>
                    ))}
                  </ul>
                )}
              </div>

              <form action={addDocGroup} className="flex items-center gap-2">
                <input type="hidden" name="doc_id" value={doc.id} />
                <select
                  name="group_id"
                  className="border rounded px-2 py-1 text-sm flex-1"
                  defaultValue=""
                >
                  <option value="" disabled>Selecionar grupo...</option>
                  {(groups ?? []).map((g) => (
                    <option key={g.id} value={g.id}>
                      {g.name} ({g.slug})
                    </option>
                  ))}
                </select>
                <button
                  type="submit"
                  className="px-3 py-1 text-sm rounded bg-blue-600 text-white"
                >
                  Vincular
                </button>
              </form>
            </div>
          );
        })}
      </section>
    </main>
  );
}

Resumo

resumo

Agora temos:

  • /admin/groups → define pastas por grupo
  • /admin/docs → define docs individuais por grupo

E tudo usando supabaseAdmin (service role) só no server.