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,19 @@
-- =====================================================================
-- IDENTITY SCHEMA — Part 22: external OAuth provider config (Google, …)
-- Admin-editable client credentials for social login. Read by identity at
-- login time; secrets never leave the server (masked in the admin API).
-- =====================================================================
SET search_path TO identity, public;
CREATE TABLE IF NOT EXISTS oauth_config (
provider TEXT PRIMARY KEY, -- 'google' (extensible: 'github', …)
client_id TEXT,
client_secret TEXT,
redirect_uri TEXT, -- must match the provider console
enabled BOOLEAN NOT NULL DEFAULT FALSE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO oauth_config (provider) VALUES ('google')
ON CONFLICT (provider) DO NOTHING;
+5 -2
View File
@@ -332,7 +332,8 @@
"music": "Music", "music": "Music",
"homeEvents": "Home Events", "homeEvents": "Home Events",
"comments": "Comments", "comments": "Comments",
"routes": "Internal Routes" "routes": "Internal Routes",
"integrations": "Integrations"
}, },
"appAdminNodesPage": { "appAdminNodesPage": {
"title": "Render Nodes", "title": "Render Nodes",
@@ -444,7 +445,9 @@
"passwordLabel": "Password", "passwordLabel": "Password",
"forgotPassword": "Forgot password?", "forgotPassword": "Forgot password?",
"createAccount": "Create Account", "createAccount": "Create Account",
"legalNotice": "By continuing, you agree to our <terms>Terms</terms> and <privacy>Privacy Policy</privacy>." "legalNotice": "By continuing, you agree to our <terms>Terms</terms> and <privacy>Privacy Policy</privacy>.",
"orContinueWith": "or continue with",
"continueWithGoogle": "Continue with Google"
}, },
"componentsAuthSupabaseSetupNotice": { "componentsAuthSupabaseSetupNotice": {
"title": "Supabase not configured", "title": "Supabase not configured",
+5 -2
View File
@@ -332,7 +332,8 @@
"music": "موسیقی", "music": "موسیقی",
"homeEvents": "رویدادهای صفحه اصلی", "homeEvents": "رویدادهای صفحه اصلی",
"comments": "نظرات", "comments": "نظرات",
"routes": "مسیرهای داخلی" "routes": "مسیرهای داخلی",
"integrations": "یکپارچه‌سازی‌ها"
}, },
"appAdminNodesPage": { "appAdminNodesPage": {
"title": "نودهای رندر", "title": "نودهای رندر",
@@ -444,7 +445,9 @@
"passwordLabel": "رمز عبور", "passwordLabel": "رمز عبور",
"forgotPassword": "رمز عبور را فراموش کرده‌اید؟", "forgotPassword": "رمز عبور را فراموش کرده‌اید؟",
"createAccount": "ساخت حساب", "createAccount": "ساخت حساب",
"legalNotice": "با ادامه دادن، با <terms>قوانین</terms> و <privacy>سیاست حفظ حریم خصوصی</privacy> ما موافقت می‌کنید." "legalNotice": "با ادامه دادن، با <terms>قوانین</terms> و <privacy>سیاست حفظ حریم خصوصی</privacy> ما موافقت می‌کنید.",
"orContinueWith": "یا ادامه با",
"continueWithGoogle": "ورود با گوگل"
}, },
"componentsAuthSupabaseSetupNotice": { "componentsAuthSupabaseSetupNotice": {
"title": "Supabase پیکربندی نشده است", "title": "Supabase پیکربندی نشده است",
@@ -415,6 +415,10 @@ public class AuthService(
return new AuthTokensResponse(accessToken, refreshToken, "Bearer", 15 * 60, userResponse, tenantResponse); return new AuthTokensResponse(accessToken, refreshToken, "Bearer", 15 * 60, userResponse, tenantResponse);
} }
/// <summary>Issue a session for an externally-authenticated (OAuth/social) user.</summary>
public Task<AuthTokensResponse> IssueOAuthSessionAsync(User user, Tenant tenant, string? deviceName, string? ipAddress)
=> CreateSessionAsync(user, tenant, null, deviceName, ipAddress);
private async Task CreateConfirmationTokenAsync( private async Task CreateConfirmationTokenAsync(
Guid userId, Guid tenantId, TokenPurpose purpose, string identifier, string? ipAddress) Guid userId, Guid tenantId, TokenPurpose purpose, string identifier, string? ipAddress)
{ {
@@ -0,0 +1,142 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using FlatRender.IdentitySvc.Domain.Entities;
using FlatRender.IdentitySvc.Domain.Enums;
using FlatRender.IdentitySvc.Infrastructure.Data;
using FlatRender.IdentitySvc.Models.Responses;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.EntityFrameworkCore;
namespace FlatRender.IdentitySvc.Application.Services;
/// <summary>External OAuth (Google) — admin config + the authorization-code login flow.</summary>
public class OAuthService(
IdentityDbContext db,
AuthService authService,
IHttpClientFactory httpFactory,
IConfiguration config)
{
private const string GoogleAuthEndpoint = "https://accounts.google.com/o/oauth2/v2/auth";
private const string GoogleTokenEndpoint = "https://oauth2.googleapis.com/token";
private const string GoogleUserInfo = "https://www.googleapis.com/oauth2/v3/userinfo";
private string DefaultTenantSlug => config["DefaultTenantSlug"] ?? "flatrender";
// ── Config ───────────────────────────────────────────────────────────────
public async Task<OAuthConfig?> GetConfigAsync(string provider) =>
await db.OAuthConfigs.FindAsync(provider);
public async Task<OAuthConfig> UpsertConfigAsync(string provider, string? clientId, string? clientSecret, string? redirectUri, bool enabled)
{
var cfg = await db.OAuthConfigs.FindAsync(provider);
if (cfg == null)
{
cfg = new OAuthConfig { Provider = provider };
db.OAuthConfigs.Add(cfg);
}
if (clientId != null) cfg.ClientId = clientId;
// Keep the existing secret if the client sent it blank/masked.
if (!string.IsNullOrEmpty(clientSecret) && !clientSecret.Contains('•')) cfg.ClientSecret = clientSecret;
if (redirectUri != null) cfg.RedirectUri = redirectUri;
cfg.Enabled = enabled;
cfg.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return cfg;
}
// ── Google authorization-code flow ─────────────────────────────────────────
public async Task<string> BuildGoogleAuthUrlAsync(string returnUrl)
{
var cfg = await GetConfigAsync("google");
if (cfg is not { Enabled: true } || string.IsNullOrEmpty(cfg.ClientId) || string.IsNullOrEmpty(cfg.RedirectUri))
throw new InvalidOperationException("Google login is not configured");
var state = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(returnUrl));
var query = new Dictionary<string, string?>
{
["client_id"] = cfg.ClientId,
["redirect_uri"] = cfg.RedirectUri,
["response_type"] = "code",
["scope"] = "openid email profile",
["state"] = state,
["access_type"] = "online",
["prompt"] = "select_account",
};
return QueryHelpers.AddQueryString(GoogleAuthEndpoint, query);
}
public record GoogleResult(AuthTokensResponse Tokens, string ReturnUrl);
public async Task<GoogleResult> HandleGoogleCallbackAsync(string code, string state, string? ipAddress)
{
var returnUrl = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(state));
var cfg = await GetConfigAsync("google")
?? throw new InvalidOperationException("Google login is not configured");
var http = httpFactory.CreateClient();
// 1) Exchange the authorization code for tokens.
var tokenResp = await http.PostAsync(GoogleTokenEndpoint, new FormUrlEncodedContent(new Dictionary<string, string>
{
["code"] = code,
["client_id"] = cfg.ClientId ?? "",
["client_secret"] = cfg.ClientSecret ?? "",
["redirect_uri"] = cfg.RedirectUri ?? "",
["grant_type"] = "authorization_code",
}));
var tokenBody = await tokenResp.Content.ReadAsStringAsync();
if (!tokenResp.IsSuccessStatusCode)
throw new InvalidOperationException($"Google token exchange failed: {tokenBody}");
using var tokenDoc = JsonDocument.Parse(tokenBody);
var accessToken = tokenDoc.RootElement.GetProperty("access_token").GetString();
// 2) Fetch the user's profile.
var infoReq = new HttpRequestMessage(HttpMethod.Get, GoogleUserInfo);
infoReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var infoResp = await http.SendAsync(infoReq);
var infoBody = await infoResp.Content.ReadAsStringAsync();
if (!infoResp.IsSuccessStatusCode)
throw new InvalidOperationException($"Google userinfo failed: {infoBody}");
using var infoDoc = JsonDocument.Parse(infoBody);
var root = infoDoc.RootElement;
var email = root.TryGetProperty("email", out var e) ? e.GetString() : null;
var name = root.TryGetProperty("name", out var n) ? n.GetString() : null;
var picture = root.TryGetProperty("picture", out var p) ? p.GetString() : null;
if (string.IsNullOrEmpty(email))
throw new InvalidOperationException("Google account has no email");
email = email.ToLowerInvariant();
// 3) Find or create the user in the default tenant.
var tenant = await db.Tenants.FirstOrDefaultAsync(t => t.Slug == DefaultTenantSlug && t.DeletedAt == null)
?? await db.Tenants.FirstOrDefaultAsync(t => t.DeletedAt == null)
?? throw new InvalidOperationException("No tenant configured");
var user = await db.Users.FirstOrDefaultAsync(u => u.TenantId == tenant.Id && u.Email == email && u.DeletedAt == null);
if (user == null)
{
user = new User
{
TenantId = tenant.Id,
Email = email,
FullName = name,
AvatarUrl = picture,
EmailVerified = true,
RegisterMode = RegisterMode.Google,
RegisterDate = DateTime.UtcNow,
};
db.Users.Add(user);
await db.SaveChangesAsync();
}
else if (!user.EmailVerified)
{
user.EmailVerified = true;
await db.SaveChangesAsync();
}
var tokens = await authService.IssueOAuthSessionAsync(user, tenant, "Google", ipAddress);
return new GoogleResult(tokens, returnUrl);
}
}
@@ -26,6 +26,22 @@ public class AdminController(AdminService svc) : ControllerBase
[HttpGet("v1/admin/plan-statistics")] [HttpGet("v1/admin/plan-statistics")]
public async Task<IActionResult> PlanStats() => Ok(await svc.GetPlanStatisticsAsync(TenantId)); public async Task<IActionResult> PlanStats() => Ok(await svc.GetPlanStatisticsAsync(TenantId));
// ── OAuth provider config ──────────────────────────────────────────────────
[HttpGet("v1/admin/oauth/{provider}")]
public async Task<IActionResult> GetOAuth(string provider, [FromServices] OAuthService oauth)
{
var c = await oauth.GetConfigAsync(provider);
return Ok(new OAuthConfigResponse(provider, c?.ClientId, c?.RedirectUri, c?.Enabled ?? false,
!string.IsNullOrEmpty(c?.ClientSecret)));
}
[HttpPut("v1/admin/oauth/{provider}")]
public async Task<IActionResult> PutOAuth(string provider, [FromBody] UpsertOAuthConfigRequest req, [FromServices] OAuthService oauth)
{
var c = await oauth.UpsertConfigAsync(provider, req.ClientId, req.ClientSecret, req.RedirectUri, req.Enabled);
return Ok(new OAuthConfigResponse(provider, c.ClientId, c.RedirectUri, c.Enabled, !string.IsNullOrEmpty(c.ClientSecret)));
}
// ── CRM notes / tags ─────────────────────────────────────────────────────── // ── CRM notes / tags ───────────────────────────────────────────────────────
[HttpGet("v1/users/{userId:guid}/crm")] [HttpGet("v1/users/{userId:guid}/crm")]
public async Task<IActionResult> GetCrm(Guid userId) => Ok(await svc.GetUserCrmAsync(userId)); public async Task<IActionResult> GetCrm(Guid userId) => Ok(await svc.GetUserCrmAsync(userId));
@@ -9,7 +9,7 @@ namespace FlatRender.IdentitySvc.Controllers;
[ApiController] [ApiController]
[Route("v1/auth")] [Route("v1/auth")]
public class AuthController(IAuthService authService) : ControllerBase public class AuthController(IAuthService authService, OAuthService oauthService) : ControllerBase
{ {
[HttpPost("register")] [HttpPost("register")]
[AllowAnonymous] [AllowAnonymous]
@@ -20,6 +20,43 @@ public class AuthController(IAuthService authService) : ControllerBase
return StatusCode(201, result); return StatusCode(201, result);
} }
// ── Google OAuth ────────────────────────────────────────────────────────────
[HttpGet("google/start")]
[AllowAnonymous]
public async Task<IActionResult> GoogleStart([FromQuery] string return_url)
{
try
{
var url = await oauthService.BuildGoogleAuthUrlAsync(return_url ?? "");
return Redirect(url);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = new { message = ex.Message } });
}
}
[HttpGet("google/callback")]
[AllowAnonymous]
public async Task<IActionResult> GoogleCallback([FromQuery] string? code, [FromQuery] string? state, [FromQuery] string? error)
{
if (!string.IsNullOrEmpty(error) || string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
return BadRequest(new { error = new { message = error ?? "Missing authorization code" } });
try
{
var result = await oauthService.HandleGoogleCallbackAsync(code, state, GetClientIp());
// Hand tokens back to the frontend via URL fragment (not sent to servers/logs).
var sep = result.ReturnUrl.Contains('#') ? "&" : "#";
var redirect = $"{result.ReturnUrl}{sep}access_token={Uri.EscapeDataString(result.Tokens.AccessToken)}" +
$"&refresh_token={Uri.EscapeDataString(result.Tokens.RefreshToken)}&expires_in={result.Tokens.ExpiresIn}";
return Redirect(redirect);
}
catch (Exception ex)
{
return BadRequest(new { error = new { message = ex.Message } });
}
}
[HttpPost("login")] [HttpPost("login")]
[AllowAnonymous] [AllowAnonymous]
[ProducesResponseType(typeof(AuthTokensResponse), 200)] [ProducesResponseType(typeof(AuthTokensResponse), 200)]
@@ -0,0 +1,12 @@
namespace FlatRender.IdentitySvc.Domain.Entities;
/// <summary>Admin-editable external OAuth provider credentials (Google, …).</summary>
public class OAuthConfig
{
public string Provider { get; set; } = default!; // "google"
public string? ClientId { get; set; }
public string? ClientSecret { get; set; }
public string? RedirectUri { get; set; }
public bool Enabled { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
@@ -31,6 +31,9 @@ public class IdentityDbContext(DbContextOptions<IdentityDbContext> options) : Db
// CRM // CRM
public DbSet<UserCrm> UserCrms => Set<UserCrm>(); public DbSet<UserCrm> UserCrms => Set<UserCrm>();
// OAuth providers
public DbSet<OAuthConfig> OAuthConfigs => Set<OAuthConfig>();
// Gamification // Gamification
public DbSet<Quest> Quests => Set<Quest>(); public DbSet<Quest> Quests => Set<Quest>();
public DbSet<UserQuestProgress> UserQuestProgresses => Set<UserQuestProgress>(); public DbSet<UserQuestProgress> UserQuestProgresses => Set<UserQuestProgress>();
@@ -56,6 +59,12 @@ public class IdentityDbContext(DbContextOptions<IdentityDbContext> options) : Db
e.ToTable("user_crm"); e.ToTable("user_crm");
e.HasKey(x => x.UserId); e.HasKey(x => x.UserId);
}); });
mb.Entity<OAuthConfig>(e =>
{
e.ToTable("oauth_config");
e.HasKey(x => x.Provider);
});
} }
private static void ConfigureTenants(ModelBuilder mb) private static void ConfigureTenants(ModelBuilder mb)
@@ -14,6 +14,12 @@ public record CrmAnalyticsResponse(
List<CrmDailyPoint> Daily List<CrmDailyPoint> Daily
); );
// ── OAuth provider config ─────────────────────────────────────────────────────
public record OAuthConfigResponse(string Provider, string? ClientId, string? RedirectUri, bool Enabled, bool HasSecret);
public record UpsertOAuthConfigRequest(string? ClientId, string? ClientSecret, string? RedirectUri, bool Enabled);
// ── Plan statistics breakdown ──────────────────────────────────────────────── // ── Plan statistics breakdown ────────────────────────────────────────────────
public record PlanStatRow(string PlanName, int Total, int Active, long RevenueMinor); public record PlanStatRow(string PlanName, int Total, int Active, long RevenueMinor);
@@ -71,7 +71,8 @@ builder.Services.AddAuthorization();
// ── Services ────────────────────────────────────────────────────────────── // ── Services ──────────────────────────────────────────────────────────────
builder.Services.AddScoped<ITokenService, TokenService>(); builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<IAuthService, AuthService>(); builder.Services.AddScoped<AuthService>();
builder.Services.AddScoped<IAuthService>(sp => sp.GetRequiredService<AuthService>());
builder.Services.AddScoped<IUserService, UserService>(); builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<ITenantService, TenantService>(); builder.Services.AddScoped<ITenantService, TenantService>();
builder.Services.AddScoped<IPlanService, PlanService>(); builder.Services.AddScoped<IPlanService, PlanService>();
@@ -79,6 +80,8 @@ builder.Services.AddScoped<IDiscountService, DiscountService>();
builder.Services.AddScoped<IGamificationService, GamificationService>(); builder.Services.AddScoped<IGamificationService, GamificationService>();
builder.Services.AddScoped<IPaymentService, PaymentService>(); builder.Services.AddScoped<IPaymentService, PaymentService>();
builder.Services.AddScoped<AdminService>(); builder.Services.AddScoped<AdminService>();
builder.Services.AddScoped<OAuthService>();
builder.Services.AddHttpClient();
// ── HTTP clients ─────────────────────────────────────────────────────────── // ── HTTP clients ───────────────────────────────────────────────────────────
builder.Services.AddHttpClient("zarinpal", client => builder.Services.AddHttpClient("zarinpal", client =>
@@ -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/files", label: t("media") },
{ href: "/admin/ai", label: t("aiContent") }, { href: "/admin/ai", label: t("aiContent") },
{ href: "/admin/messaging", label: t("messaging") }, { href: "/admin/messaging", label: t("messaging") },
{ href: "/admin/integrations", label: t("integrations") },
{ href: "/admin/marketing", label: t("marketing") }, { href: "/admin/marketing", label: t("marketing") },
{ href: "/admin/crm", label: t("crm") }, { href: "/admin/crm", label: t("crm") },
{ href: "/admin/users", label: t("users") }, { 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(""); 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 ────────────────────────────────────────── // ── Main sign-in / sign-up submit ──────────────────────────────────────────
const onSubmit = async (values: AuthFormValues) => { const onSubmit = async (values: AuthFormValues) => {
setSubmitting(true); setSubmitting(true);
@@ -376,6 +383,25 @@ export function AuthPageContent() {
{activeTab === "sign-in" ? t("signInTab") : t("createAccount")} {activeTab === "sign-in" ? t("signInTab") : t("createAccount")}
</Button> </Button>
</form> </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> </div>
<p className="mt-6 text-center text-xs text-neutral-500"> <p className="mt-6 text-center text-xs text-neutral-500">