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