feat(auth+admin): Sign in with Google (OAuth) + Integrations config panel
Build backend images / build content-svc (push) Failing after 1m2s
Build backend images / build file-svc (push) Failing after 3m11s
Build backend images / build gateway (push) Failing after 5m39s
Build backend images / build identity-svc (push) Failing after 38s
Build backend images / build notification-svc (push) Failing after 2m0s
Build backend images / build render-svc (push) Failing after 58s
Build backend images / build studio-svc (push) Failing after 58s

Backend (identity-svc):
- oauth_config table (mig 22) + OAuthConfig entity
- OAuthService: admin config CRUD + Google authorization-code flow (build consent
  URL, exchange code, fetch userinfo, find/create RegisterMode.Google user, issue
  session via AuthService.IssueOAuthSessionAsync)
- AuthController: GET /v1/auth/google/{start,callback} (public); tokens handed to
  frontend via URL fragment
- AdminController: GET/PUT /v1/admin/oauth/{provider} (admin, secret masked)

Frontend:
- "ورود با گوگل" button on /auth → identity start endpoint
- /auth/callback reads fragment tokens → /api/auth/oauth-session sets httpOnly cookies
- /admin/integrations: Google client_id/secret/redirect_uri + enable, with setup guide
- nav + fa/en labels

Client ID/Secret are configured entirely in the admin panel — no redeploy needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-03 00:08:21 +03:30
parent 88a44b1349
commit 675b60d858
17 changed files with 469 additions and 6 deletions
@@ -0,0 +1,7 @@
"use client";
import { IntegrationsAdmin } from "@/components/admin/IntegrationsAdmin";
export default function Page() {
return <IntegrationsAdmin />;
}
+1
View File
@@ -31,6 +31,7 @@ export default async function AdminLayout({
{ href: "/admin/files", label: t("media") },
{ href: "/admin/ai", label: t("aiContent") },
{ href: "/admin/messaging", label: t("messaging") },
{ href: "/admin/integrations", label: t("integrations") },
{ href: "/admin/marketing", label: t("marketing") },
{ href: "/admin/crm", label: t("crm") },
{ href: "/admin/users", label: t("users") },
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
/**
* OAuth landing page. Identity redirects here with tokens in the URL fragment
* (#access_token=…&refresh_token=…&expires_in=…). We hand them to a server route
* that sets httpOnly session cookies, then continue to the app.
*/
export default function OAuthCallbackPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const hash = typeof window !== "undefined" ? window.location.hash.replace(/^#/, "") : "";
const params = new URLSearchParams(hash);
const accessToken = params.get("access_token");
const refreshToken = params.get("refresh_token");
const expiresIn = Number(params.get("expires_in") ?? "900");
if (!accessToken || !refreshToken) {
setError("ورود ناموفق بود. لطفاً دوباره تلاش کنید.");
return;
}
const next = searchParams.get("next") || "/dashboard";
(async () => {
try {
const res = await fetch("/api/auth/oauth-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ access_token: accessToken, refresh_token: refreshToken, expires_in: expiresIn }),
});
if (!res.ok) throw new Error("session");
// Clear the fragment from history, then continue.
window.history.replaceState(null, "", window.location.pathname);
router.replace(next.startsWith("/") ? next : "/dashboard");
router.refresh();
} catch {
setError("برقراری نشست ناموفق بود.");
}
})();
}, [router, searchParams]);
return (
<main className="flex min-h-screen items-center justify-center bg-neutral-50 text-neutral-700" dir="rtl">
<div className="text-center">
{error ? (
<>
<p className="text-sm text-red-600">{error}</p>
<a href="/auth" className="mt-3 inline-block text-sm text-primary-600 hover:underline">بازگشت به ورود</a>
</>
) : (
<p className="text-sm">در حال ورود</p>
)}
</div>
</main>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { setAuthCookies } from "@/lib/auth/cookies";
export const dynamic = "force-dynamic";
/** Receives OAuth tokens from the client callback and sets httpOnly session cookies. */
export async function POST(req: Request) {
const body = await req.json().catch(() => null);
const accessToken = body?.access_token;
const refreshToken = body?.refresh_token;
const expiresIn = Number(body?.expires_in ?? 900);
if (!accessToken || !refreshToken) {
return NextResponse.json({ error: "Missing tokens" }, { status: 400 });
}
const out = NextResponse.json({ ok: true });
return setAuthCookies(out, accessToken, refreshToken, expiresIn);
}
@@ -0,0 +1,96 @@
"use client";
import { useCallback, useEffect, useState } from "react";
const card = "rounded-xl border border-[#1e2235] bg-[#0f1120] p-5";
const btn = "rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-50";
const inp = "w-full rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500";
const lbl = "mb-1 block text-xs font-medium text-gray-400";
export function IntegrationsAdmin() {
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const [redirectUri, setRedirectUri] = useState("");
const [enabled, setEnabled] = useState(false);
const [hasSecret, setHasSecret] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// Suggested redirect URI = gateway base (…/v1) + /auth/google/callback
const suggested = ((process.env.NEXT_PUBLIC_API_URL ?? "") + "/auth/google/callback").replace(/([^:])\/\//g, "$1/");
const load = useCallback(async () => {
const r = await fetch("/api/admin/resource/admin/oauth/google", { cache: "no-store" }).then((x) => x.json()).catch(() => null);
if (r) {
setClientId(r.client_id ?? "");
setRedirectUri(r.redirect_uri ?? "");
setEnabled(!!r.enabled);
setHasSecret(!!r.has_secret);
}
}, []);
useEffect(() => { load(); }, [load]);
const save = async () => {
setSaving(true); setMsg(null);
const res = await fetch("/api/admin/resource/admin/oauth/google", {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: clientId,
client_secret: clientSecret || null, // blank keeps existing
redirect_uri: redirectUri || suggested,
enabled,
}),
});
const d = await res.json().catch(() => null);
setMsg(res.ok ? "ذخیره شد ✓" : (d?.error?.message ?? "خطا در ذخیره"));
setSaving(false);
if (res.ok) { setClientSecret(""); load(); }
};
return (
<div className="space-y-6" dir="rtl">
<div>
<h1 className="text-xl font-semibold text-white">یکپارچهسازیها</h1>
<p className="mt-1 text-sm text-gray-400">پیکربندی ورود با حسابهای خارجی. کلیدها را از کنسول مربوطه دریافت و اینجا وارد کنید.</p>
</div>
<section className={card}>
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-white">ورود با گوگل (Google OAuth)</h2>
<label className="flex items-center gap-2 text-sm text-gray-300">
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} /> فعال
</label>
</div>
<div className="mt-4 grid gap-3">
<div>
<label className={lbl}>Client ID</label>
<input className={inp} value={clientId} onChange={(e) => setClientId(e.target.value)} placeholder="xxxxxx.apps.googleusercontent.com" dir="ltr" />
</div>
<div>
<label className={lbl}>Client Secret {hasSecret && <span className="text-emerald-400">(ذخیرهشده برای حفظ خالی بگذارید)</span>}</label>
<input className={inp} type="password" value={clientSecret} onChange={(e) => setClientSecret(e.target.value)} placeholder={hasSecret ? "••••••••" : "GOCSPX-…"} dir="ltr" />
</div>
<div>
<label className={lbl}>Redirect URI (در کنسول گوگل ثبت کنید)</label>
<input className={inp} value={redirectUri} onChange={(e) => setRedirectUri(e.target.value)} placeholder={suggested} dir="ltr" />
<p className="mt-1 text-[11px] text-gray-500">مقدار پیشنهادی: <code className="text-gray-400" dir="ltr">{suggested}</code></p>
</div>
</div>
<div className="mt-4 flex items-center gap-3">
<button className={btn} onClick={save} disabled={saving || !clientId}>{saving ? "در حال ذخیره…" : "ذخیره تنظیمات"}</button>
{msg && <span className="text-xs text-gray-400">{msg}</span>}
</div>
<div className="mt-4 rounded-lg border border-[#262b40] bg-[#0c0e1a] p-3 text-xs leading-6 text-gray-400">
<p className="font-medium text-gray-300">راهنمای سریع:</p>
<p>۱) به <span dir="ltr">console.cloud.google.com</span> بروید و یک «OAuth 2.0 Client ID» از نوع Web بسازید.</p>
<p>۲) مقدار Redirect URI بالا را در «Authorized redirect URIs» ثبت کنید.</p>
<p>۳) Client ID و Secret را اینجا وارد کنید، «فعال» را بزنید و ذخیره کنید.</p>
<p>۴) دکمهٔ «ورود با گوگل» در صفحهٔ ورود ظاهر و فعال میشود.</p>
</div>
</section>
</div>
);
}
+26
View File
@@ -92,6 +92,13 @@ export function AuthPageContent() {
setResetNewPassword("");
};
// ── Google OAuth — redirect to identity's start endpoint ──────────────────
const googleSignIn = () => {
const base = process.env.NEXT_PUBLIC_API_URL ?? "";
const returnUrl = `${window.location.origin}/auth/callback?next=${encodeURIComponent(nextPath)}`;
window.location.href = `${base}/auth/google/start?return_url=${encodeURIComponent(returnUrl)}`;
};
// ── Main sign-in / sign-up submit ──────────────────────────────────────────
const onSubmit = async (values: AuthFormValues) => {
setSubmitting(true);
@@ -376,6 +383,25 @@ export function AuthPageContent() {
{activeTab === "sign-in" ? t("signInTab") : t("createAccount")}
</Button>
</form>
<div className="relative my-5 flex items-center">
<div className="flex-grow border-t border-gray-100" />
<span className="mx-3 text-xs text-neutral-400">{t("orContinueWith")}</span>
<div className="flex-grow border-t border-gray-100" />
</div>
<button
type="button"
onClick={googleSignIn}
className="flex w-full items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" aria-hidden>
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.27-4.74 3.27-8.1Z" />
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A11 11 0 0 0 12 23Z" />
<path fill="#FBBC05" d="M5.84 14.1a6.6 6.6 0 0 1 0-4.2V7.06H2.18a11 11 0 0 0 0 9.88l3.66-2.84Z" />
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1A11 11 0 0 0 2.18 7.06l3.66 2.84C6.71 7.31 9.14 5.38 12 5.38Z" />
</svg>
{t("continueWithGoogle")}
</button>
</div>
<p className="mt-6 text-center text-xs text-neutral-500">