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>
);
}