freemium F3: UI de config (blacklist CRUD + toggle root_redirect)
- SaasAppConfigPage + rota /saas/app-config + menu "Config / Bloqueios" - gerencia blacklist (email/slug, add/remove) e o root_redirect - build OK Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — SaasAppConfigPage (Freemium F3c/F3a)
|
||||
|--------------------------------------------------------------------------
|
||||
| Configurações globais do SaaS (dev-only):
|
||||
| • root_redirect: pra onde o visitante não-logado vai na raiz "/".
|
||||
| • Blacklist de e-mails (bloqueia cadastro; suporta '@dominio.com') e slugs.
|
||||
| RLS garante que só saas_admin lê/escreve (tabelas saas_app_config / blacklist).
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
|
||||
import SelectButton from 'primevue/selectbutton';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Button from 'primevue/button';
|
||||
import Tag from 'primevue/tag';
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
// ── root_redirect ──────────────────────────────────────────
|
||||
const rootRedirect = ref('landing');
|
||||
const savingRedirect = ref(false);
|
||||
const redirectOptions = [
|
||||
{ label: 'Landing (/lp)', value: 'landing' },
|
||||
{ label: 'Login', value: 'login' }
|
||||
];
|
||||
|
||||
async function loadRootRedirect() {
|
||||
const { data } = await supabase.from('saas_app_config').select('root_redirect').eq('id', true).maybeSingle();
|
||||
rootRedirect.value = data?.root_redirect || 'landing';
|
||||
}
|
||||
|
||||
async function saveRootRedirect(v) {
|
||||
savingRedirect.value = true;
|
||||
try {
|
||||
const { error } = await supabase.from('saas_app_config').update({ root_redirect: v, updated_at: new Date().toISOString() }).eq('id', true);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Destino da raiz atualizado.', life: 2500 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao salvar.', life: 4500 });
|
||||
await loadRootRedirect();
|
||||
} finally {
|
||||
savingRedirect.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── blacklist ──────────────────────────────────────────────
|
||||
const blacklist = ref([]);
|
||||
const loadingBl = ref(false);
|
||||
const newKind = ref('email');
|
||||
const newValue = ref('');
|
||||
const newNote = ref('');
|
||||
const kindOptions = [
|
||||
{ label: 'E-mail', value: 'email' },
|
||||
{ label: 'Slug', value: 'slug' }
|
||||
];
|
||||
|
||||
async function loadBlacklist() {
|
||||
loadingBl.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase.from('blacklist').select('*').order('created_at', { ascending: false });
|
||||
if (error) throw error;
|
||||
blacklist.value = Array.isArray(data) ? data : [];
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar.', life: 4500 });
|
||||
} finally {
|
||||
loadingBl.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addEntry() {
|
||||
const value = String(newValue.value || '').trim().toLowerCase();
|
||||
if (!value) {
|
||||
toast.add({ severity: 'warn', summary: 'Valor', detail: 'Informe o e-mail/domínio ou slug.', life: 3000 });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { error } = await supabase.from('blacklist').insert({ kind: newKind.value, value, note: newNote.value?.trim() || null });
|
||||
if (error) throw error;
|
||||
newValue.value = '';
|
||||
newNote.value = '';
|
||||
await loadBlacklist();
|
||||
toast.add({ severity: 'success', summary: 'Adicionado', life: 2000 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao adicionar.', life: 4500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function removeEntry(row) {
|
||||
try {
|
||||
const { error } = await supabase.from('blacklist').delete().eq('id', row.id);
|
||||
if (error) throw error;
|
||||
await loadBlacklist();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao remover.', life: 4500 });
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadRootRedirect();
|
||||
loadBlacklist();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">Configurações do SaaS</h1>
|
||||
<p class="text-sm text-[var(--text-color-secondary)]">Destino da raiz pública e listas de bloqueio.</p>
|
||||
</div>
|
||||
|
||||
<!-- root_redirect -->
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<h2 class="font-semibold mb-1">Raiz pública "/"</h2>
|
||||
<p class="text-sm text-[var(--text-color-secondary)] mb-3">Pra onde o visitante não-logado vai ao abrir a raiz do site.</p>
|
||||
<SelectButton v-model="rootRedirect" :options="redirectOptions" optionLabel="label" optionValue="value" :allowEmpty="false" :disabled="savingRedirect" @change="saveRootRedirect(rootRedirect)" />
|
||||
</div>
|
||||
|
||||
<!-- blacklist -->
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<h2 class="font-semibold mb-1">Lista de bloqueio</h2>
|
||||
<p class="text-sm text-[var(--text-color-secondary)] mb-3">E-mails bloqueiam o cadastro de verdade (use <code>@dominio.com</code> pra um domínio inteiro). Slugs ficam indisponíveis na criação.</p>
|
||||
|
||||
<div class="flex flex-wrap items-end gap-2 mb-4">
|
||||
<SelectButton v-model="newKind" :options="kindOptions" optionLabel="label" optionValue="value" :allowEmpty="false" />
|
||||
<InputText v-model="newValue" :placeholder="newKind === 'email' ? 'spam@x.com ou @dominio.com' : 'slug_proibido'" class="w-64" @keydown.enter.prevent="addEntry" />
|
||||
<InputText v-model="newNote" placeholder="nota (opcional)" class="w-48" @keydown.enter.prevent="addEntry" />
|
||||
<Button label="Adicionar" icon="pi pi-plus" @click="addEntry" />
|
||||
</div>
|
||||
|
||||
<DataTable :value="blacklist" :loading="loadingBl" paginator :rows="10" dataKey="id" size="small" stripedRows>
|
||||
<Column field="kind" header="Tipo">
|
||||
<template #body="{ data }"><Tag :severity="data.kind === 'email' ? 'warning' : 'info'" :value="data.kind" /></template>
|
||||
</Column>
|
||||
<Column field="value" header="Valor" />
|
||||
<Column field="note" header="Nota">
|
||||
<template #body="{ data }">{{ data.note || '—' }}</template>
|
||||
</Column>
|
||||
<Column header="" :style="{ width: '4rem' }">
|
||||
<template #body="{ data }">
|
||||
<Button icon="pi pi-trash" severity="danger" text rounded @click="removeEntry(data)" />
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user