From 675b60d8588f52c54d44c56f2a27f8dd5e51e9eb Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Wed, 3 Jun 2026 00:08:21 +0330 Subject: [PATCH] feat(auth+admin): Sign in with Google (OAuth) + Integrations config panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../migrations/22_identity_oauth_config.sql | 19 +++ messages/en.json | 7 +- messages/fa.json | 7 +- .../Application/Services/AuthService.cs | 4 + .../Application/Services/OAuthService.cs | 142 ++++++++++++++++++ .../Controllers/AdminController.cs | 16 ++ .../Controllers/AuthController.cs | 39 ++++- .../Domain/Entities/OAuthConfig.cs | 12 ++ .../Infrastructure/Data/IdentityDbContext.cs | 9 ++ .../FlatRender.IdentitySvc/Models/Admin.cs | 6 + .../FlatRender.IdentitySvc/Program.cs | 5 +- src/app/[locale]/admin/integrations/page.tsx | 7 + src/app/[locale]/admin/layout.tsx | 1 + src/app/[locale]/auth/callback/page.tsx | 61 ++++++++ src/app/api/auth/oauth-session/route.ts | 18 +++ src/components/admin/IntegrationsAdmin.tsx | 96 ++++++++++++ src/components/auth/AuthPageContent.tsx | 26 ++++ 17 files changed, 469 insertions(+), 6 deletions(-) create mode 100644 backend/db/migrations/22_identity_oauth_config.sql create mode 100644 services/identity/FlatRender.IdentitySvc/Application/Services/OAuthService.cs create mode 100644 services/identity/FlatRender.IdentitySvc/Domain/Entities/OAuthConfig.cs create mode 100644 src/app/[locale]/admin/integrations/page.tsx create mode 100644 src/app/[locale]/auth/callback/page.tsx create mode 100644 src/app/api/auth/oauth-session/route.ts create mode 100644 src/components/admin/IntegrationsAdmin.tsx diff --git a/backend/db/migrations/22_identity_oauth_config.sql b/backend/db/migrations/22_identity_oauth_config.sql new file mode 100644 index 0000000..434adc4 --- /dev/null +++ b/backend/db/migrations/22_identity_oauth_config.sql @@ -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; diff --git a/messages/en.json b/messages/en.json index 66a6585..1500cd5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -332,7 +332,8 @@ "music": "Music", "homeEvents": "Home Events", "comments": "Comments", - "routes": "Internal Routes" + "routes": "Internal Routes", + "integrations": "Integrations" }, "appAdminNodesPage": { "title": "Render Nodes", @@ -444,7 +445,9 @@ "passwordLabel": "Password", "forgotPassword": "Forgot password?", "createAccount": "Create Account", - "legalNotice": "By continuing, you agree to our Terms and Privacy Policy." + "legalNotice": "By continuing, you agree to our Terms and Privacy Policy.", + "orContinueWith": "or continue with", + "continueWithGoogle": "Continue with Google" }, "componentsAuthSupabaseSetupNotice": { "title": "Supabase not configured", diff --git a/messages/fa.json b/messages/fa.json index c221e4a..6d134ed 100644 --- a/messages/fa.json +++ b/messages/fa.json @@ -332,7 +332,8 @@ "music": "موسیقی", "homeEvents": "رویدادهای صفحه اصلی", "comments": "نظرات", - "routes": "مسیرهای داخلی" + "routes": "مسیرهای داخلی", + "integrations": "یکپارچه‌سازی‌ها" }, "appAdminNodesPage": { "title": "نودهای رندر", @@ -444,7 +445,9 @@ "passwordLabel": "رمز عبور", "forgotPassword": "رمز عبور را فراموش کرده‌اید؟", "createAccount": "ساخت حساب", - "legalNotice": "با ادامه دادن، با قوانین و سیاست حفظ حریم خصوصی ما موافقت می‌کنید." + "legalNotice": "با ادامه دادن، با قوانین و سیاست حفظ حریم خصوصی ما موافقت می‌کنید.", + "orContinueWith": "یا ادامه با", + "continueWithGoogle": "ورود با گوگل" }, "componentsAuthSupabaseSetupNotice": { "title": "Supabase پیکربندی نشده است", diff --git a/services/identity/FlatRender.IdentitySvc/Application/Services/AuthService.cs b/services/identity/FlatRender.IdentitySvc/Application/Services/AuthService.cs index 18cdbed..3f87bc1 100644 --- a/services/identity/FlatRender.IdentitySvc/Application/Services/AuthService.cs +++ b/services/identity/FlatRender.IdentitySvc/Application/Services/AuthService.cs @@ -415,6 +415,10 @@ public class AuthService( return new AuthTokensResponse(accessToken, refreshToken, "Bearer", 15 * 60, userResponse, tenantResponse); } + /// Issue a session for an externally-authenticated (OAuth/social) user. + public Task IssueOAuthSessionAsync(User user, Tenant tenant, string? deviceName, string? ipAddress) + => CreateSessionAsync(user, tenant, null, deviceName, ipAddress); + private async Task CreateConfirmationTokenAsync( Guid userId, Guid tenantId, TokenPurpose purpose, string identifier, string? ipAddress) { diff --git a/services/identity/FlatRender.IdentitySvc/Application/Services/OAuthService.cs b/services/identity/FlatRender.IdentitySvc/Application/Services/OAuthService.cs new file mode 100644 index 0000000..e251441 --- /dev/null +++ b/services/identity/FlatRender.IdentitySvc/Application/Services/OAuthService.cs @@ -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; + +/// External OAuth (Google) — admin config + the authorization-code login flow. +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 GetConfigAsync(string provider) => + await db.OAuthConfigs.FindAsync(provider); + + public async Task 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 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 + { + ["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 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 + { + ["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); + } +} diff --git a/services/identity/FlatRender.IdentitySvc/Controllers/AdminController.cs b/services/identity/FlatRender.IdentitySvc/Controllers/AdminController.cs index 1c87cbc..98117df 100644 --- a/services/identity/FlatRender.IdentitySvc/Controllers/AdminController.cs +++ b/services/identity/FlatRender.IdentitySvc/Controllers/AdminController.cs @@ -26,6 +26,22 @@ public class AdminController(AdminService svc) : ControllerBase [HttpGet("v1/admin/plan-statistics")] public async Task PlanStats() => Ok(await svc.GetPlanStatisticsAsync(TenantId)); + // ── OAuth provider config ────────────────────────────────────────────────── + [HttpGet("v1/admin/oauth/{provider}")] + public async Task 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 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 ─────────────────────────────────────────────────────── [HttpGet("v1/users/{userId:guid}/crm")] public async Task GetCrm(Guid userId) => Ok(await svc.GetUserCrmAsync(userId)); diff --git a/services/identity/FlatRender.IdentitySvc/Controllers/AuthController.cs b/services/identity/FlatRender.IdentitySvc/Controllers/AuthController.cs index e6d8dad..66774da 100644 --- a/services/identity/FlatRender.IdentitySvc/Controllers/AuthController.cs +++ b/services/identity/FlatRender.IdentitySvc/Controllers/AuthController.cs @@ -9,7 +9,7 @@ namespace FlatRender.IdentitySvc.Controllers; [ApiController] [Route("v1/auth")] -public class AuthController(IAuthService authService) : ControllerBase +public class AuthController(IAuthService authService, OAuthService oauthService) : ControllerBase { [HttpPost("register")] [AllowAnonymous] @@ -20,6 +20,43 @@ public class AuthController(IAuthService authService) : ControllerBase return StatusCode(201, result); } + // ── Google OAuth ──────────────────────────────────────────────────────────── + [HttpGet("google/start")] + [AllowAnonymous] + public async Task 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 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")] [AllowAnonymous] [ProducesResponseType(typeof(AuthTokensResponse), 200)] diff --git a/services/identity/FlatRender.IdentitySvc/Domain/Entities/OAuthConfig.cs b/services/identity/FlatRender.IdentitySvc/Domain/Entities/OAuthConfig.cs new file mode 100644 index 0000000..a90012a --- /dev/null +++ b/services/identity/FlatRender.IdentitySvc/Domain/Entities/OAuthConfig.cs @@ -0,0 +1,12 @@ +namespace FlatRender.IdentitySvc.Domain.Entities; + +/// Admin-editable external OAuth provider credentials (Google, …). +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; +} diff --git a/services/identity/FlatRender.IdentitySvc/Infrastructure/Data/IdentityDbContext.cs b/services/identity/FlatRender.IdentitySvc/Infrastructure/Data/IdentityDbContext.cs index 0665ab7..0ec5a67 100644 --- a/services/identity/FlatRender.IdentitySvc/Infrastructure/Data/IdentityDbContext.cs +++ b/services/identity/FlatRender.IdentitySvc/Infrastructure/Data/IdentityDbContext.cs @@ -31,6 +31,9 @@ public class IdentityDbContext(DbContextOptions options) : Db // CRM public DbSet UserCrms => Set(); + // OAuth providers + public DbSet OAuthConfigs => Set(); + // Gamification public DbSet Quests => Set(); public DbSet UserQuestProgresses => Set(); @@ -56,6 +59,12 @@ public class IdentityDbContext(DbContextOptions options) : Db e.ToTable("user_crm"); e.HasKey(x => x.UserId); }); + + mb.Entity(e => + { + e.ToTable("oauth_config"); + e.HasKey(x => x.Provider); + }); } private static void ConfigureTenants(ModelBuilder mb) diff --git a/services/identity/FlatRender.IdentitySvc/Models/Admin.cs b/services/identity/FlatRender.IdentitySvc/Models/Admin.cs index 57c0cc5..d5c2f7d 100644 --- a/services/identity/FlatRender.IdentitySvc/Models/Admin.cs +++ b/services/identity/FlatRender.IdentitySvc/Models/Admin.cs @@ -14,6 +14,12 @@ public record CrmAnalyticsResponse( List 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 ──────────────────────────────────────────────── public record PlanStatRow(string PlanName, int Total, int Active, long RevenueMinor); diff --git a/services/identity/FlatRender.IdentitySvc/Program.cs b/services/identity/FlatRender.IdentitySvc/Program.cs index fcb83e7..73dd0dd 100644 --- a/services/identity/FlatRender.IdentitySvc/Program.cs +++ b/services/identity/FlatRender.IdentitySvc/Program.cs @@ -71,7 +71,8 @@ builder.Services.AddAuthorization(); // ── Services ────────────────────────────────────────────────────────────── builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(sp => sp.GetRequiredService()); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -79,6 +80,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddHttpClient(); // ── HTTP clients ─────────────────────────────────────────────────────────── builder.Services.AddHttpClient("zarinpal", client => diff --git a/src/app/[locale]/admin/integrations/page.tsx b/src/app/[locale]/admin/integrations/page.tsx new file mode 100644 index 0000000..a5965e4 --- /dev/null +++ b/src/app/[locale]/admin/integrations/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { IntegrationsAdmin } from "@/components/admin/IntegrationsAdmin"; + +export default function Page() { + return ; +} diff --git a/src/app/[locale]/admin/layout.tsx b/src/app/[locale]/admin/layout.tsx index 5471afe..8dd17cf 100644 --- a/src/app/[locale]/admin/layout.tsx +++ b/src/app/[locale]/admin/layout.tsx @@ -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") }, diff --git a/src/app/[locale]/auth/callback/page.tsx b/src/app/[locale]/auth/callback/page.tsx new file mode 100644 index 0000000..e26257e --- /dev/null +++ b/src/app/[locale]/auth/callback/page.tsx @@ -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(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 ( +
+
+ {error ? ( + <> +

{error}

+ بازگشت به ورود + + ) : ( +

در حال ورود…

+ )} +
+
+ ); +} diff --git a/src/app/api/auth/oauth-session/route.ts b/src/app/api/auth/oauth-session/route.ts new file mode 100644 index 0000000..020b220 --- /dev/null +++ b/src/app/api/auth/oauth-session/route.ts @@ -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); +} diff --git a/src/components/admin/IntegrationsAdmin.tsx b/src/components/admin/IntegrationsAdmin.tsx new file mode 100644 index 0000000..90171d5 --- /dev/null +++ b/src/components/admin/IntegrationsAdmin.tsx @@ -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(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 ( +
+
+

یکپارچه‌سازی‌ها

+

پیکربندی ورود با حساب‌های خارجی. کلیدها را از کنسول مربوطه دریافت و اینجا وارد کنید.

+
+ +
+
+

ورود با گوگل (Google OAuth)

+ +
+ +
+
+ + setClientId(e.target.value)} placeholder="xxxxxx.apps.googleusercontent.com" dir="ltr" /> +
+
+ + setClientSecret(e.target.value)} placeholder={hasSecret ? "••••••••" : "GOCSPX-…"} dir="ltr" /> +
+
+ + setRedirectUri(e.target.value)} placeholder={suggested} dir="ltr" /> +

مقدار پیشنهادی: {suggested}

+
+
+ +
+ + {msg && {msg}} +
+ +
+

راهنمای سریع:

+

۱) به console.cloud.google.com بروید و یک «OAuth 2.0 Client ID» از نوع Web بسازید.

+

۲) مقدار Redirect URI بالا را در «Authorized redirect URIs» ثبت کنید.

+

۳) Client ID و Secret را اینجا وارد کنید، «فعال» را بزنید و ذخیره کنید.

+

۴) دکمهٔ «ورود با گوگل» در صفحهٔ ورود ظاهر و فعال می‌شود.

+
+
+
+ ); +} diff --git a/src/components/auth/AuthPageContent.tsx b/src/components/auth/AuthPageContent.tsx index 0aa7e65..fd8c098 100644 --- a/src/components/auth/AuthPageContent.tsx +++ b/src/components/auth/AuthPageContent.tsx @@ -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")} + +
+
+ {t("orContinueWith")} +
+
+