Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica

- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind
- Frontend Homenest (versión alternativa)
- Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp)
- WACRM fork (Next.js 16 + Supabase)
- Hermes + Bridge + Skills (Qwen3.6 via Nan Builders)
- Scripts de migración y operación
- Documentación extensiva en docs/
This commit is contained in:
2026-07-20 07:44:23 +00:00
commit a718592291
699 changed files with 324602 additions and 0 deletions

View File

@@ -0,0 +1,431 @@
'use client';
// ============================================================
// /join/[token] — invitation redemption landing page.
//
// Four UI states driven by:
// - the peek result (server-validated invite payload), and
// - whether the visitor is currently authenticated.
//
// ┌──────────────────────┬───────────────┬─────────────────────────┐
// │ peek │ auth │ render │
// ├──────────────────────┼───────────────┼─────────────────────────┤
// │ loading │ — │ spinner │
// │ ok:false (any reason)│ — │ friendly error + signup │
// │ ok:true │ signed out │ "Sign up" + "Sign in" │
// │ ok:true │ signed in │ "Accept" button → redeem │
// └──────────────────────┴───────────────┴─────────────────────────┘
//
// We deliberately do NOT redeem automatically on page load — the
// invitee should confirm what account/role they're accepting.
// Auto-redeem would also race with the signup flow returning to
// this page after email verification.
// ============================================================
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { toast } from 'sonner';
import {
AlertTriangle,
CheckCircle,
Loader2,
MailX,
ShieldCheck,
UsersRound,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { createClient } from '@/lib/supabase/client';
interface PeekOk {
ok: true;
account_name: string;
role: 'admin' | 'agent' | 'viewer';
expires_at: string;
}
interface PeekFail {
ok: false;
reason: 'not_found' | 'used' | 'expired' | 'server_error';
}
type PeekResult = PeekOk | PeekFail;
const ROLE_LABEL: Record<PeekOk['role'], string> = {
admin: 'Admin',
agent: 'Agent',
viewer: 'Viewer',
};
const FAIL_COPY: Record<PeekFail['reason'], { title: string; body: string }> = {
not_found: {
title: 'Invite not found',
body: 'This link doesn’t match a valid invitation. Double-check the URL or ask the person who invited you to send a new one.',
},
used: {
title: 'Invite already used',
body: 'This invitation has already been accepted. If that wasn’t you, ask the account admin to send a fresh link.',
},
expired: {
title: 'Invite expired',
body: 'This invitation has expired. Ask the account admin to send a new one — they take a few seconds to generate.',
},
server_error: {
title: 'Something went wrong',
body: 'We couldn’t verify this invitation right now. Try refreshing the page in a moment.',
},
};
export default function JoinPage() {
const params = useParams<{ token: string }>();
const token = params?.token;
const [peek, setPeek] = useState<PeekResult | null>(null);
// Local auth probe — the AuthProvider lives inside the (dashboard)
// route group, so it doesn't reach this page. We hit Supabase
// directly the same way `/login` and `/signup` do.
const [authedUserId, setAuthedUserId] = useState<string | null | undefined>(
undefined, // undefined = unknown / still loading; null = signed out
);
const [accepting, setAccepting] = useState(false);
// `redeem_invitation` returns 409 when the caller's current account
// has domain data, or they're already a member of a shared account.
// A transient toast wasn't enough — the user has no actionable next
// step. Surface a blocking modal that walks them through it.
const [conflictMessage, setConflictMessage] = useState<string | null>(null);
const [signingOut, setSigningOut] = useState(false);
// Extracted so the "Try again" button on the server_error card
// can re-run the same logic without remounting the component.
const loadPeekAndAuth = useCallback(async () => {
if (!token) return;
setPeek(null);
setAuthedUserId(undefined);
try {
const [peekRes, authRes] = await Promise.all([
fetch(`/api/invitations/${encodeURIComponent(token)}/peek`, {
cache: 'no-store',
}),
createClient().auth.getUser(),
]);
const peekBody = (await peekRes.json()) as PeekResult;
setPeek(peekBody);
setAuthedUserId(authRes.data.user?.id ?? null);
} catch (err) {
console.error('[join] peek error:', err);
setPeek({ ok: false, reason: 'server_error' });
setAuthedUserId(null);
}
}, [token]);
// Fetch peek + auth state on mount. The peek endpoint is
// rate-limited per-IP (30/min) so double-mounting in React 19
// strict mode dev is harmless. We also use the `cancelled` flag
// to drop setState calls if the component unmounts mid-fetch.
useEffect(() => {
if (!token) return;
let cancelled = false;
(async () => {
try {
const [peekRes, authRes] = await Promise.all([
fetch(`/api/invitations/${encodeURIComponent(token)}/peek`, {
cache: 'no-store',
}),
createClient().auth.getUser(),
]);
const peekBody = (await peekRes.json()) as PeekResult;
if (cancelled) return;
setPeek(peekBody);
setAuthedUserId(authRes.data.user?.id ?? null);
} catch (err) {
console.error('[join] peek error:', err);
if (cancelled) return;
setPeek({ ok: false, reason: 'server_error' });
setAuthedUserId(null);
}
})();
return () => {
cancelled = true;
};
}, [token]);
const handleAccept = useCallback(async () => {
if (!token) return;
setAccepting(true);
try {
const res = await fetch(
`/api/invitations/${encodeURIComponent(token)}/redeem`,
{ method: 'POST' },
);
if (!res.ok) {
const payload = (await res.json().catch(() => ({}))) as {
error?: string;
};
// 409 = caller already has data / is in another shared
// account. The redeem RPC's error message is descriptive
// enough to show directly; we open a modal so the user has
// a clear next-action (sign out → use different email)
// rather than a 3-second toast.
if (res.status === 409) {
setConflictMessage(
payload.error ||
'You are already in another account. Sign in with a different email to join this one.',
);
} else {
toast.error(payload.error || 'Failed to accept invitation');
}
setAccepting(false);
return;
}
toast.success('Welcome to the team');
// Full reload (not router.push) so AuthProvider re-fetches
// the profile with the new account_id and account_role.
window.location.href = '/dashboard';
} catch (err) {
console.error('[join] redeem error:', err);
toast.error('Could not reach the server');
setAccepting(false);
}
}, [token]);
const handleSignOutAndRetry = useCallback(async () => {
setSigningOut(true);
try {
await createClient().auth.signOut();
// Hard reload so the new auth state propagates everywhere
// (middleware, AuthProvider). Preserves the invite token in
// the URL so the rebuilt page renders the signed-out CTA path.
window.location.reload();
} catch (err) {
console.error('[join] sign-out error:', err);
toast.error('Could not sign out. Try refreshing the page.');
setSigningOut(false);
}
}, []);
// ----- Loading state (peek pending OR auth not yet resolved) -----
if (peek === null || authedUserId === undefined) {
return (
<Card className="w-full max-w-md border-border bg-card">
<CardContent className="flex flex-col items-center gap-3 py-12">
<Loader2 className="size-6 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Verifying invitation…</p>
</CardContent>
</Card>
);
}
// ----- Peek failed -----
if (!peek.ok) {
const copy = FAIL_COPY[peek.reason];
return (
<Card className="w-full max-w-md border-border bg-card">
<CardHeader className="items-center text-center">
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-red-500/10">
<MailX className="h-6 w-6 text-red-400" />
</div>
<CardTitle className="text-xl text-foreground">{copy.title}</CardTitle>
<CardDescription className="text-muted-foreground">
{copy.body}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{/* For server_error the failure is transient — the network
flapped or the peek endpoint hiccupped. Try-again is
the right primary action; the "create account" /
"sign in" links stay as secondary options. Other
failure reasons (not_found / used / expired) are
terminal for this token, so no retry — just the
signup/sign-in escape hatches. */}
{peek.reason === 'server_error' ? (
<>
<Button
onClick={loadPeekAndAuth}
className="w-full bg-primary text-primary-foreground hover:bg-primary/90"
>
Try again
</Button>
<Link href="/signup">
<Button
variant="outline"
className="w-full border-border text-muted-foreground hover:bg-muted hover:text-foreground"
>
Create a new account instead
</Button>
</Link>
</>
) : (
<>
<Link href="/signup">
<Button className="w-full bg-primary text-primary-foreground hover:bg-primary/90">
Create a new account instead
</Button>
</Link>
<Link href="/login">
<Button
variant="outline"
className="w-full border-border text-muted-foreground hover:bg-muted hover:text-foreground"
>
Sign in
</Button>
</Link>
</>
)}
</CardContent>
</Card>
);
}
// ----- Peek OK -----
const inviteHeader = (
<CardHeader className="items-center text-center">
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
<UsersRound className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-xl text-foreground">
You&apos;re invited to{' '}
<span className="text-primary">{peek.account_name}</span>
</CardTitle>
<CardDescription className="text-muted-foreground">
You&apos;ll join as{' '}
<span className="inline-flex items-center gap-1 text-foreground">
<ShieldCheck className="size-3.5 text-primary" />
{ROLE_LABEL[peek.role]}
</span>
. Link valid until{' '}
{new Date(peek.expires_at).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
.
</CardDescription>
</CardHeader>
);
// ----- Authed: show Accept button -----
if (authedUserId) {
return (
<>
<Card className="w-full max-w-md border-border bg-card">
{inviteHeader}
<CardContent className="flex flex-col gap-3">
<Button
onClick={handleAccept}
disabled={accepting}
className="w-full bg-primary text-primary-foreground hover:bg-primary/90"
>
{accepting ? (
<>
<Loader2 className="size-4 animate-spin" />
Accepting…
</>
) : (
<>
<CheckCircle className="size-4" />
Accept invitation
</>
)}
</Button>
<p className="text-center text-xs text-muted-foreground">
Accepting moves your login into{' '}
<span className="text-muted-foreground">{peek.account_name}</span>. Your
empty personal account from signup will be cleaned up.
</p>
</CardContent>
</Card>
{/* Conflict modal — opens when the redeem endpoint returns 409
(caller already in a shared account or has domain data).
Blocks the flow until the user picks a recovery action so
they aren't stuck retrying an inevitable failure. */}
<Dialog
open={conflictMessage !== null}
onOpenChange={(open) => {
if (!open) setConflictMessage(null);
}}
>
<DialogContent className="bg-popover border-border sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-popover-foreground">
<AlertTriangle className="size-4 text-amber-400" />
Can&apos;t join {peek.account_name} with this account
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{conflictMessage}
</DialogDescription>
</DialogHeader>
<div className="space-y-2 py-2 text-xs text-muted-foreground">
<p>
To join{' '}
<span className="text-popover-foreground">{peek.account_name}</span>,
sign out and sign up again with a different email address.
The invite link stays valid as long as it hasn&apos;t
expired.
</p>
</div>
<DialogFooter className="bg-popover border-border">
<Button
variant="outline"
onClick={() => setConflictMessage(null)}
className="border-border text-popover-foreground hover:bg-muted"
>
Stay signed in
</Button>
<Button
onClick={handleSignOutAndRetry}
disabled={signingOut}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
{signingOut ? (
<>
<Loader2 className="size-4 animate-spin" />
Signing out…
</>
) : (
'Sign out & use a different email'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
// ----- Not authed: prompt to sign up or sign in -----
return (
<Card className="w-full max-w-md border-border bg-card">
{inviteHeader}
<CardContent className="flex flex-col gap-2">
<Link href={`/signup?invite=${encodeURIComponent(token!)}`}>
<Button className="w-full bg-primary text-primary-foreground hover:bg-primary/90">
Create account &amp; join
</Button>
</Link>
<Link href={`/login?invite=${encodeURIComponent(token!)}`}>
<Button
variant="outline"
className="w-full border-border text-muted-foreground hover:bg-muted hover:text-foreground"
>
I already have an account
</Button>
</Link>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,44 @@
// ============================================================
// /join/[token] layout — minimal full-bleed dark shell.
//
// The route group sits outside both `(auth)` and `(dashboard)`
// because it's hybrid: the page must render for anonymous
// visitors (to show "Sign up to join Acme") *and* for signed-in
// users (to show "Accept invite"). Reusing `(auth)`'s layout
// would funnel signed-in users through the middleware's auth-
// page redirect; reusing `(dashboard)` would funnel anonymous
// visitors through its login redirect. A dedicated layout
// avoids both.
//
// Styling matches the login / signup pages — centered card on a
// slate-950 background — so the join experience feels like a
// natural step in the auth funnel rather than a foreign page.
//
// Referrer-Policy: no-referrer
// The plaintext invite token lives in the URL path. Without
// this header, any externally-loaded resource (third-party
// font, CDN script, image) would receive the full join URL in
// its `Referer` header. The /join page doesn't currently load
// anything external, but `Referrer-Policy: no-referrer` is a
// cheap belt-and-braces guard against future regressions
// accidentally leaking tokens. Per Next.js 16's `metadata`
// export, this surfaces as `<meta name="referrer" content="no-referrer">`.
// ============================================================
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
export const metadata: Metadata = {
referrer: 'no-referrer',
// Belt-and-braces against an invite URL ending up in search
// results if a join page is ever crawled.
robots: { index: false, follow: false },
};
export default function JoinLayout({ children }: { children: ReactNode }) {
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
{children}
</div>
);
}