@gringhost/react
v0.12.10
Published
GrinGhost React components — authentication, wallet balance, budget, and recent activity
Readme
@gringhost/react
Composants React pour intégrer GrinGhost dans une app Next.js — authentification OIDC, wallet de crédits, budget par site et activité récente. Le débit est 100% côté serveur (voir « Facturer une action »).
npm install @gringhost/reactCe que ça fait
GrinGhost est à la fois le provider OAuth de tes utilisateurs et leur wallet de crédits. Ce package fournit :
GrinGhostProvider— gère l'auth, le solde, le budget et l'activité en interne via SupabaseGrinGhostButton— bouton autonome (login / menu) — pas de propsuseGrinGhost— hook :user,credits,loadCredits,budget,loadBudget,activity,loadActivity,appUrl
Le SDK gère l'authentification et l'affichage du solde. La facturation, elle, se fait côté serveur (dans ton backend) — voir « Facturer une action » plus bas.
Modèle de facturation : débit serveur
Le débit ne se fait jamais dans le navigateur. Côté serveur, tu débites avant l'appel IA :
POST /api/site/debit { user_payment_token, action_id, idempotency_key }
→ GrinGhost vérifie le budget, débite, écrit au ledger
→ si l'IA échoue : POST /api/site/refund { debit_id, idempotency_key }L'utilisateur fixe un budget par site lors de la connexion ; le site ne peut jamais le dépasser.
Prérequis
- Next.js (App Router) — testé sur 15+
- Supabase (auth + session)
- Un site GrinGhost avec au moins une action dans le catalogue
Setup
1. Dashboard GrinGhost
gringhost.com/dashboard/dev → Mes Sites → Nouveau Site
Récupère :
sandbox_api_key/api_key— clé API de ton siteclient_id/client_secret— credentials OAuthid— UUID du site
Puis Catalogue → Nouvelle action pour chaque action payante. Copie l'UUID généré.
2. Supabase — provider OIDC
Authentication → Sign In / Up → Social Providers → Add provider → Custom OAuth 2.0
| Champ | Valeur |
|-------|--------|
| Provider name | gringhost |
| Issuer URL | https://gringhost.com |
| Client ID | <client_id> du dashboard GrinGhost |
| Client Secret | <client_secret> du dashboard GrinGhost |
Ajoute les redirect URLs (Authentication → URL Configuration) :
http://localhost:3000/auth/callback ← dev
https://ton-domaine.com/auth/callback ← prod3. Variables d'environnement
# Supabase
NEXT_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
# GrinGhost — server-side uniquement (sauf APP_ID)
GRINGHOST_BASE_URL=https://gringhost.com
GRINGHOST_API_KEY=<sandbox_api_key> # api_key en prod
GRINGHOST_IS_SANDBOX=true # false en prod
NEXT_PUBLIC_GRINGHOST_APP_ID=<id du site>
GRINGHOST_MY_ACTION_ID=<uuid de l'action>
# IA
OPENAI_API_KEY=sk-...GRINGHOST_API_KEY ne doit jamais être exposé côté client.
4. Clients Supabase
lib/supabase/client.ts — browser :
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}lib/supabase/server.ts — server components et routes API :
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) { return cookieStore.get(name)?.value },
set(name: string, value: string, options: CookieOptions) { try { cookieStore.set({ name, value, ...options }) } catch {} },
remove(name: string, options: CookieOptions) { try { cookieStore.set({ name, value: '', ...options }) } catch {} },
},
}
)
}5. Callback OAuth — app/auth/callback/route.ts
Reçoit le code après connexion GrinGhost et l'échange contre une session Supabase :
import { createClient } from '@/lib/supabase/server'
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
if (code) {
const supabase = await createClient()
await supabase.auth.exchangeCodeForSession(code)
}
return NextResponse.redirect(origin + '/')
}6. Rafraîchissement de session — proxy.ts
Maintient la session Supabase active sur chaque requête :
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function proxy(request: NextRequest) {
let response = NextResponse.next({ request: { headers: request.headers } })
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) { return request.cookies.get(name)?.value },
set(name: string, value: string, options: CookieOptions) {
request.cookies.set({ name, value, ...options })
response = NextResponse.next({ request: { headers: request.headers } })
response.cookies.set({ name, value, ...options })
},
remove(name: string, options: CookieOptions) {
request.cookies.set({ name, value: '', ...options })
response = NextResponse.next({ request: { headers: request.headers } })
response.cookies.set({ name, value: '', ...options })
},
},
}
)
await supabase.auth.getUser()
return response
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
}7. Provider — app/providers.tsx
layout.tsx est un Server Component — il ne peut pas créer le client Supabase. Isole ça dans un client boundary :
'use client'
import { GrinGhostProvider } from '@gringhost/react'
import { createClient } from '@/lib/supabase/client'
const supabase = createClient()
export default function Providers({ children }: { children: React.ReactNode }) {
return <GrinGhostProvider supabase={supabase} locale="en">{children}</GrinGhostProvider>
}// app/layout.tsx — reste Server Component
import Providers from './providers'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}Le bouton GrinGhost
Place <GrinGhostButton /> une seule fois dans ton header — pas de props. Il gère tout :
- Non connecté → bouton "Continue with GrinGhost"
- Connecté → menu avec : email + solde du wallet, budget alloué à ce site (barre + « Modifier le budget → »), activité récente (3 derniers débits/remboursements + « Voir tout → »), « Tarifs & avis de cette app », « Gérer sur gringhost.com », déconnexion
Le débit est côté serveur — le bouton ne fait que consulter et rediriger, il ne débite jamais.
import { GrinGhostButton } from '@gringhost/react'
export default function Header() {
return (
<header>
<GrinGhostButton />
</header>
)
}Lire l'état auth et le solde
'use client'
import { useGrinGhost } from '@gringhost/react'
export default function MyComponent() {
const { user, isLoaded, credits, loadCredits } = useGrinGhost()
if (!isLoaded) return null
if (!user) return <p>Connecte-toi via le bouton GrinGhost.</p>
return <p>{credits} crédits</p>
}Recharger & gérer le budget (sécurité)
Le menu de GrinGhostButton propose un lien « Modifier le budget → » toujours présent
(ouvre gringhost.com/dashboard/apps ; en alerte, un statut rouge l'accompagne) et « Gérer sur
gringhost.com » (ouvre le dashboard). La recharge du wallet se fait depuis le dashboard — pas de
bouton « Recharger » renvoyant directement vers /buy, par souci de confiance.
Ces actions sont de simples redirections, par conception. Le SDK tourne dans la page du site partenaire : toute action critique (paiement, modification de budget) se fait sur gringhost.com, sous la session first-party de l'utilisateur — jamais via un appel issu du SDK, qui pourrait être falsifié. Le SDK ne fait que consulter et rediriger ; il ne peut ni acheter de crédits ni changer un budget. Le débit reste côté backend partenaire (clé API), plafonné par le budget fixé par l'user.
Depuis 0.12.1, ces redirections emportent ?login_hint=<email de l'user connecté ici>. Si la
session gringhost.com porte un autre compte (l'user a deux comptes ouverts), gringhost.com propose de
basculer plutôt que d'ouvrir le mauvais wallet. Le hint est non-fiable (avertissement + présélection
du compte Google uniquement) — jamais un octroi d'accès, l'auth reste le cookie de session gringhost.com.
Activité récente (activity / loadActivity)
useGrinGhost() expose activity : les derniers débits/remboursements de l'utilisateur sur ton site
(jamais son activité sur d'autres sites). Le menu de GrinGhostButton affiche déjà les 3 plus récents
avec un lien « Voir tout → » vers gringhost.com/dashboard/activity. Tu peux aussi l'afficher toi-même :
'use client'
import { useEffect } from 'react'
import { useGrinGhost } from '@gringhost/react'
export function RecentActivity() {
const { activity, loadActivity } = useGrinGhost()
useEffect(() => { loadActivity() }, [loadActivity])
if (!activity?.length) return null
return (
<ul>
{activity.map(a => (
<li key={a.id}>{a.label} · {a.type === 'refund' ? '+' : '−'}{Math.abs(a.credits)} cr</li>
))}
</ul>
)
}Chaque entrée : { id, type: 'debit' | 'refund', label, credits, created_at }.
Prérequis : une route proxy côté ton app, app/api/internal/activity/route.ts, qui relaie
vers GET {GRINGHOST_BASE_URL}/api/user/activity avec ton GRINGHOST_API_KEY + le provider_token
(même schéma que /api/internal/budget). Le starter l'inclut déjà.
Fiche de l'app — tarifs & avis (appUrl)
useGrinGhost() expose appUrl : l'URL publique de la fiche de ton app sur GrinGhost
(https://gringhost.com/catalog/...), qui affiche description, tarifs (catalogue d'actions),
note moyenne ★ et tous les avis utilisateurs. Le bouton GrinGhostButton propose déjà l'entrée
« Tarifs & avis de cette app » dans son menu — mais tu peux aussi poser ton propre lien :
'use client'
import { useGrinGhost } from '@gringhost/react'
export function PricingLink() {
const { appUrl } = useGrinGhost()
if (!appUrl) return null
return <a href={appUrl} target="_blank" rel="noopener noreferrer">Tarifs & avis</a>
}appUrl est dérivée automatiquement du client_id présent dans le token (donc dispo dès que
l'utilisateur est connecté). Pour l'avoir avant connexion, passe ton siteId au provider :
<GrinGhostProvider supabase={supabase} siteId="ton-site-id">{children}</GrinGhostProvider>Depuis la fiche, un utilisateur qui a réellement utilisé l'app peut la noter (lien « Donne ton avis » vers son dashboard GrinGhost).
Route credits — app/api/internal/credits/route.ts
Lit le solde GrinGhost de l'utilisateur connecté. Appelée automatiquement par GrinGhostProvider.
⚠️ Durabilité — le
provider_tokenexpire ~1h après le login (Supabase le supprime de la session). L'exemple ci-dessous est la version « live only » : passé cette heure,provider_tokenest absent et le solde retombe à0. En production, stocke lepayment_token(ghpay_xxx) dès que tu l'obtiens (il n'expire pas) et lis le solde en secours viaGET /api/user/budgetavecx-api-key+x-payment-token. Le starter Next.js le fait déjà (lib/gringhost.ts, cookie httpOnly) — recommandé pour toute app réelle.
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
const { data: { session } } = await supabase.auth.getSession()
const providerToken = session?.provider_token
if (!providerToken) return NextResponse.json({ credits: 0 })
try {
const res = await fetch(`${process.env.GRINGHOST_BASE_URL}/api/oauth/userinfo`, {
headers: { 'Authorization': `Bearer ${providerToken}` },
})
if (!res.ok) return NextResponse.json({ credits: 0 })
const data = await res.json()
const isSandbox = process.env.GRINGHOST_IS_SANDBOX === 'true'
return NextResponse.json({ credits: isSandbox ? (data.sandbox_credits ?? 0) : (data.credits ?? 0) })
} catch {
return NextResponse.json({ credits: 0 })
}
}Facturer une action (côté serveur)
La facturation se fait dans ton backend, jamais dans le navigateur. Récupère d'abord le
user_payment_token (ghpay_xxx) de l'utilisateur via GET /api/user/payment-auth (authentifié par
son provider_token + ta clé API), puis débite avant l'appel IA.
⚠️ Durabilité : le
provider_tokenexpire ~1h après le login. L'exemple ci-dessous re-fetchpayment-authà chaque appel → au-delà d'une heure il renvoiepayment_not_configured(402). En production, stocke lepayment_token(ghpay_xxx) dès qu'il est disponible et réutilise-le (il n'expire pas). Le starter fournitgetPaymentToken()(lib/gringhost.ts, cookie httpOnly) qui gère ce fallback pour toi.
Route action — app/api/internal/mon-action/route.ts
import { createClient } from '@/lib/supabase/server'
import { NextRequest, NextResponse } from 'next/server'
const GH = process.env.GRINGHOST_BASE_URL
const KEY = { 'Content-Type': 'application/json', 'x-api-key': process.env.GRINGHOST_API_KEY! }
export async function POST(request: NextRequest) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
const { input } = await request.json()
// 1. Récupérer le ghpay_xxx de l'utilisateur
const { data: { session } } = await supabase.auth.getSession()
const providerToken = session?.provider_token
if (!providerToken) return NextResponse.json({ error: 'payment_not_configured' }, { status: 402 })
const payRes = await fetch(`${GH}/api/user/payment-auth`, {
headers: { 'Authorization': `Bearer ${providerToken}`, 'x-api-key': process.env.GRINGHOST_API_KEY! },
})
if (!payRes.ok) return NextResponse.json({ error: 'payment_not_configured' }, { status: 402 })
const { payment_token } = await payRes.json()
// 2. Débiter AVANT l'appel IA
const debitRes = await fetch(`${GH}/api/site/debit`, {
method: 'POST', headers: KEY,
body: JSON.stringify({ user_payment_token: payment_token, action_id: process.env.GRINGHOST_MY_ACTION_ID, idempotency_key: crypto.randomUUID() }),
})
if (debitRes.status === 402) return NextResponse.json({ error: 'budget_exceeded' }, { status: 402 })
if (!debitRes.ok) return NextResponse.json({ error: 'debit_failed' }, { status: 500 })
const { debit_id } = await debitRes.json()
// 3. Appel IA (après débit confirmé). Si l'IA échoue : POST /api/site/refund { debit_id }
const result = await callYourAI(input)
return NextResponse.json({ result })
}Côté client
'use client'
import { useGrinGhost } from '@gringhost/react'
export function MyComponent() {
const { loadCredits } = useGrinGhost()
async function handleAction() {
const res = await fetch('/api/internal/mon-action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: '...' }),
})
if (res.status === 402) { window.open('https://gringhost.com/dashboard/apps', '_blank'); return } // budget épuisé → l'user l'ajuste sur son dashboard
loadCredits() // rafraîchit le solde dans GrinGhostButton
}
return <button onClick={handleAction}>Lancer l'action</button>
}Flux de facturation — détail
[Utilisateur clique]
↓
POST /api/internal/mon-action (ton serveur)
↓
POST /api/site/debit (GrinGhost) → { debit_id } ← débite (budget vérifié)
↓
Appel IA (si elle échoue : POST /api/site/refund { debit_id })
↓
RéponseSandbox vs Production
| | Sandbox (sandbox_api_key) | Production (api_key) |
|---|---|---|
| Wallet réel touché | Non, jamais | Oui |
| Crédits débités | Aucun (fictif) | Crédits réels |
| Entrée ledger | Oui (is_sandbox: true) | Oui |
| GRINGHOST_IS_SANDBOX | true | false |
En prod : seuls GRINGHOST_API_KEY et GRINGHOST_IS_SANDBOX changent. Aucune modification de code.
Garanties de sécurité
Budget plafonné par l'utilisateur
L'utilisateur fixe un budget par site lors de la connexion (max_budget_credits). Le site ne peut
jamais le dépasser, ni voir le solde total. Le prix vient du catalogue —
le dev ne peut pas le gonfler au moment du débit (changements loggés et surveillés). Autorisation révocable à tout moment.
Remboursement si l'IA échoue
Si l'IA plante après un débit, le serveur appelle POST /api/site/refund { debit_id } → les crédits
sont rendus à l'utilisateur.
Deux secrets requis + anti-double-débit
- Débiter exige le
user_payment_token(ghpay_xxx) et la clé API du site. Un seul ne suffit pas. - Le token est lié à un
(user_id, site_id)— inutilisable sur un autre site. idempotency_keyUNIQUE — un retry réseau avec la même clé ne débite jamais deux fois.- Génère toujours
crypto.randomUUID()frais par requête.
Tarification
1 crédit = 0.001 $ pour l'utilisateur. Tu (développeur) reçois 90%.
const coutReel = 0.00005 // $ — coût API IA
const marge = 2
const credits = Math.ceil(coutReel * marge / 0.001) // nombre de créditsLes prix sont dans le catalogue GrinGhost, pas dans le code. Le dev les fait évoluer librement — chaque changement est enregistré et surveillé par GrinGhost. La garantie utilisateur reste son budget, infranchissable.
Documentation complète
Langue du widget
GrinGhostProvider accepte locale?: 'en' | 'fr' (défaut 'en') : toutes les chaînes du
bouton et de son menu (Account/Compte, budget, activité, Sign out/Se déconnecter…) et les
formats de nombres suivent cette prop. Passe la langue courante de ton app :
<GrinGhostProvider supabase={supabase} locale={locale}>