mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-11 17:27:54 +02:00
URL publique du site réglable depuis le backoffice
Connecter un réseau social imposait de poser NEXT_PUBLIC_SITE_URL dans docker-compose et de redémarrer le conteneur. L'écran Réseaux sociaux porte désormais le champ, pré-rempli avec l'adresse par laquelle l'administrateur consulte la page. - Nouveau réglage `site_public_url` ; `siteUrl()` lit la base puis retombe sur les variables d'environnement, donc les déploiements actuels ne bougent pas. - `siteUrl()`, `redirectUri()`, `authorizeUrl()`, `promoLink()` et `buildPromoMessage()` deviennent asynchrones. - Seule l'origine est conservée : le schéma est ajouté s'il manque, un chemin est retiré, et une adresse invalide revient sur l'écran avec un message au lieu d'une page d'erreur brute. - `saveSiteSettings` saute cette clé : le formulaire Paramètres ne la contient pas et l'aurait écrasée à chaque enregistrement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
This commit is contained in:
9 files changed
+146
-37
No files matched your search
+2
-2
@@ -30,8 +30,8 @@ SEED_ADMIN_NAME=Administrateur Plein R
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
|
||||
# URL publique du site, utilisée dans le texte des publications réseaux sociaux
|
||||
# (lien vers la fiche de l'adhérent). À défaut, AUTH_URL est utilisé.
|
||||
# URL publique du site. Se règle normalement dans Backend › Réseaux sociaux ;
|
||||
# cette variable ne sert que de repli (puis AUTH_URL en dernier recours).
|
||||
NEXT_PUBLIC_SITE_URL=
|
||||
|
||||
# ---- Publication des promotions sur les réseaux sociaux ----
|
||||
|
||||
@@ -58,7 +58,11 @@ site sans traitement supplémentaire.
|
||||
`src/lib/crypto.ts` (AES-256-GCM, clé `SOCIAL_TOKEN_KEY` ou `AUTH_SECRET`), posés
|
||||
depuis `/backend/reseaux`. Les variables d'environnement restent lues en repli.
|
||||
Aucun secret ne doit jamais repartir vers le navigateur.
|
||||
- `isNetworkConfigured()` / `configuredNetworks()` sont **asynchrones** (accès base).
|
||||
- `isNetworkConfigured()` / `configuredNetworks()` / `siteUrl()` / `redirectUri()`
|
||||
sont **asynchrones** (accès base).
|
||||
- L'URL publique est le réglage `site_public_url`, édité sur `/backend/reseaux`
|
||||
(pré-rempli depuis les en-têtes de la requête). `saveSiteSettings` **saute**
|
||||
cette clé : le formulaire Paramètres ne la contient pas et l'écraserait.
|
||||
- Routes OAuth : `src/app/api/social/[network]/{connect,callback}`. Le `state`
|
||||
anti-CSRF passe par un cookie httpOnly ; aucun jeton ne transite par une URL.
|
||||
- Facebook : le jeton de page n'expire pas. LinkedIn : 60 jours, rafraîchissement
|
||||
|
||||
@@ -111,8 +111,13 @@ jetons sont récupérés par OAuth et stockés **chiffrés** (AES-256-GCM, clé
|
||||
navigateur. Un réseau non connecté voit simplement sa case disparaître du
|
||||
formulaire de promotion.
|
||||
|
||||
L'écran affiche l'URL de redirection à déclarer sur le portail développeur —
|
||||
c'est l'erreur de configuration la plus fréquente.
|
||||
Le même écran porte l'**URL publique du site**, pré-remplie avec l'adresse par
|
||||
laquelle vous consultez le backoffice : elle sert à l'adresse de retour OAuth et
|
||||
au lien inséré dans les publications. Seule l'origine est conservée (le schéma
|
||||
est ajouté si vous l'omettez, un éventuel chemin est retiré).
|
||||
|
||||
L'écran affiche ensuite l'URL de redirection à déclarer sur le portail
|
||||
développeur — c'est l'erreur de configuration la plus fréquente.
|
||||
|
||||
**Facebook.** Créez une application « Business » sur
|
||||
[developers.facebook.com](https://developers.facebook.com/apps), ajoutez le
|
||||
@@ -171,8 +176,8 @@ Voir [`.env.example`](./.env.example). Les principales :
|
||||
- `AUTH_URL` — URL publique de l'application
|
||||
- `SEED_ON_START` — `true` pour seeder au démarrage du conteneur
|
||||
- `SEED_ADMIN_EMAIL` / `SEED_ADMIN_PASSWORD` / `SEED_ADMIN_NAME` — premier admin
|
||||
- `NEXT_PUBLIC_SITE_URL` — URL publique : sert au lien des posts **et** à l'adresse
|
||||
de retour OAuth. Obligatoire pour connecter un réseau social.
|
||||
- `NEXT_PUBLIC_SITE_URL` — repli pour l'URL publique du site ; elle se règle
|
||||
normalement dans **Backend › Réseaux sociaux**, aucune variable n'est requise.
|
||||
- `SOCIAL_TOKEN_KEY` — clé de chiffrement des jetons réseaux (défaut : `AUTH_SECRET`)
|
||||
- `FACEBOOK_PAGE_ID` / `FACEBOOK_PAGE_ACCESS_TOKEN`, `LINKEDIN_ORGANIZATION_URN` /
|
||||
`LINKEDIN_ACCESS_TOKEN` — repli si aucun compte n'est connecté via le backoffice
|
||||
|
||||
@@ -34,10 +34,11 @@ export async function GET(
|
||||
return back(request, "Réseau inconnu.");
|
||||
}
|
||||
|
||||
if (!siteUrl()) {
|
||||
const base = await siteUrl();
|
||||
if (!base) {
|
||||
return back(
|
||||
request,
|
||||
"L'URL publique du site n'est pas configurée (NEXT_PUBLIC_SITE_URL) : impossible de construire l'adresse de retour."
|
||||
"Renseignez d'abord l'URL publique du site : elle sert à construire l'adresse de retour."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,11 +51,11 @@ export async function GET(
|
||||
// `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));
|
||||
const response = NextResponse.redirect(await authorizeUrl(network, account.appId, state));
|
||||
response.cookies.set(`plr_oauth_${network}`, state, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: new URL(siteUrl()).protocol === "https:",
|
||||
secure: new URL(base).protocol === "https:",
|
||||
path: "/",
|
||||
maxAge: 600,
|
||||
});
|
||||
|
||||
@@ -952,6 +952,9 @@ export async function saveSiteSettings(formData: FormData) {
|
||||
const updatedAt = new Date();
|
||||
|
||||
for (const key of Object.keys(SITE_SETTING_DEFAULTS)) {
|
||||
// Réglée sur l'écran Réseaux sociaux : ce formulaire ne la contient pas et
|
||||
// l'écraserait avec une chaîne vide.
|
||||
if (key === "site_public_url") continue;
|
||||
await db
|
||||
.insert(siteSettings)
|
||||
.values({ key, value: asString(formData, key), updatedAt })
|
||||
@@ -1133,6 +1136,45 @@ export async function setContactStatus(formData: FormData) {
|
||||
revalidatePath("/backend");
|
||||
}
|
||||
|
||||
// ---- URL publique de l'application ----
|
||||
// Sert à l'adresse de retour OAuth et au lien inséré dans les publications.
|
||||
export async function saveSitePublicUrl(formData: FormData) {
|
||||
const { role } = await requireRole();
|
||||
if (!can(role, "manageSettings")) throw new Error("Accès refusé");
|
||||
|
||||
const raw = asString(formData, "sitePublicUrl");
|
||||
let value = "";
|
||||
if (raw) {
|
||||
// Le schéma est facultatif à la saisie ; seul l'origine est conservée, un
|
||||
// chemin ou des paramètres casseraient l'adresse de retour OAuth.
|
||||
const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`;
|
||||
let parsed: URL | null = null;
|
||||
try {
|
||||
parsed = new URL(candidate);
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
if (!parsed || !["http:", "https:"].includes(parsed.protocol) || !parsed.hostname) {
|
||||
// Message rendu par le bandeau de l'écran, plutôt qu'une page d'erreur brute.
|
||||
redirect(
|
||||
`/backend/reseaux?error=${encodeURIComponent(
|
||||
"Adresse invalide : indiquez par exemple https://pleinr.example.fr"
|
||||
)}`
|
||||
);
|
||||
}
|
||||
value = parsed.origin;
|
||||
}
|
||||
|
||||
const updatedAt = new Date();
|
||||
await db
|
||||
.insert(siteSettings)
|
||||
.values({ key: "site_public_url", value, updatedAt })
|
||||
.onConflictDoUpdate({ target: siteSettings.key, set: { value, updatedAt } });
|
||||
|
||||
revalidatePath("/backend/reseaux");
|
||||
revalidatePath("/backend/parametres");
|
||||
}
|
||||
|
||||
// ---- Réseaux sociaux : identifiants d'application et connexion ----
|
||||
export async function saveSocialApp(formData: FormData) {
|
||||
const { role } = await requireRole();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { CSSProperties } from "react";
|
||||
import { auth } from "@/auth";
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
SOCIAL_NETWORKS,
|
||||
type SocialTarget,
|
||||
} from "@/lib/social-accounts";
|
||||
import { disconnectSocial, saveSocialApp, selectSocialTarget } from "../actions";
|
||||
import { disconnectSocial, saveSitePublicUrl, saveSocialApp, selectSocialTarget } from "../actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -56,7 +57,20 @@ export default async function ReseauxPage({
|
||||
const { error, connected, choose } = await searchParams;
|
||||
const accounts = await getSocialAccounts();
|
||||
const byNetwork = new Map(accounts.map((a) => [a.network, a]));
|
||||
const base = siteUrl();
|
||||
const base = await siteUrl();
|
||||
|
||||
// Adresse par laquelle l'administrateur consulte cette page : proposée par
|
||||
// défaut pour lui éviter de la recopier à la main.
|
||||
const requestHeaders = await headers();
|
||||
const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host") ?? "";
|
||||
const protocol = requestHeaders.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
|
||||
const detected = host ? `${protocol}://${host}` : "";
|
||||
|
||||
// Calculées ici : le rendu des cartes n'est pas asynchrone.
|
||||
const redirectUris = new Map<string, string>();
|
||||
for (const network of SOCIAL_NETWORKS) {
|
||||
redirectUris.set(network, await redirectUri(network));
|
||||
}
|
||||
|
||||
// Cibles à proposer quand le compte administre plusieurs pages.
|
||||
const targets = new Map<string, SocialTarget[]>();
|
||||
@@ -87,13 +101,39 @@ export default async function ReseauxPage({
|
||||
</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>
|
||||
)}
|
||||
<section style={panel}>
|
||||
<h2 className="font-display" style={{ ...title, margin: "0 0 4px" }}>
|
||||
URL publique du site
|
||||
</h2>
|
||||
<p style={{ margin: "0 0 14px", fontSize: 13.5, color: "#6c6150", lineHeight: 1.6 }}>
|
||||
Adresse à laquelle vos visiteurs accèdent au site. Elle sert à construire
|
||||
l'adresse de retour des connexions Facebook et LinkedIn, et le lien inséré dans
|
||||
les publications.
|
||||
</p>
|
||||
<form action={saveSitePublicUrl} style={{ display: "flex", gap: 10, flexWrap: "wrap", alignItems: "flex-end" }}>
|
||||
<div style={{ flex: "1 1 320px", minWidth: 0 }}>
|
||||
<label className="field-label">Adresse</label>
|
||||
<input
|
||||
name="sitePublicUrl"
|
||||
className="field"
|
||||
defaultValue={base || detected}
|
||||
placeholder="https://pleinr.example.fr"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" style={submitButton}>Enregistrer</button>
|
||||
</form>
|
||||
{!base && detected && (
|
||||
<div style={{ marginTop: 10, fontSize: 12.5, color: "#9a6638" }}>
|
||||
Adresse détectée depuis votre navigation : <strong>{detected}</strong>. Vérifiez-la puis
|
||||
enregistrez.
|
||||
</div>
|
||||
)}
|
||||
{!base && !detected && (
|
||||
<div style={{ marginTop: 10, fontSize: 12.5, color: "#a8503c" }}>
|
||||
Renseignez cette adresse avant de connecter un réseau social.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{SOCIAL_NETWORKS.map((network) => {
|
||||
const account = byNetwork.get(network);
|
||||
@@ -240,7 +280,7 @@ export default async function ReseauxPage({
|
||||
<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 —"}
|
||||
{base ? redirectUris.get(network) : "— renseignez d'abord l'URL publique du site ci-dessus —"}
|
||||
</code>
|
||||
</div>
|
||||
{help.caution && (
|
||||
|
||||
@@ -3,6 +3,9 @@ import { db } from "@/db";
|
||||
import { siteSettings } from "@/db/schema";
|
||||
|
||||
export const SITE_SETTING_DEFAULTS = {
|
||||
// URL publique de l'application : sert à l'adresse de retour OAuth des réseaux
|
||||
// sociaux et au lien inséré dans les publications.
|
||||
site_public_url: "",
|
||||
association_name: "Plein R",
|
||||
association_tagline: "Association des commerçants et entreprises du Bassin de Pompey.",
|
||||
association_intro:
|
||||
|
||||
+24
-10
@@ -1,6 +1,6 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { socialAccounts, type SocialAccount, type SocialNetwork } from "@/db/schema";
|
||||
import { siteSettings, socialAccounts, type SocialAccount, type SocialNetwork } from "@/db/schema";
|
||||
import { decryptSecret, encryptSecret } from "./crypto";
|
||||
|
||||
/**
|
||||
@@ -30,12 +30,21 @@ function env(key: string): string {
|
||||
return (process.env[key] ?? "").trim();
|
||||
}
|
||||
|
||||
export function siteUrl(): string {
|
||||
return (env("NEXT_PUBLIC_SITE_URL") || env("AUTH_URL")).replace(/\/+$/, "");
|
||||
/**
|
||||
* URL publique de l'application. Réglée dans Backend › Réseaux sociaux ; les
|
||||
* variables d'environnement restent acceptées en repli.
|
||||
*/
|
||||
export async function siteUrl(): Promise<string> {
|
||||
const [row] = await db
|
||||
.select({ value: siteSettings.value })
|
||||
.from(siteSettings)
|
||||
.where(eq(siteSettings.key, "site_public_url"));
|
||||
const stored = (row?.value ?? "").trim();
|
||||
return (stored || env("NEXT_PUBLIC_SITE_URL") || env("AUTH_URL")).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function redirectUri(network: SocialNetwork): string {
|
||||
return `${siteUrl()}/api/social/${network}/callback`;
|
||||
export async function redirectUri(network: SocialNetwork): Promise<string> {
|
||||
return `${await siteUrl()}/api/social/${network}/callback`;
|
||||
}
|
||||
|
||||
// ---- Lecture ----
|
||||
@@ -123,11 +132,16 @@ const SCOPES: Record<SocialNetwork, string> = {
|
||||
linkedin: "w_organization_social r_organization_social rw_organization_admin",
|
||||
};
|
||||
|
||||
export function authorizeUrl(network: SocialNetwork, appId: string, state: string): string {
|
||||
export async function authorizeUrl(
|
||||
network: SocialNetwork,
|
||||
appId: string,
|
||||
state: string
|
||||
): Promise<string> {
|
||||
const redirect = await redirectUri(network);
|
||||
if (network === "facebook") {
|
||||
const params = new URLSearchParams({
|
||||
client_id: appId,
|
||||
redirect_uri: redirectUri("facebook"),
|
||||
redirect_uri: redirect,
|
||||
state,
|
||||
scope: SCOPES.facebook,
|
||||
response_type: "code",
|
||||
@@ -137,7 +151,7 @@ export function authorizeUrl(network: SocialNetwork, appId: string, state: strin
|
||||
const params = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: appId,
|
||||
redirect_uri: redirectUri("linkedin"),
|
||||
redirect_uri: redirect,
|
||||
state,
|
||||
scope: SCOPES.linkedin,
|
||||
});
|
||||
@@ -176,7 +190,7 @@ async function exchangeFacebook(
|
||||
`${base}/oauth/access_token?${new URLSearchParams({
|
||||
client_id: appId,
|
||||
client_secret: appSecret,
|
||||
redirect_uri: redirectUri("facebook"),
|
||||
redirect_uri: await redirectUri("facebook"),
|
||||
code,
|
||||
})}`
|
||||
);
|
||||
@@ -232,7 +246,7 @@ async function exchangeLinkedIn(
|
||||
code,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUri("linkedin"),
|
||||
redirect_uri: await redirectUri("linkedin"),
|
||||
}),
|
||||
});
|
||||
if (!tokenRes.ok) throw new SocialAuthError(`LinkedIn (code) : ${await readError(tokenRes)}`);
|
||||
|
||||
+6
-6
@@ -39,20 +39,20 @@ export type PromoForSharing = {
|
||||
memberName: string | null;
|
||||
};
|
||||
|
||||
export function promoLink(promo: PromoForSharing): string | null {
|
||||
const base = siteUrl();
|
||||
export async function promoLink(promo: PromoForSharing): Promise<string | null> {
|
||||
const base = await siteUrl();
|
||||
if (!base) return null;
|
||||
return promo.memberId ? `${base}/adherents/${promo.memberId}` : `${base}/#promotions`;
|
||||
}
|
||||
|
||||
/** Texte du post, commun aux deux réseaux. */
|
||||
export function buildPromoMessage(promo: PromoForSharing): string {
|
||||
export async function buildPromoMessage(promo: PromoForSharing): Promise<string> {
|
||||
const lines: string[] = [];
|
||||
lines.push(promo.badge ? `${promo.badge} — ${promo.title}` : promo.title);
|
||||
if (promo.memberName) lines.push(`Chez ${promo.memberName}`);
|
||||
if (promo.text?.trim()) lines.push("", promo.text.trim());
|
||||
if (promo.validUntil?.trim()) lines.push("", promo.validUntil.trim());
|
||||
const link = promoLink(promo);
|
||||
const link = await promoLink(promo);
|
||||
if (link) lines.push("", link);
|
||||
lines.push("", "#PleinR #BassinDePompey #CommerceLocal");
|
||||
return lines.join("\n");
|
||||
@@ -133,7 +133,7 @@ async function publishToFacebook(promo: PromoForSharing, message: string): Promi
|
||||
} else {
|
||||
endpoint = `${base}/feed`;
|
||||
body.set("message", message);
|
||||
const link = promoLink(promo);
|
||||
const link = await promoLink(promo);
|
||||
if (link) body.set("link", link);
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ export async function publishPromoToNetwork(
|
||||
network: SocialNetwork,
|
||||
promo: PromoForSharing
|
||||
): Promise<PublishResult> {
|
||||
const message = buildPromoMessage(promo);
|
||||
const message = await buildPromoMessage(promo);
|
||||
return network === "facebook"
|
||||
? publishToFacebook(promo, message)
|
||||
: publishToLinkedIn(promo, message);
|
||||
|
||||
Reference in new issue
Block a user