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:
- Verifica se o usuário está logado
- Garante que seja um email da lista
allowedEmails - Busca todos os grupos (
app_groups) - Busca todas as pastas (
app_group_folders) - Mostra para cada grupo:
- nome, slug
- pastas atualmente vinculadas
- formulário para adicionar nova pasta
- Ao enviar o form:
- chama a server action
addFolder - insere na tabela
app_group_folders - dá
revalidatePathpara recarregar a página com os dados atualizados
- chama a server action
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:
- Listar todos os docs (app_docs)
- Mostrar quais grupos tem permissão explícita naquele doc (
app_group_docs) - Permitir adicionar uma permissão
doc x grupo
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.