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:
@@ -64,6 +64,7 @@ export default function saasMenu(sessionCtx, opts = {}) {
|
|||||||
{ label: 'Clínicas (Tenants)', icon: 'pi pi-fw pi-users', to: '/saas/tenants' },
|
{ 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: '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: '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: 'Feriados', icon: 'pi pi-fw pi-star', to: '/saas/feriados' },
|
||||||
{ label: 'Suporte Técnico', icon: 'pi pi-fw pi-headphones', to: '/saas/support' }
|
{ label: 'Suporte Técnico', icon: 'pi pi-fw pi-headphones', to: '/saas/support' }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -172,6 +172,12 @@ export default {
|
|||||||
name: 'saas-usuarios',
|
name: 'saas-usuarios',
|
||||||
component: () => import('@/views/pages/saas/SaasUsuariosPage.vue'),
|
component: () => import('@/views/pages/saas/SaasUsuariosPage.vue'),
|
||||||
meta: { requiresAuth: true, saasAdmin: true }
|
meta: { requiresAuth: true, saasAdmin: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'app-config',
|
||||||
|
name: 'saas-app-config',
|
||||||
|
component: () => import('@/views/pages/saas/SaasAppConfigPage.vue'),
|
||||||
|
meta: { requiresAuth: true, saasAdmin: true }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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