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:
@@ -0,0 +1,95 @@
|
||||
-- ============================================================================
|
||||
-- RPC accept_tenant_invite — destrava o fluxo de aceitar convite
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Recebe o token UUID do invite. Em uma transação (SECURITY DEFINER):
|
||||
-- 1. Lê invite ATIVO (não accepted, não revoked, não expired)
|
||||
-- 2. INSERT em tenant_members com role do invite + user_id = auth.uid()
|
||||
-- 3. UPDATE invite com accepted_at + accepted_by
|
||||
--
|
||||
-- Retorna jsonb { ok, tenant_id, role } em sucesso ou throw com mensagem PT-BR.
|
||||
--
|
||||
-- Chamada pelo features/tenantship/services/tenantInvitesRepository.acceptInvite().
|
||||
-- Stub anterior tava jogando erro PT-BR explicando isso. Agora funciona.
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.accept_tenant_invite(p_token uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_uid uuid;
|
||||
v_invite record;
|
||||
v_existing_member record;
|
||||
BEGIN
|
||||
-- Quem está aceitando — auth.uid() pega do JWT
|
||||
v_uid := auth.uid();
|
||||
IF v_uid IS NULL THEN
|
||||
RAISE EXCEPTION 'Sessão inválida (sem user autenticado).';
|
||||
END IF;
|
||||
|
||||
-- 1. Lê invite ativo. Lock via FOR UPDATE pra evitar race.
|
||||
SELECT id, tenant_id, email, role, accepted_at, revoked_at, expires_at
|
||||
INTO v_invite
|
||||
FROM public.tenant_invites
|
||||
WHERE token = p_token
|
||||
FOR UPDATE;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Convite não encontrado. Verifique o link.';
|
||||
END IF;
|
||||
|
||||
IF v_invite.revoked_at IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Convite revogado pelo administrador.';
|
||||
END IF;
|
||||
|
||||
IF v_invite.accepted_at IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Convite já foi aceito anteriormente.';
|
||||
END IF;
|
||||
|
||||
IF v_invite.expires_at IS NOT NULL AND v_invite.expires_at < now() THEN
|
||||
RAISE EXCEPTION 'Convite expirado. Peça um novo ao administrador.';
|
||||
END IF;
|
||||
|
||||
-- 2. Idempotência: se já é membro do tenant, só marca invite aceito.
|
||||
SELECT id, role, status
|
||||
INTO v_existing_member
|
||||
FROM public.tenant_members
|
||||
WHERE tenant_id = v_invite.tenant_id
|
||||
AND user_id = v_uid
|
||||
LIMIT 1;
|
||||
|
||||
IF v_existing_member.id IS NULL THEN
|
||||
INSERT INTO public.tenant_members (tenant_id, user_id, role, status)
|
||||
VALUES (v_invite.tenant_id, v_uid, v_invite.role, 'active');
|
||||
ELSIF v_existing_member.status <> 'active' THEN
|
||||
UPDATE public.tenant_members
|
||||
SET status = 'active', role = v_invite.role
|
||||
WHERE id = v_existing_member.id;
|
||||
END IF;
|
||||
-- (se já está ativo, deixa como tá — convite aceito não rebaixa)
|
||||
|
||||
-- 3. Marca invite como aceito
|
||||
UPDATE public.tenant_invites
|
||||
SET accepted_at = now(), accepted_by = v_uid
|
||||
WHERE id = v_invite.id;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'ok', true,
|
||||
'tenant_id', v_invite.tenant_id,
|
||||
'role', v_invite.role
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.accept_tenant_invite(uuid) IS
|
||||
'Aceita convite de membership. SECURITY DEFINER pra criar tenant_members em nome do user logado. Lock FOR UPDATE no invite previne race condition.';
|
||||
|
||||
-- Permite que qualquer authenticated chame (precisa do token UUID válido pra entrar).
|
||||
REVOKE ALL ON FUNCTION public.accept_tenant_invite(uuid) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.accept_tenant_invite(uuid) TO authenticated;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/features/tenantship/composables/useTenantInvites.js
|
||||
|
|
||||
| Thin wrapper sobre tenantInvitesRepository. Segue
|
||||
| blueprints/composable-blueprint.md (Tipo A — thin wrapper default).
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
import { listForTenant, getByToken, sendInvite, revokeInvite, acceptInvite } from '@/features/tenantship/services/tenantInvitesRepository';
|
||||
|
||||
export function useTenantInvites() {
|
||||
const rows = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
async function loadForTenant({ tenantId, includeInactive } = {}) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
rows.value = await listForTenant({ tenantId, includeInactive });
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar convites.';
|
||||
rows.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function send(payload) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const created = await sendInvite(payload);
|
||||
// Inserir/replace na lista local sem re-fetch
|
||||
const idx = rows.value.findIndex((r) => r.id === created.id);
|
||||
if (idx >= 0) rows.value[idx] = created;
|
||||
else rows.value = [created, ...rows.value];
|
||||
return created;
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao enviar convite.';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(inviteId, opts) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const updated = await revokeInvite(inviteId, opts);
|
||||
const idx = rows.value.findIndex((r) => r.id === updated.id);
|
||||
if (idx >= 0) rows.value[idx] = updated;
|
||||
return updated;
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao revogar convite.';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* STUB — depende de RPC ainda não criada. Joga erro PT-BR explicando.
|
||||
* Ver tenantInvitesRepository.acceptInvite.
|
||||
*/
|
||||
async function accept(token) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
return await acceptInvite(token);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao aceitar convite.';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read público pelo token (anonymous). Não atualiza `rows` —
|
||||
* usado no fluxo de aceitar (link externo).
|
||||
*/
|
||||
async function fetchByToken(token) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
return await getByToken(token);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar convite.';
|
||||
return null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { rows, loading, error, loadForTenant, send, revoke, accept, fetchByToken };
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/features/tenantship/composables/useTenantMembers.js
|
||||
|
|
||||
| Thin wrapper sobre tenantMembersRepository. Segue
|
||||
| blueprints/composable-blueprint.md (Tipo A — thin wrapper default).
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
import { listForTenant, getById, updateMemberRole, updateMemberStatus, removeMember } from '@/features/tenantship/services/tenantMembersRepository';
|
||||
|
||||
export function useTenantMembers() {
|
||||
const rows = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
async function loadForTenant({ tenantId, status } = {}) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
rows.value = await listForTenant({ tenantId, status });
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar membros.';
|
||||
rows.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchById(memberId, opts) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
return await getById(memberId, opts);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar membro.';
|
||||
return null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateRole(memberId, role, opts) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const updated = await updateMemberRole(memberId, role, opts);
|
||||
const idx = rows.value.findIndex((r) => r.id === updated.id);
|
||||
if (idx >= 0) rows.value[idx] = { ...rows.value[idx], role: updated.role };
|
||||
return updated;
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao atualizar papel.';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateStatus(memberId, status, opts) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const updated = await updateMemberStatus(memberId, status, opts);
|
||||
const idx = rows.value.findIndex((r) => r.id === updated.id);
|
||||
if (idx >= 0) rows.value[idx] = { ...rows.value[idx], status: updated.status };
|
||||
return updated;
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao atualizar status.';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(memberId, opts) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await removeMember(memberId, opts);
|
||||
rows.value = rows.value.filter((r) => r.id !== memberId);
|
||||
return true;
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao remover membro.';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { rows, loading, error, loadForTenant, fetchById, updateRole, updateStatus, remove };
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/features/tenantship/services/_tenantGuards.js
|
||||
|
|
||||
| Guards compartilhados entre repositories do feature tenantship.
|
||||
| Cópia canônica do pattern extraído de features/agenda/services/_tenantGuards.js
|
||||
| (ver blueprints/repository-blueprint.md seção 3).
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
export function assertTenantId(tenantId) {
|
||||
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') {
|
||||
throw new Error('Tenant ativo inválido. Selecione a clínica/tenant antes de operar.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUid() {
|
||||
const { data, error } = await supabase.auth.getUser();
|
||||
if (error) throw error;
|
||||
const uid = data?.user?.id;
|
||||
if (!uid) throw new Error('Usuário não autenticado.');
|
||||
return uid;
|
||||
}
|
||||
|
||||
export function assertEmail(email) {
|
||||
if (!email || typeof email !== 'string') {
|
||||
throw new Error('E-mail inválido.');
|
||||
}
|
||||
const trimmed = email.trim().toLowerCase();
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
|
||||
throw new Error('E-mail em formato inválido.');
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function assertRole(role) {
|
||||
if (!['therapist', 'secretary'].includes(role)) {
|
||||
throw new Error("Role inválida. Aceitos: 'therapist' ou 'secretary'.");
|
||||
}
|
||||
return role;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/features/tenantship/services/tenantInvitesRepository.js
|
||||
|
|
||||
| Repository de tenant_invites. Pure functions seguindo
|
||||
| blueprints/repository-blueprint.md.
|
||||
|
|
||||
| A tabela tenant_invites já existe no schema (tenants_multi_tenant.sql:100)
|
||||
| com role CHECK ['therapist','secretary'], token uuid auto, expires_at default
|
||||
| now()+7d, accepted_at/by, revoked_at/by.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { assertTenantId, getUid, assertEmail, assertRole } from './_tenantGuards';
|
||||
import { TENANT_INVITE_SELECT, flattenInviteRow } from './tenantInvitesSelects';
|
||||
|
||||
function resolveTenantId(tenantIdArg) {
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantIdArg || tenantStore.activeTenantId;
|
||||
assertTenantId(tenantId);
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista convites de um tenant. Ordem: pending primeiro (mais recentes), depois resto.
|
||||
*
|
||||
* @param {Object} [opts]
|
||||
* @param {string} [opts.tenantId]
|
||||
* @param {boolean} [opts.includeInactive=false] - se true, inclui revoked/accepted/expired
|
||||
*/
|
||||
export async function listForTenant({ tenantId, includeInactive = false } = {}) {
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
let q = supabase.from('tenant_invites').select(TENANT_INVITE_SELECT).eq('tenant_id', tid);
|
||||
|
||||
if (!includeInactive) {
|
||||
q = q.is('accepted_at', null).is('revoked_at', null).gt('expires_at', new Date().toISOString());
|
||||
}
|
||||
|
||||
q = q.order('created_at', { ascending: false });
|
||||
|
||||
const { data, error } = await q;
|
||||
if (error) throw error;
|
||||
return (data || []).map(flattenInviteRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca convite por token. Usado no fluxo de aceitar (read público).
|
||||
* NOTA: política RLS deve permitir SELECT por token sem auth — a ser configurada.
|
||||
*
|
||||
* @param {string} token - uuid do convite
|
||||
*/
|
||||
export async function getByToken(token) {
|
||||
if (!token) throw new Error('Token inválido.');
|
||||
const { data, error } = await supabase.from('tenant_invites').select(TENANT_INVITE_SELECT).eq('token', token).maybeSingle();
|
||||
if (error) throw error;
|
||||
return data ? flattenInviteRow(data) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envia novo convite (cria row). Idempotente por (tenant_id, email) ativo —
|
||||
* se já existe convite pending pro mesmo email, retorna o existente.
|
||||
*
|
||||
* TODO (Módulo 6 — Notificações): após criar a row, disparar email/WhatsApp
|
||||
* com o link `/aceitar-convite?token=${row.token}`. Hoje só insere.
|
||||
*
|
||||
* @param {Object} payload
|
||||
* @param {string} payload.email
|
||||
* @param {'therapist'|'secretary'} payload.role
|
||||
* @param {string} [payload.tenantId]
|
||||
* @returns {Promise<Object>} row do convite (novo ou existente)
|
||||
*/
|
||||
export async function sendInvite({ email, role, tenantId } = {}) {
|
||||
const tid = resolveTenantId(tenantId);
|
||||
const uid = await getUid();
|
||||
const safeEmail = assertEmail(email);
|
||||
const safeRole = assertRole(role);
|
||||
|
||||
// Idempotência: se já existe pending pro mesmo (tenant, email), retorna existente
|
||||
const { data: existing } = await supabase
|
||||
.from('tenant_invites')
|
||||
.select(TENANT_INVITE_SELECT)
|
||||
.eq('tenant_id', tid)
|
||||
.eq('email', safeEmail)
|
||||
.is('accepted_at', null)
|
||||
.is('revoked_at', null)
|
||||
.gt('expires_at', new Date().toISOString())
|
||||
.maybeSingle();
|
||||
|
||||
if (existing) return flattenInviteRow(existing);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('tenant_invites')
|
||||
.insert([{ tenant_id: tid, email: safeEmail, role: safeRole, invited_by: uid }])
|
||||
.select(TENANT_INVITE_SELECT)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return flattenInviteRow(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoga convite (soft — registra revoked_at + revoked_by, não deleta a row).
|
||||
*
|
||||
* @param {string} inviteId
|
||||
* @param {Object} [opts]
|
||||
* @param {string} [opts.tenantId]
|
||||
*/
|
||||
export async function revokeInvite(inviteId, { tenantId } = {}) {
|
||||
if (!inviteId) throw new Error('ID inválido.');
|
||||
const tid = resolveTenantId(tenantId);
|
||||
const uid = await getUid();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('tenant_invites')
|
||||
.update({ revoked_at: new Date().toISOString(), revoked_by: uid })
|
||||
.eq('id', inviteId)
|
||||
.eq('tenant_id', tid)
|
||||
.select(TENANT_INVITE_SELECT)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return flattenInviteRow(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aceita convite — cria tenant_members + marca accepted_at no invite (atomicamente via RPC).
|
||||
*
|
||||
* RPC `accept_tenant_invite(p_token uuid)` (migration 20260520000005):
|
||||
* - SECURITY DEFINER (auth.uid() do caller é o aceitador)
|
||||
* - Lock FOR UPDATE no invite (anti-race)
|
||||
* - Idempotente: re-aceitar não cria duplicata
|
||||
* - Retorna jsonb { ok, tenant_id, role } em sucesso
|
||||
* - Throw com mensagem PT-BR em erros (revogado, expirado, já aceito, sem sessão)
|
||||
*
|
||||
* @param {string} token - uuid do invite
|
||||
* @returns {Promise<{ok: boolean, tenant_id: string, role: string}>}
|
||||
*/
|
||||
export async function acceptInvite(token) {
|
||||
if (!token) throw new Error('Token inválido.');
|
||||
|
||||
const { data, error } = await supabase.rpc('accept_tenant_invite', { p_token: token });
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/features/tenantship/services/tenantInvitesSelects.js
|
||||
|
|
||||
| Fonte única do SELECT de tenant_invites.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export const TENANT_INVITE_SELECT = `
|
||||
id, tenant_id, email, role, token,
|
||||
invited_by, created_at, expires_at,
|
||||
accepted_at, accepted_by,
|
||||
revoked_at, revoked_by
|
||||
`.trim();
|
||||
|
||||
/**
|
||||
* Computa status derivado do invite (sem campo no banco — calculado em runtime).
|
||||
*
|
||||
* @param {Object} row
|
||||
* @returns {'pending'|'expired'|'accepted'|'revoked'}
|
||||
*/
|
||||
export function deriveInviteStatus(row) {
|
||||
if (!row) return 'pending';
|
||||
if (row.revoked_at) return 'revoked';
|
||||
if (row.accepted_at) return 'accepted';
|
||||
if (row.expires_at && new Date(row.expires_at) < new Date()) return 'expired';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
/**
|
||||
* Achata a row adicionando o status derivado.
|
||||
*/
|
||||
export function flattenInviteRow(r) {
|
||||
if (!r) return r;
|
||||
return { ...r, status: deriveInviteStatus(r) };
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/features/tenantship/services/tenantMembersRepository.js
|
||||
|
|
||||
| Repository de tenant_members. Pure functions seguindo
|
||||
| blueprints/repository-blueprint.md.
|
||||
|
|
||||
| Tabela: tenant_members (id, tenant_id, user_id, role, status='active', created_at)
|
||||
| View enriched: v_tenant_members_with_profiles (inclui full_name e email)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { assertTenantId, getUid } from './_tenantGuards';
|
||||
import { TENANT_MEMBER_PROFILE_SELECT, TENANT_MEMBER_RAW_SELECT, flattenMemberRow } from './tenantMembersSelects';
|
||||
|
||||
const VALID_ROLES = ['tenant_admin', 'therapist', 'secretary'];
|
||||
const VALID_STATUSES = ['active', 'inactive', 'suspended'];
|
||||
|
||||
function resolveTenantId(tenantIdArg) {
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantIdArg || tenantStore.activeTenantId;
|
||||
assertTenantId(tenantId);
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista membros de um tenant com profile (full_name, email).
|
||||
*
|
||||
* @param {Object} [opts]
|
||||
* @param {string} [opts.tenantId]
|
||||
* @param {string} [opts.status] - filtra por status (active/inactive/suspended)
|
||||
*/
|
||||
export async function listForTenant({ tenantId, status } = {}) {
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
let q = supabase.from('v_tenant_members_with_profiles').select(TENANT_MEMBER_PROFILE_SELECT).eq('tenant_id', tid).order('created_at', { ascending: false });
|
||||
|
||||
if (status) q = q.eq('status', status);
|
||||
|
||||
const { data, error } = await q;
|
||||
if (error) throw error;
|
||||
return (data || []).map(flattenMemberRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lê o tenant_member ativo do usuário LOGADO no tenant ativo (ou no tenantId passado).
|
||||
* Retorna `{ id, tenant_id, role, status }` ou null.
|
||||
*
|
||||
* Útil pra criar entidades que precisam de `responsible_member_id` (ex: patients).
|
||||
*
|
||||
* @param {Object} [opts]
|
||||
* @param {string} [opts.tenantId]
|
||||
*/
|
||||
export async function getMyActiveMember({ tenantId } = {}) {
|
||||
const tid = resolveTenantId(tenantId);
|
||||
const uid = await getUid();
|
||||
|
||||
const { data, error } = await supabase.from('tenant_members').select(TENANT_MEMBER_RAW_SELECT).eq('user_id', uid).eq('tenant_id', tid).eq('status', 'active').order('created_at', { ascending: false }).limit(1).maybeSingle();
|
||||
|
||||
if (error) throw error;
|
||||
return data || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lê um member pela id (raw, sem profile join — útil pra verificações rápidas).
|
||||
*
|
||||
* @param {string} memberId
|
||||
* @param {Object} [opts]
|
||||
* @param {string} [opts.tenantId]
|
||||
*/
|
||||
export async function getById(memberId, { tenantId } = {}) {
|
||||
if (!memberId) throw new Error('ID inválido.');
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
const { data, error } = await supabase.from('tenant_members').select(TENANT_MEMBER_RAW_SELECT).eq('id', memberId).eq('tenant_id', tid).maybeSingle();
|
||||
|
||||
if (error) throw error;
|
||||
return data || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualiza role de um member. Tenant_admin não pode rebaixar a si mesmo —
|
||||
* a UI deve bloquear, mas RLS no banco também deve garantir.
|
||||
*
|
||||
* @param {string} memberId
|
||||
* @param {'tenant_admin'|'therapist'|'secretary'} role
|
||||
* @param {Object} [opts]
|
||||
* @param {string} [opts.tenantId]
|
||||
*/
|
||||
export async function updateMemberRole(memberId, role, { tenantId } = {}) {
|
||||
if (!memberId) throw new Error('ID inválido.');
|
||||
if (!VALID_ROLES.includes(role)) {
|
||||
throw new Error(`Role inválida. Aceitos: ${VALID_ROLES.join(', ')}.`);
|
||||
}
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
const { data, error } = await supabase.from('tenant_members').update({ role }).eq('id', memberId).eq('tenant_id', tid).select(TENANT_MEMBER_RAW_SELECT).single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualiza status (ativar/inativar/suspender) sem remover.
|
||||
*
|
||||
* @param {string} memberId
|
||||
* @param {'active'|'inactive'|'suspended'} status
|
||||
* @param {Object} [opts]
|
||||
* @param {string} [opts.tenantId]
|
||||
*/
|
||||
export async function updateMemberStatus(memberId, status, { tenantId } = {}) {
|
||||
if (!memberId) throw new Error('ID inválido.');
|
||||
if (!VALID_STATUSES.includes(status)) {
|
||||
throw new Error(`Status inválido. Aceitos: ${VALID_STATUSES.join(', ')}.`);
|
||||
}
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
const { data, error } = await supabase.from('tenant_members').update({ status }).eq('id', memberId).eq('tenant_id', tid).select(TENANT_MEMBER_RAW_SELECT).single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove member (hard delete da tabela tenant_members).
|
||||
* Os dados do user em outras tabelas (pacientes, agenda, etc) PERMANECEM —
|
||||
* a remoção é apenas do vínculo membership.
|
||||
*
|
||||
* @param {string} memberId
|
||||
* @param {Object} [opts]
|
||||
* @param {string} [opts.tenantId]
|
||||
*/
|
||||
export async function removeMember(memberId, { tenantId } = {}) {
|
||||
if (!memberId) throw new Error('ID inválido.');
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
// Defesa: nunca permitir o próprio user se remover via essa função
|
||||
// (causa lockout). UI deve bloquear; aqui só sanity-check.
|
||||
const uid = await getUid();
|
||||
const target = await getById(memberId, { tenantId: tid });
|
||||
if (target && target.user_id === uid) {
|
||||
throw new Error('Não é permitido remover a si mesmo. Peça a outro admin do tenant.');
|
||||
}
|
||||
|
||||
const { error } = await supabase.from('tenant_members').delete().eq('id', memberId).eq('tenant_id', tid);
|
||||
|
||||
if (error) throw error;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/features/tenantship/services/tenantMembersSelects.js
|
||||
|
|
||||
| SELECTs canônicos pra tenant_members + view v_tenant_members_with_profiles.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* SELECT direto da tabela tenant_members (sem join).
|
||||
* Use quando precisa só dos campos crus.
|
||||
*/
|
||||
export const TENANT_MEMBER_RAW_SELECT = `
|
||||
id, tenant_id, user_id, role, status, created_at
|
||||
`.trim();
|
||||
|
||||
/**
|
||||
* SELECT enriched via view v_tenant_members_with_profiles.
|
||||
* Inclui full_name e email (joins com profiles e auth.users).
|
||||
* Use pra listagens da UI.
|
||||
*/
|
||||
export const TENANT_MEMBER_PROFILE_SELECT = `
|
||||
tenant_member_id, tenant_id, user_id, role, status, created_at,
|
||||
full_name, email
|
||||
`.trim();
|
||||
|
||||
/**
|
||||
* Normaliza row da view pra usar `id` no lugar de `tenant_member_id`.
|
||||
*/
|
||||
export function flattenMemberRow(r) {
|
||||
if (!r) return r;
|
||||
const out = { ...r };
|
||||
if (r.tenant_member_id && !r.id) out.id = r.tenant_member_id;
|
||||
return out;
|
||||
}
|
||||
@@ -177,6 +177,16 @@ export default {
|
||||
component: () => import('@/views/pages/auth/SecurityPage.vue')
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 👥 MEMBROS & CONVITES
|
||||
// ======================================================
|
||||
{
|
||||
path: 'members',
|
||||
name: 'admin-members',
|
||||
component: () => import('@/views/pages/admin/MembersPage.vue'),
|
||||
meta: { roles: ['tenant_admin', 'clinic_admin'] }
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 🔒 MÓDULO PRO — Online Scheduling
|
||||
// ======================================================
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/services/tenantFeatureAdminService.js
|
||||
|
|
||||
| Service de admin SaaS pra gestão de features por tenant. Substitui supabase
|
||||
| direto que estava em SaasTenantFeaturesPage.vue (audit alta — 4 queries
|
||||
| inline + 1 RPC).
|
||||
|
|
||||
| Diferente de `tenantFeaturesStore` (que gerencia features do user/tenant ATIVO),
|
||||
| este service opera em QUALQUER tenant selecionado pelo SaaS admin.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
// SELECTs canônicos
|
||||
const TENANT_LIST_SELECT = 'id, name';
|
||||
const FEATURE_CATALOG_SELECT = 'id, key, name, descricao';
|
||||
const ENTITLEMENT_SELECT = 'feature_key';
|
||||
const TENANT_FEATURE_SELECT = 'feature_key, enabled';
|
||||
const SUBSCRIPTION_SELECT = 'plan_key';
|
||||
const EXCEPTIONS_LOG_SELECT = 'feature_key, enabled, reason, created_by, created_at';
|
||||
|
||||
/**
|
||||
* Lista todos os tenants (apenas SaaS admin tem acesso via RLS).
|
||||
*/
|
||||
export async function listTenants() {
|
||||
const { data, error } = await supabase.from('tenants').select(TENANT_LIST_SELECT).order('name', { ascending: true });
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista o catálogo completo de features do sistema.
|
||||
*/
|
||||
export async function listFeatureCatalog() {
|
||||
const { data, error } = await supabase.from('features').select(FEATURE_CATALOG_SELECT).order('key', { ascending: true });
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Carrega o estado completo de features de um tenant: entitlements via plano,
|
||||
* overrides (exceções), plano ativo, e log das últimas 50 exceções.
|
||||
*
|
||||
* @param {string} tenantId
|
||||
* @returns {Promise<{planAllowed: Set, planKey: string|null, overrides: Object, exceptionsLog: Array}>}
|
||||
*/
|
||||
export async function loadTenantFeatureState(tenantId) {
|
||||
if (!tenantId) {
|
||||
return { planAllowed: new Set(), planKey: null, overrides: {}, exceptionsLog: [] };
|
||||
}
|
||||
|
||||
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(ENTITLEMENT_SELECT).eq('tenant_id', tenantId),
|
||||
supabase.from('tenant_features').select(TENANT_FEATURE_SELECT).eq('tenant_id', tenantId),
|
||||
supabase.from('v_tenant_active_subscription').select(SUBSCRIPTION_SELECT).eq('tenant_id', tenantId).maybeSingle(),
|
||||
supabase.from('tenant_feature_exceptions_log').select(EXCEPTIONS_LOG_SELECT).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 planAllowed = new Set();
|
||||
for (const r of ent || []) planAllowed.add(r.feature_key);
|
||||
|
||||
const overrides = {};
|
||||
for (const r of ovr || []) overrides[r.feature_key] = !!r.enabled;
|
||||
|
||||
return {
|
||||
planAllowed,
|
||||
planKey: sub?.plan_key || null,
|
||||
overrides,
|
||||
exceptionsLog: log || []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplica exceção comercial (force enable/disable) numa feature de um tenant.
|
||||
* RPC `set_tenant_feature_exception` faz UPSERT em tenant_features +
|
||||
* INSERT em tenant_feature_exceptions_log.
|
||||
*/
|
||||
export async function setFeatureException(tenantId, featureKey, enabled, reason = null) {
|
||||
if (!tenantId) throw new Error('tenantId obrigatório.');
|
||||
if (!featureKey) throw new Error('featureKey obrigatória.');
|
||||
|
||||
const { error } = await supabase.rpc('set_tenant_feature_exception', {
|
||||
p_tenant_id: tenantId,
|
||||
p_feature_key: featureKey,
|
||||
p_enabled: enabled,
|
||||
p_reason: reason ? String(reason).trim() || null : null
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user