mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-16 03:36:32 +02:00
Connexion Facebook / LinkedIn en OAuth depuis le backoffice
Les jetons se posaient à la main dans docker-compose. Un écran Backend ›
Réseaux sociaux permet désormais de coller les identifiants de l'application,
cliquer Connecter, choisir la page, et ne plus y revenir.
- Table `social_accounts` : identifiants, jetons et cible par réseau. Tous les
secrets sont chiffrés en AES-256-GCM (`src/lib/crypto.ts`), clé
`SOCIAL_TOKEN_KEY` avec repli sur `AUTH_SECRET`.
- Routes `api/social/[network]/{connect,callback}` : `state` anti-CSRF en cookie
httpOnly, échange du code, récupération des pages administrées. Aucun jeton ne
transite par une URL — les cibles sont relistées côté serveur au moment de la
sélection.
- Facebook : jeton utilisateur longue durée puis jeton de PAGE, qui n'expire
pas. LinkedIn : jeton 60 jours, rafraîchi automatiquement si l'application a
obtenu les jetons de rafraîchissement programmatiques.
- Faute de quoi le backoffice affiche la date d'expiration, un bandeau sur le
tableau de bord à J-7 et un bouton Reconnecter.
- `social.ts` lit les identifiants via `social-accounts.ts` : base d'abord,
variables d'environnement ensuite. Les installations existantes continuent de
fonctionner sans modification.
- Le secret d'application n'est jamais renvoyé au navigateur : champ vide =
valeur conservée.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
This commit is contained in:
19 files changed
+2599
-82
No files matched your search
@@ -0,0 +1,85 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { can } from "@/lib/rbac";
|
||||
import {
|
||||
exchangeCode,
|
||||
getDecryptedAppSecret,
|
||||
getSocialAccount,
|
||||
saveConnection,
|
||||
SOCIAL_NETWORKS,
|
||||
type SocialNetwork,
|
||||
} from "@/lib/social-accounts";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const SETTINGS = "/backend/reseaux";
|
||||
|
||||
function back(request: Request, params: Record<string, string>) {
|
||||
const url = new URL(SETTINGS, request.url);
|
||||
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
|
||||
const response = NextResponse.redirect(url);
|
||||
// Le state a fait son office, quel que soit le résultat.
|
||||
for (const network of SOCIAL_NETWORKS) response.cookies.delete(`plr_oauth_${network}`);
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ network: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!can(session?.user.role, "manageSettings")) {
|
||||
return NextResponse.redirect(new URL("/backend", request.url));
|
||||
}
|
||||
|
||||
const { network: raw } = await params;
|
||||
const network = raw as SocialNetwork;
|
||||
if (!SOCIAL_NETWORKS.includes(network)) {
|
||||
return back(request, { error: "Réseau inconnu." });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error");
|
||||
if (error) {
|
||||
return back(request, { error: `Autorisation refusée : ${error}` });
|
||||
}
|
||||
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
const expected = request.headers
|
||||
.get("cookie")
|
||||
?.split(";")
|
||||
.map((c) => c.trim())
|
||||
.find((c) => c.startsWith(`plr_oauth_${network}=`))
|
||||
?.split("=")[1];
|
||||
|
||||
if (!code || !state || !expected || state !== expected) {
|
||||
return back(request, { error: "Requête de retour invalide (state). Relancez la connexion." });
|
||||
}
|
||||
|
||||
const account = await getSocialAccount(network);
|
||||
const appSecret = await getDecryptedAppSecret(network);
|
||||
if (!account?.appId || !appSecret) {
|
||||
return back(request, { error: "Identifiants d'application introuvables." });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await exchangeCode(network, account.appId, appSecret, code);
|
||||
const userId = Number(session?.user.id);
|
||||
|
||||
// Une seule page administrée : on la sélectionne d'office. Sinon on laisse
|
||||
// choisir, en conservant le jeton utilisateur le temps de la sélection.
|
||||
if (result.targets.length === 1) {
|
||||
await saveConnection(network, result, result.targets[0], Number.isFinite(userId) ? userId : null);
|
||||
return back(request, { connected: network });
|
||||
}
|
||||
|
||||
// Plusieurs pages : on garde le jeton utilisateur, l'écran relistera les
|
||||
// cibles côté serveur. Rien de sensible ne transite par l'URL.
|
||||
await saveConnection(network, result, null, Number.isFinite(userId) ? userId : null);
|
||||
return back(request, { choose: network });
|
||||
} catch (caught) {
|
||||
const message = caught instanceof Error ? caught.message : "Échec de la connexion.";
|
||||
return back(request, { error: message.slice(0, 400) });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { can } from "@/lib/rbac";
|
||||
import {
|
||||
authorizeUrl,
|
||||
getDecryptedAppSecret,
|
||||
getSocialAccount,
|
||||
siteUrl,
|
||||
SOCIAL_NETWORKS,
|
||||
type SocialNetwork,
|
||||
} from "@/lib/social-accounts";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const SETTINGS = "/backend/reseaux";
|
||||
|
||||
function back(request: Request, error: string) {
|
||||
return NextResponse.redirect(new URL(`${SETTINGS}?error=${encodeURIComponent(error)}`, request.url));
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ network: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!can(session?.user.role, "manageSettings")) {
|
||||
return NextResponse.redirect(new URL("/backend", request.url));
|
||||
}
|
||||
|
||||
const { network: raw } = await params;
|
||||
const network = raw as SocialNetwork;
|
||||
if (!SOCIAL_NETWORKS.includes(network)) {
|
||||
return back(request, "Réseau inconnu.");
|
||||
}
|
||||
|
||||
if (!siteUrl()) {
|
||||
return back(
|
||||
request,
|
||||
"L'URL publique du site n'est pas configurée (NEXT_PUBLIC_SITE_URL) : impossible de construire l'adresse de retour."
|
||||
);
|
||||
}
|
||||
|
||||
const account = await getSocialAccount(network);
|
||||
const appSecret = await getDecryptedAppSecret(network);
|
||||
if (!account?.appId || !appSecret) {
|
||||
return back(request, "Enregistrez d'abord l'identifiant et le secret de l'application.");
|
||||
}
|
||||
|
||||
// `state` en cookie httpOnly : vérifié au retour pour écarter toute requête
|
||||
// de rappel forgée.
|
||||
const state = randomBytes(24).toString("hex");
|
||||
const response = NextResponse.redirect(authorizeUrl(network, account.appId, state));
|
||||
response.cookies.set(`plr_oauth_${network}`, state, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: new URL(siteUrl()).protocol === "https:",
|
||||
path: "/",
|
||||
maxAge: 600,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
@@ -171,6 +171,11 @@ export function BackendShell({
|
||||
{dotDiamond}Catégories
|
||||
</NavLink>
|
||||
)}
|
||||
{can(user.role, "manageSettings") && (
|
||||
<NavLink href="/backend/reseaux" active={pathname === "/backend/reseaux"}>
|
||||
{dot}Réseaux sociaux
|
||||
</NavLink>
|
||||
)}
|
||||
{can(user.role, "manageSettings") && (
|
||||
<NavLink href="/backend/parametres" active={pathname === "/backend/parametres"}>
|
||||
{dot}Paramètres
|
||||
|
||||
@@ -31,6 +31,11 @@ import {
|
||||
SOCIAL_NETWORKS,
|
||||
type SocialNetwork,
|
||||
} from "@/lib/social";
|
||||
import {
|
||||
disconnectAccount,
|
||||
saveAppCredentials,
|
||||
selectTarget,
|
||||
} from "@/lib/social-accounts";
|
||||
import { normalizeWebsite } from "@/lib/member-profile";
|
||||
import { SITE_SETTING_DEFAULTS } from "@/lib/site-settings";
|
||||
import type { AppRole } from "@/types/next-auth";
|
||||
@@ -164,7 +169,7 @@ async function publishPromoShares(
|
||||
for (const network of networks) {
|
||||
if (alreadyPosted.has(network)) continue;
|
||||
|
||||
if (!isNetworkConfigured(network)) {
|
||||
if (!(await isNetworkConfigured(network))) {
|
||||
await db.insert(socialPosts).values({
|
||||
promotionId: promoId,
|
||||
network,
|
||||
@@ -1128,6 +1133,54 @@ export async function setContactStatus(formData: FormData) {
|
||||
revalidatePath("/backend");
|
||||
}
|
||||
|
||||
// ---- Réseaux sociaux : identifiants d'application et connexion ----
|
||||
export async function saveSocialApp(formData: FormData) {
|
||||
const { role } = await requireRole();
|
||||
if (!can(role, "manageSettings")) throw new Error("Accès refusé");
|
||||
|
||||
const network = String(formData.get("network")) as SocialNetwork;
|
||||
if (!SOCIAL_NETWORKS.includes(network)) return;
|
||||
|
||||
const appId = asString(formData, "appId");
|
||||
if (!appId) throw new Error("L'identifiant de l'application est requis.");
|
||||
// Champ secret laissé vide = on garde celui déjà enregistré.
|
||||
const appSecret = asString(formData, "appSecret") || null;
|
||||
|
||||
await saveAppCredentials(network, appId, appSecret);
|
||||
revalidatePath("/backend/reseaux");
|
||||
}
|
||||
|
||||
export async function selectSocialTarget(formData: FormData) {
|
||||
const { role } = await requireRole();
|
||||
if (!can(role, "manageSettings")) throw new Error("Accès refusé");
|
||||
|
||||
const network = String(formData.get("network")) as SocialNetwork;
|
||||
const targetId = asString(formData, "targetId");
|
||||
if (!SOCIAL_NETWORKS.includes(network) || !targetId) return;
|
||||
|
||||
await selectTarget(network, targetId);
|
||||
await logActivity(`Page ${SOCIAL_LABELS[network]} sélectionnée pour la publication`, "#2C6FB3");
|
||||
revalidatePath("/backend/reseaux");
|
||||
revalidatePath("/backend/promotions");
|
||||
}
|
||||
|
||||
export async function disconnectSocial(formData: FormData) {
|
||||
const { role, name } = await requireRole();
|
||||
if (!can(role, "manageSettings")) throw new Error("Accès refusé");
|
||||
|
||||
const network = String(formData.get("network")) as SocialNetwork;
|
||||
if (!SOCIAL_NETWORKS.includes(network)) return;
|
||||
|
||||
await disconnectAccount(network);
|
||||
await logActivity(
|
||||
`Compte ${SOCIAL_LABELS[network]} déconnecté par <strong>${name}</strong>`,
|
||||
"#d8472b"
|
||||
);
|
||||
revalidatePath("/backend/reseaux");
|
||||
revalidatePath("/backend/promotions");
|
||||
revalidatePath("/backend/espace");
|
||||
}
|
||||
|
||||
// ---- Sign out ----
|
||||
export async function doSignOut() {
|
||||
await signOut({ redirectTo: "/" });
|
||||
|
||||
@@ -125,7 +125,7 @@ export default async function EspacePage() {
|
||||
.from(categories)
|
||||
.orderBy(asc(categories.sort));
|
||||
const categoryLabels = catRows.map((c) => c.label);
|
||||
const networks = configuredNetworks();
|
||||
const networks = await configuredNetworks();
|
||||
|
||||
function fmtDate(d: Date) {
|
||||
return new Date(d).toLocaleDateString("fr-FR", { day: "numeric", month: "long" });
|
||||
|
||||
@@ -4,7 +4,8 @@ import { desc, eq } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/db";
|
||||
import { activityLog, contactMessages, members, membershipRequests, promotions } from "@/db/schema";
|
||||
import { isStaff } from "@/lib/rbac";
|
||||
import { can, isStaff } from "@/lib/rbac";
|
||||
import { expiryStatus, getSocialAccounts, SOCIAL_LABELS } from "@/lib/social-accounts";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -49,6 +50,15 @@ export default async function DashboardPage() {
|
||||
|
||||
const pendingCount = pendingPromos.length;
|
||||
|
||||
// Jetons réseaux à renouveler : sans alerte, on ne découvre l'expiration
|
||||
// qu'au moment où une publication échoue.
|
||||
const expiringNetworks = can(session?.user.role, "manageSettings")
|
||||
? (await getSocialAccounts())
|
||||
.filter((a) => a.accessToken && a.targetId)
|
||||
.map((a) => ({ network: a.network, status: expiryStatus(a.expiresAt) }))
|
||||
.filter((a) => a.status === "soon" || a.status === "expired")
|
||||
: [];
|
||||
|
||||
function timeAgo(date: Date) {
|
||||
const diff = Date.now() - new Date(date).getTime();
|
||||
const h = Math.floor(diff / 3_600_000);
|
||||
@@ -60,6 +70,34 @@ export default async function DashboardPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{expiringNetworks.length > 0 && (
|
||||
<Link
|
||||
href="/backend/reseaux"
|
||||
style={{
|
||||
display: "block",
|
||||
textDecoration: "none",
|
||||
background: "#fbeede",
|
||||
border: "1px solid #ecd8b8",
|
||||
color: "#9a6638",
|
||||
borderRadius: 12,
|
||||
padding: "13px 16px",
|
||||
marginBottom: 20,
|
||||
fontSize: 13.5,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
{expiringNetworks.map((n) => (
|
||||
<div key={n.network}>
|
||||
<strong>{SOCIAL_LABELS[n.network]}</strong>{" "}
|
||||
{n.status === "expired"
|
||||
? "— le jeton a expiré, les publications échoueront."
|
||||
: "— le jeton expire dans moins de 7 jours."}{" "}
|
||||
Reconnecter le compte →
|
||||
</div>
|
||||
))}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div className="grid grid-4" style={{ marginBottom: 26 }}>
|
||||
<StatCard label="Adhérents actifs" value={activeMembers.length} hint="sur le réseau" valueColor="#13324F" hintColor="#1f8a5b" />
|
||||
<StatCard label="Promotions en ligne" value={livePromos.length} hint="visibles sur le site" />
|
||||
|
||||
@@ -32,7 +32,7 @@ export default async function PromotionsPage() {
|
||||
redirect("/backend");
|
||||
}
|
||||
const canShare = can(session?.user.role, "publishSocial");
|
||||
const networks = canShare ? configuredNetworks() : [];
|
||||
const networks = canShare ? await configuredNetworks() : [];
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import type { CSSProperties } from "react";
|
||||
import { auth } from "@/auth";
|
||||
import { can } from "@/lib/rbac";
|
||||
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||||
import {
|
||||
expiryStatus,
|
||||
getSocialAccounts,
|
||||
listStoredTargets,
|
||||
redirectUri,
|
||||
siteUrl,
|
||||
SOCIAL_LABELS,
|
||||
SOCIAL_NETWORKS,
|
||||
type SocialTarget,
|
||||
} from "@/lib/social-accounts";
|
||||
import { disconnectSocial, saveSocialApp, selectSocialTarget } from "../actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const HELP: Record<
|
||||
(typeof SOCIAL_NETWORKS)[number],
|
||||
{ portal: string; steps: string[]; caution?: string }
|
||||
> = {
|
||||
facebook: {
|
||||
portal: "https://developers.facebook.com/apps",
|
||||
steps: [
|
||||
"Créez une application de type « Business » et relevez l'identifiant et la clé secrète (Paramètres › Général).",
|
||||
"Ajoutez le produit « Connexion Facebook » puis collez l'URL de redirection ci-dessous dans « URI de redirection OAuth valides ».",
|
||||
"Laissez l'application en mode développement et ajoutez le compte de l'association comme administrateur : publier sur votre propre page ne demande alors aucune revue Meta.",
|
||||
],
|
||||
},
|
||||
linkedin: {
|
||||
portal: "https://www.linkedin.com/developers/apps",
|
||||
steps: [
|
||||
"Créez une application rattachée à la page LinkedIn de l'association et relevez le Client ID et le Client Secret (onglet Auth).",
|
||||
"Collez l'URL de redirection ci-dessous dans « Authorized redirect URLs ».",
|
||||
"Onglet Produits : demandez « Community Management API », indispensable pour publier au nom de la page.",
|
||||
],
|
||||
caution:
|
||||
"LinkedIn soumet cette demande à une revue (page vérifiée, nom légal, adresse, politique de confidentialité) et ses jetons expirent au bout de 60 jours : il faudra recliquer sur Reconnecter environ tous les deux mois.",
|
||||
},
|
||||
};
|
||||
|
||||
function fmtDate(d: Date) {
|
||||
return new Date(d).toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" });
|
||||
}
|
||||
|
||||
export default async function ReseauxPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ error?: string; connected?: string; choose?: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!can(session?.user.role, "manageSettings")) redirect("/backend");
|
||||
|
||||
const { error, connected, choose } = await searchParams;
|
||||
const accounts = await getSocialAccounts();
|
||||
const byNetwork = new Map(accounts.map((a) => [a.network, a]));
|
||||
const base = siteUrl();
|
||||
|
||||
// Cibles à proposer quand le compte administre plusieurs pages.
|
||||
const targets = new Map<string, SocialTarget[]>();
|
||||
let targetError: string | null = null;
|
||||
for (const network of SOCIAL_NETWORKS) {
|
||||
const account = byNetwork.get(network);
|
||||
if (account?.accessToken && !account.targetId) {
|
||||
try {
|
||||
targets.set(network, await listStoredTargets(network));
|
||||
} catch (caught) {
|
||||
targetError = caught instanceof Error ? caught.message : "Pages illisibles.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 22, maxWidth: 1080 }}>
|
||||
{error && <Banner tone="error">{error}</Banner>}
|
||||
{targetError && <Banner tone="error">{targetError}</Banner>}
|
||||
{connected && (
|
||||
<Banner tone="ok">
|
||||
Compte {SOCIAL_LABELS[connected as "facebook"] ?? connected} connecté.
|
||||
</Banner>
|
||||
)}
|
||||
{choose && (
|
||||
<Banner tone="warn">
|
||||
Connexion réussie : choisissez la page à utiliser pour les publications.
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{!base && (
|
||||
<Banner tone="warn">
|
||||
L'URL publique du site n'est pas renseignée (variable
|
||||
<code> NEXT_PUBLIC_SITE_URL</code>). Elle est indispensable pour construire
|
||||
l'adresse de retour OAuth : renseignez-la avant de connecter un réseau.
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{SOCIAL_NETWORKS.map((network) => {
|
||||
const account = byNetwork.get(network);
|
||||
const help = HELP[network];
|
||||
const status = expiryStatus(account?.expiresAt);
|
||||
const isConnected = !!account?.accessToken && !!account.targetId;
|
||||
const candidates = targets.get(network) ?? [];
|
||||
|
||||
return (
|
||||
<section key={network} style={panel}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 6 }}>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 12,
|
||||
background: SOCIAL_BRAND[network],
|
||||
color: "#fff",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<SocialIcon network={network} size={20} />
|
||||
</span>
|
||||
<h2 className="font-display" style={{ ...title, margin: 0, flex: 1 }}>
|
||||
{SOCIAL_LABELS[network]}
|
||||
</h2>
|
||||
<StatusChip connected={isConnected} status={status} />
|
||||
</div>
|
||||
|
||||
{isConnected && (
|
||||
<div style={{ fontSize: 13.5, color: "#6c6150", marginBottom: 14, lineHeight: 1.6 }}>
|
||||
Publie sur <strong style={{ color: "#26201a" }}>{account?.targetName}</strong>.
|
||||
{status === "never" && " Ce jeton n'expire pas."}
|
||||
{account?.expiresAt && status !== "never" && ` Jeton valable jusqu'au ${fmtDate(account.expiresAt)}.`}
|
||||
{account?.connectedAt && ` Connecté le ${fmtDate(account.connectedAt)}.`}
|
||||
<br />
|
||||
Pour changer de page, relancez une connexion.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(status === "soon" || status === "expired") && isConnected && (
|
||||
<Banner tone={status === "expired" ? "error" : "warn"}>
|
||||
{status === "expired"
|
||||
? `Le jeton ${SOCIAL_LABELS[network]} a expiré : les publications échoueront tant que vous n'aurez pas reconnecté le compte.`
|
||||
: `Le jeton ${SOCIAL_LABELS[network]} expire bientôt. Un clic sur Reconnecter suffit à le renouveler.`}
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{candidates.length > 0 && (
|
||||
<div style={{ background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 12, padding: 15, marginBottom: 16 }}>
|
||||
<div className="field-label" style={{ marginBottom: 9 }}>
|
||||
Page à utiliser pour les publications
|
||||
</div>
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
{candidates.map((target) => (
|
||||
<form key={target.id} action={selectSocialTarget} style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<input type="hidden" name="network" value={network} />
|
||||
<input type="hidden" name="targetId" value={target.id} />
|
||||
<span style={{ flex: 1, fontSize: 13.5, color: "#3c3322", fontWeight: 600 }}>
|
||||
{target.name}
|
||||
</span>
|
||||
<button type="submit" style={{ border: "none", background: "#13324F", color: "#fff", fontWeight: 700, fontSize: 12.5, padding: "8px 14px", borderRadius: 9, cursor: "pointer" }}>
|
||||
Choisir
|
||||
</button>
|
||||
</form>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action={saveSocialApp} style={{ marginBottom: 14 }}>
|
||||
<input type="hidden" name="network" value={network} />
|
||||
<div className="grid grid-2" style={{ gap: 16 }}>
|
||||
<div>
|
||||
<label className="field-label">
|
||||
{network === "facebook" ? "Identifiant de l'application" : "Client ID"}
|
||||
</label>
|
||||
<input name="appId" defaultValue={account?.appId ?? ""} className="field" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">
|
||||
{network === "facebook" ? "Clé secrète" : "Client Secret"}
|
||||
</label>
|
||||
<input
|
||||
name="appSecret"
|
||||
type="password"
|
||||
className="field"
|
||||
autoComplete="new-password"
|
||||
placeholder={account ? "Enregistré — laissez vide pour le conserver" : "Collez la clé secrète"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" style={{ ...submitButton, marginTop: 14 }}>
|
||||
Enregistrer les identifiants
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", borderTop: "1px solid #f0e8d6", paddingTop: 14 }}>
|
||||
<a
|
||||
href={`/api/social/${network}/connect`}
|
||||
className="font-display"
|
||||
style={{
|
||||
textDecoration: "none",
|
||||
border: "none",
|
||||
background: account?.appId ? SOCIAL_BRAND[network] : "#d8cdb4",
|
||||
color: "#fff",
|
||||
fontWeight: 700,
|
||||
fontSize: 14,
|
||||
padding: "12px 20px",
|
||||
borderRadius: 10,
|
||||
pointerEvents: account?.appId ? undefined : "none",
|
||||
}}
|
||||
>
|
||||
{isConnected ? "Reconnecter" : "Connecter"}
|
||||
</a>
|
||||
{isConnected && (
|
||||
<form action={disconnectSocial}>
|
||||
<input type="hidden" name="network" value={network} />
|
||||
<button type="submit" style={{ border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", fontWeight: 700, fontSize: 14, padding: "12px 18px", borderRadius: 10, cursor: "pointer" }}>
|
||||
Déconnecter
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<details style={{ marginTop: 16 }}>
|
||||
<summary style={{ cursor: "pointer", fontSize: 13.5, fontWeight: 700, color: "#9a6638" }}>
|
||||
Comment obtenir ces identifiants ?
|
||||
</summary>
|
||||
<ol style={{ margin: "12px 0 0", paddingLeft: 20, fontSize: 13.5, color: "#6c6150", lineHeight: 1.7 }}>
|
||||
{help.steps.map((step) => (
|
||||
<li key={step}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
<div style={{ marginTop: 12, fontSize: 13, color: "#6c6150" }}>
|
||||
Portail :{" "}
|
||||
<a href={help.portal} target="_blank" rel="noopener noreferrer" style={{ color: "#2C6FB3", fontWeight: 700 }}>
|
||||
{help.portal}
|
||||
</a>
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<div className="field-label">URL de redirection à déclarer</div>
|
||||
<code style={{ display: "block", background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 9, padding: "10px 12px", fontSize: 12.5, color: "#3c3322", overflowWrap: "anywhere" }}>
|
||||
{base ? redirectUri(network) : "— renseignez d'abord NEXT_PUBLIC_SITE_URL —"}
|
||||
</code>
|
||||
</div>
|
||||
{help.caution && (
|
||||
<div style={{ marginTop: 12, background: "#fbeede", border: "1px solid #ecd8b8", color: "#9a6638", borderRadius: 10, padding: "11px 13px", fontSize: 13, lineHeight: 1.6 }}>
|
||||
{help.caution}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusChip({ connected, status }: { connected: boolean; status: string }) {
|
||||
const [label, bg, color] = !connected
|
||||
? ["Non connecté", "#f1efe7", "#a99c82"]
|
||||
: status === "expired"
|
||||
? ["Jeton expiré", "#fbe9e6", "#d8472b"]
|
||||
: status === "soon"
|
||||
? ["Expire bientôt", "#fbeede", "#9a6638"]
|
||||
: ["Connecté", "#e6f4ec", "#1f8a5b"];
|
||||
return (
|
||||
<span style={{ background: bg, color, borderRadius: 999, padding: "6px 13px", fontSize: 12, fontWeight: 800 }}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Banner({ tone, children }: { tone: "ok" | "warn" | "error"; children: React.ReactNode }) {
|
||||
const palette = {
|
||||
ok: { bg: "#e6f4ec", border: "#c7e6d5", color: "#1f8a5b" },
|
||||
warn: { bg: "#fbeede", border: "#ecd8b8", color: "#9a6638" },
|
||||
error: { bg: "#fbe9e6", border: "#f2d5cf", color: "#a8503c" },
|
||||
}[tone];
|
||||
return (
|
||||
<div style={{ background: palette.bg, border: `1px solid ${palette.border}`, color: palette.color, borderRadius: 12, padding: "12px 16px", fontSize: 13.5, lineHeight: 1.6 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const panel: CSSProperties = {
|
||||
background: "#fff",
|
||||
border: "1px solid #e6dcc6",
|
||||
borderRadius: 16,
|
||||
padding: 22,
|
||||
};
|
||||
|
||||
const title: CSSProperties = {
|
||||
fontSize: 20,
|
||||
color: "#26201a",
|
||||
};
|
||||
|
||||
const submitButton: CSSProperties = {
|
||||
border: "none",
|
||||
background: "#13324F",
|
||||
color: "#fff",
|
||||
fontWeight: 800,
|
||||
fontSize: 14,
|
||||
padding: "11px 20px",
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
};
|
||||
Reference in new issue
Block a user