M5: tenantship + admin members + accept_invite RPC

Modulo 5 da Fase 1 + quick wins fechados. features/tenantship/ com
2 services + 2 composables (members + invites). MembersPage.vue
nova em views/pages/admin/ + rota /admin/members em routes.clinic.
Migration 20260520000005 cria RPC accept_tenant_invite (SECURITY
DEFINER + lock FOR UPDATE) — tenantInvitesRepository.acceptInvite
agora chama RPC real (nao mais stub). SaasTenantFeaturesPage
refatorada pra usar novo tenantFeatureAdminService. SetupWizardPage
2648 linhas deferido pra sessao dedicada.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-21 04:20:33 -03:00
parent fbfb95648e
commit 0956e4facc
12 changed files with 1173 additions and 42 deletions
+336
View File
@@ -0,0 +1,336 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI MembersPage.vue
|--------------------------------------------------------------------------
| Gestão de membros e convites do tenant ativo. Usa services do tenantship
| (0.5.D scaffold). Cobre: listar membros ativos, mudar role, remover,
| listar convites pendentes, enviar novo convite, revogar.
|
| Aceitar convite ainda é STUB no repository (precisa RPC
| `accept_tenant_invite(p_token uuid)`). Página de aceitar (via link
| /aceitar-convite?token=...) fica pra sessão dedicada.
|
| Rota sugerida (registrar manualmente em routes.clinic.js ou routes.saas.js):
| { path: 'members', name: 'AdminMembers', component: () => import('@/views/pages/admin/MembersPage.vue') }
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, onMounted, computed } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useTenantStore } from '@/stores/tenantStore';
import { useTenantMembers } from '@/features/tenantship/composables/useTenantMembers';
import { useTenantInvites } from '@/features/tenantship/composables/useTenantInvites';
const toast = useToast();
const tenantStore = useTenantStore();
const members = useTenantMembers();
const invites = useTenantInvites();
const tenantId = computed(() => tenantStore.activeTenantId || tenantStore.tenantId || null);
// ─── Estado UI ───────────────────────────────────────────────────────────
const inviteDialogOpen = ref(false);
const inviteForm = ref({ email: '', role: 'therapist' });
const inviteSaving = ref(false);
const roleEditDialogOpen = ref(false);
const roleEditTarget = ref(null);
const roleEditNewRole = ref('therapist');
const roleEditSaving = ref(false);
const removeConfirmOpen = ref(false);
const removeTarget = ref(null);
const removing = ref(false);
const ROLE_LABELS = {
tenant_admin: 'Administrador',
therapist: 'Terapeuta',
secretary: 'Secretária'
};
const ROLE_OPTIONS = [
{ value: 'therapist', label: 'Terapeuta' },
{ value: 'secretary', label: 'Secretária' }
// tenant_admin não é atribuível via UI — promoção manual.
];
const INVITE_ROLE_OPTIONS = [
{ value: 'therapist', label: 'Terapeuta' },
{ value: 'secretary', label: 'Secretária' }
];
// ─── Computeds ───────────────────────────────────────────────────────────
const activeMembers = computed(() => members.rows.value.filter((m) => m.status === 'active'));
const pendingInvites = computed(() => invites.rows.value.filter((i) => i.status === 'pending'));
// ─── Lifecycle ───────────────────────────────────────────────────────────
onMounted(async () => {
if (!tenantId.value) {
toast.add({ severity: 'warn', summary: 'Sem tenant ativo', detail: 'Selecione um tenant antes de gerenciar membros.', life: 4000 });
return;
}
await refreshAll();
});
async function refreshAll() {
await Promise.all([members.loadForTenant({ tenantId: tenantId.value }), invites.loadForTenant({ tenantId: tenantId.value })]);
}
// ─── Convites ────────────────────────────────────────────────────────────
function openInviteDialog() {
inviteForm.value = { email: '', role: 'therapist' };
inviteDialogOpen.value = true;
}
async function submitInvite() {
const email = String(inviteForm.value.email || '').trim().toLowerCase();
if (!email) {
toast.add({ severity: 'warn', summary: 'E-mail obrigatório', life: 3000 });
return;
}
inviteSaving.value = true;
try {
await invites.send({ email, role: inviteForm.value.role, tenantId: tenantId.value });
toast.add({
severity: 'success',
summary: 'Convite enviado',
detail: `Token gerado pra ${email}. (Envio de e-mail/WhatsApp pendente — Módulo 6.)`,
life: 4500
});
inviteDialogOpen.value = false;
} catch (e) {
toast.add({
severity: 'error',
summary: 'Falha ao enviar convite',
detail: e?.message || 'Erro desconhecido.',
life: 4500
});
} finally {
inviteSaving.value = false;
}
}
async function revokeInvite(invite) {
try {
await invites.revoke(invite.id, { tenantId: tenantId.value });
toast.add({ severity: 'success', summary: 'Convite revogado', life: 2500 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Falha ao revogar', detail: e?.message, life: 4500 });
}
}
function copyInviteLink(invite) {
const baseUrl = window.location.origin;
const link = `${baseUrl}/aceitar-convite?token=${invite.token}`;
navigator.clipboard
.writeText(link)
.then(() => toast.add({ severity: 'info', summary: 'Link copiado', detail: link, life: 3000 }))
.catch(() => toast.add({ severity: 'error', summary: 'Falha ao copiar', life: 3000 }));
}
// ─── Members ─────────────────────────────────────────────────────────────
function openRoleEdit(member) {
roleEditTarget.value = member;
roleEditNewRole.value = member.role;
roleEditDialogOpen.value = true;
}
async function submitRoleEdit() {
if (!roleEditTarget.value) return;
roleEditSaving.value = true;
try {
await members.updateRole(roleEditTarget.value.id, roleEditNewRole.value, { tenantId: tenantId.value });
toast.add({ severity: 'success', summary: 'Papel atualizado', life: 2500 });
roleEditDialogOpen.value = false;
} catch (e) {
toast.add({ severity: 'error', summary: 'Falha ao atualizar papel', detail: e?.message, life: 4500 });
} finally {
roleEditSaving.value = false;
}
}
function openRemoveConfirm(member) {
removeTarget.value = member;
removeConfirmOpen.value = true;
}
async function submitRemove() {
if (!removeTarget.value) return;
removing.value = true;
try {
await members.remove(removeTarget.value.id, { tenantId: tenantId.value });
toast.add({ severity: 'success', summary: 'Membro removido', life: 2500 });
removeConfirmOpen.value = false;
} catch (e) {
toast.add({ severity: 'error', summary: 'Falha ao remover', detail: e?.message, life: 4500 });
} finally {
removing.value = false;
}
}
function fmtDate(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
</script>
<template>
<div class="p-4 max-w-[1100px] mx-auto">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-semibold">Membros & Convites</h1>
<p class="text-sm text-[var(--text-color-secondary)]">Gestão de quem tem acesso à clínica.</p>
</div>
<Button label="Convidar membro" icon="pi pi-user-plus" @click="openInviteDialog" />
</div>
<!-- Aviso se sem tenant -->
<div v-if="!tenantId" class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4 text-yellow-800">
Selecione um tenant ativo no menu pra gerenciar membros.
</div>
<!-- Loading state -->
<div v-else-if="members.loading.value || invites.loading.value" class="text-center py-8 text-[var(--text-color-secondary)]">
<i class="pi pi-spin pi-spinner mr-2" /> Carregando
</div>
<template v-else>
<!-- Membros ativos -->
<section class="mb-8">
<h2 class="text-lg font-medium mb-3 flex items-center gap-2">
<i class="pi pi-users text-blue-500" />
Membros ativos
<span class="text-sm text-[var(--text-color-secondary)] font-normal">({{ activeMembers.length }})</span>
</h2>
<div v-if="!activeMembers.length" class="text-center py-6 text-[var(--text-color-secondary)] border border-dashed border-[var(--surface-border)] rounded-lg">
Nenhum membro ativo.
</div>
<DataTable v-else :value="activeMembers" stripedRows class="text-sm">
<Column field="full_name" header="Nome">
<template #body="{ data }">
<div class="font-medium">{{ data.full_name || '—' }}</div>
<div class="text-xs text-[var(--text-color-secondary)]">{{ data.email }}</div>
</template>
</Column>
<Column field="role" header="Papel">
<template #body="{ data }">
<span class="px-2 py-0.5 rounded-full text-xs bg-blue-100 text-blue-700">
{{ ROLE_LABELS[data.role] || data.role }}
</span>
</template>
</Column>
<Column field="created_at" header="Desde">
<template #body="{ data }">{{ fmtDate(data.created_at) }}</template>
</Column>
<Column header="Ações" :style="{ width: '180px' }">
<template #body="{ data }">
<div class="flex gap-2">
<Button v-if="data.role !== 'tenant_admin'" icon="pi pi-pencil" severity="secondary" text rounded v-tooltip.top="'Alterar papel'" @click="openRoleEdit(data)" />
<Button v-if="data.role !== 'tenant_admin'" icon="pi pi-times" severity="danger" text rounded v-tooltip.top="'Remover'" @click="openRemoveConfirm(data)" />
<span v-else class="text-xs text-[var(--text-color-secondary)] italic">admin</span>
</div>
</template>
</Column>
</DataTable>
</section>
<!-- Convites pendentes -->
<section>
<h2 class="text-lg font-medium mb-3 flex items-center gap-2">
<i class="pi pi-envelope text-amber-500" />
Convites pendentes
<span class="text-sm text-[var(--text-color-secondary)] font-normal">({{ pendingInvites.length }})</span>
</h2>
<div v-if="!pendingInvites.length" class="text-center py-6 text-[var(--text-color-secondary)] border border-dashed border-[var(--surface-border)] rounded-lg">
Nenhum convite pendente.
</div>
<DataTable v-else :value="pendingInvites" stripedRows class="text-sm">
<Column field="email" header="E-mail" />
<Column field="role" header="Papel convidado">
<template #body="{ data }">
<span class="px-2 py-0.5 rounded-full text-xs bg-blue-100 text-blue-700">
{{ ROLE_LABELS[data.role] || data.role }}
</span>
</template>
</Column>
<Column field="created_at" header="Enviado em">
<template #body="{ data }">{{ fmtDate(data.created_at) }}</template>
</Column>
<Column field="expires_at" header="Expira em">
<template #body="{ data }">{{ fmtDate(data.expires_at) }}</template>
</Column>
<Column header="Ações" :style="{ width: '200px' }">
<template #body="{ data }">
<div class="flex gap-2">
<Button icon="pi pi-link" severity="secondary" text rounded v-tooltip.top="'Copiar link'" @click="copyInviteLink(data)" />
<Button icon="pi pi-times" severity="danger" text rounded v-tooltip.top="'Revogar'" @click="revokeInvite(data)" />
</div>
</template>
</Column>
</DataTable>
</section>
</template>
<!-- Dialog: Convidar -->
<Dialog v-model:visible="inviteDialogOpen" modal :draggable="false" header="Convidar membro" :style="{ width: '480px', maxWidth: '94vw' }">
<div class="flex flex-col gap-4 pt-2">
<FloatLabel variant="on">
<InputText id="inv-email" v-model="inviteForm.email" type="email" class="w-full" autofocus />
<label for="inv-email">E-mail *</label>
</FloatLabel>
<FloatLabel variant="on">
<Select id="inv-role" v-model="inviteForm.role" :options="INVITE_ROLE_OPTIONS" optionLabel="label" optionValue="value" class="w-full" />
<label for="inv-role">Papel</label>
</FloatLabel>
<div class="text-xs text-[var(--text-color-secondary)]">
O convite gera um link com token de 7 dias. Envio automático de e-mail/WhatsApp será adicionado no Módulo 6 por enquanto copie o link manualmente.
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" text @click="inviteDialogOpen = false" />
<Button label="Enviar convite" icon="pi pi-send" :loading="inviteSaving" @click="submitInvite" />
</template>
</Dialog>
<!-- Dialog: Editar papel -->
<Dialog v-model:visible="roleEditDialogOpen" modal :draggable="false" header="Alterar papel" :style="{ width: '420px', maxWidth: '94vw' }">
<div class="flex flex-col gap-3 pt-2">
<div class="text-sm">Membro: <strong>{{ roleEditTarget?.full_name || roleEditTarget?.email }}</strong></div>
<FloatLabel variant="on">
<Select id="role-new" v-model="roleEditNewRole" :options="ROLE_OPTIONS" optionLabel="label" optionValue="value" class="w-full" />
<label for="role-new">Novo papel</label>
</FloatLabel>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" text @click="roleEditDialogOpen = false" />
<Button label="Salvar" :loading="roleEditSaving" @click="submitRoleEdit" />
</template>
</Dialog>
<!-- Dialog: Confirmar remoção -->
<Dialog v-model:visible="removeConfirmOpen" modal :draggable="false" header="Remover membro" :style="{ width: '420px', maxWidth: '94vw' }">
<div class="pt-2">
<p>
Tem certeza que quer remover
<strong>{{ removeTarget?.full_name || removeTarget?.email }}</strong>
do tenant?
</p>
<p class="text-xs text-[var(--text-color-secondary)] mt-2">Os dados criados por essa pessoa (pacientes, sessões, prontuários) permanecem apenas o vínculo é desfeito.</p>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" text @click="removeConfirmOpen = false" />
<Button label="Remover" icon="pi pi-times" severity="danger" :loading="removing" @click="submitRemove" />
</template>
</Dialog>
</div>
</template>
+21 -42
View File
@@ -16,7 +16,13 @@
-->
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { supabase } from '@/lib/supabase/client';
// Audit alta (2026-05-20): supabase direto extraído pra service module.
import {
listTenants as svcListTenants,
listFeatureCatalog as svcListFeatures,
loadTenantFeatureState as svcLoadTenantState,
setFeatureException as svcSetFeatureException
} from '@/services/tenantFeatureAdminService';
import Select from 'primevue/select';
import DataTable from 'primevue/datatable';
@@ -92,21 +98,19 @@ function statusSeverity(s) {
}
async function loadTenants() {
const { data, error } = await supabase.from('tenants').select('id, name').order('name', { ascending: true });
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: 'Falha ao carregar clínicas', life: 4000 });
return;
try {
tenants.value = await svcListTenants();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar clínicas', life: 4000 });
}
tenants.value = data || [];
}
async function loadFeatures() {
const { data, error } = await supabase.from('features').select('id, key, name, descricao').order('key', { ascending: true });
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: 'Falha ao carregar catálogo', life: 4000 });
return;
try {
features.value = await svcListFeatures();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar catálogo', life: 4000 });
}
features.value = data || [];
}
async function loadTenantState(tenantId) {
@@ -119,30 +123,12 @@ async function loadTenantState(tenantId) {
}
loading.value = true;
try {
const [{ data: ent, error: e1 }, { data: ovr, error: e2 }, { data: sub, error: e3 }, { data: log, error: e4 }] = await Promise.all([
supabase.from('v_tenant_entitlements').select('feature_key').eq('tenant_id', tenantId),
supabase.from('tenant_features').select('feature_key, enabled').eq('tenant_id', tenantId),
supabase.from('v_tenant_active_subscription').select('plan_key').eq('tenant_id', tenantId).maybeSingle(),
supabase.from('tenant_feature_exceptions_log').select('feature_key, enabled, reason, created_by, created_at').eq('tenant_id', tenantId).order('created_at', { ascending: false }).limit(50)
]);
if (e1) throw e1;
if (e2) throw e2;
if (e3) throw e3;
if (e4) throw e4;
const set = new Set();
for (const r of ent || []) set.add(r.feature_key);
planAllowed.value = set;
const map = {};
for (const r of ovr || []) map[r.feature_key] = !!r.enabled;
overrides.value = map;
planKey.value = sub?.plan_key || null;
exceptionsLog.value = log || [];
const state = await svcLoadTenantState(tenantId);
planAllowed.value = state.planAllowed;
planKey.value = state.planKey;
overrides.value = state.overrides;
exceptionsLog.value = state.exceptionsLog;
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar tenant', detail: e?.message || 'falha', life: 4000 });
} finally {
@@ -173,14 +159,7 @@ async function confirmChange() {
saving.value = true;
try {
const { error } = await supabase.rpc('set_tenant_feature_exception', {
p_tenant_id: selectedTenantId.value,
p_feature_key: feature.key,
p_enabled: nextEnabled,
p_reason: reason || null
});
if (error) throw error;
await svcSetFeatureException(selectedTenantId.value, feature.key, nextEnabled, reason);
toast.add({
severity: 'success',