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:
Leonardo
2026-06-13 20:26:40 -03:00
parent d50073da1a
commit 3730b71150
3 changed files with 159 additions and 0 deletions
+1
View File
@@ -64,6 +64,7 @@ export default function saasMenu(sessionCtx, opts = {}) {
{ label: 'Clínicas (Tenants)', icon: 'pi pi-fw pi-users', to: '/saas/tenants' },
{ label: 'Recursos por Clínica', icon: 'pi pi-fw pi-key', to: '/saas/tenant-features' },
{ label: 'Segurança / Bots', icon: 'pi pi-fw pi-shield', to: '/saas/security' },
{ label: 'Config / Bloqueios', icon: 'pi pi-fw pi-cog', to: '/saas/app-config' },
{ label: 'Feriados', icon: 'pi pi-fw pi-star', to: '/saas/feriados' },
{ label: 'Suporte Técnico', icon: 'pi pi-fw pi-headphones', to: '/saas/support' }
]
+6
View File
@@ -172,6 +172,12 @@ export default {
name: 'saas-usuarios',
component: () => import('@/views/pages/saas/SaasUsuariosPage.vue'),
meta: { requiresAuth: true, saasAdmin: true }
},
{
path: 'app-config',
name: 'saas-app-config',
component: () => import('@/views/pages/saas/SaasAppConfigPage.vue'),
meta: { requiresAuth: true, saasAdmin: true }
}
]
};
+152
View File
@@ -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 saas_admin /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>