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
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:
@@ -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
@@ -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>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": {
|
||||
"title": "Supabase not configured",
|
||||
|
||||
+5
-2
@@ -332,7 +332,8 @@
|
||||
"music": "موسیقی",
|
||||
"homeEvents": "رویدادهای صفحه اصلی",
|
||||
"comments": "نظرات",
|
||||
"routes": "مسیرهای داخلی"
|
||||
"routes": "مسیرهای داخلی",
|
||||
"integrations": "یکپارچهسازیها"
|
||||
},
|
||||
"appAdminNodesPage": {
|
||||
"title": "نودهای رندر",
|
||||
@@ -444,7 +445,9 @@
|
||||
"passwordLabel": "رمز عبور",
|
||||
"forgotPassword": "رمز عبور را فراموش کردهاید؟",
|
||||
"createAccount": "ساخت حساب",
|
||||
"legalNotice": "با ادامه دادن، با <terms>قوانین</terms> و <privacy>سیاست حفظ حریم خصوصی</privacy> ما موافقت میکنید."
|
||||
"legalNotice": "با ادامه دادن، با <terms>قوانین</terms> و <privacy>سیاست حفظ حریم خصوصی</privacy> ما موافقت میکنید.",
|
||||
"orContinueWith": "یا ادامه با",
|
||||
"continueWithGoogle": "ورود با گوگل"
|
||||
},
|
||||
"componentsAuthSupabaseSetupNotice": {
|
||||
"title": "Supabase پیکربندی نشده است",
|
||||
|
||||
@@ -415,6 +415,10 @@ public class AuthService(
|
||||
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(
|
||||
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")]
|
||||
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 ───────────────────────────────────────────────────────
|
||||
[HttpGet("v1/users/{userId:guid}/crm")]
|
||||
public async Task<IActionResult> GetCrm(Guid userId) => Ok(await svc.GetUserCrmAsync(userId));
|
||||
|
||||
@@ -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<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")]
|
||||
[AllowAnonymous]
|
||||
[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
|
||||
public DbSet<UserCrm> UserCrms => Set<UserCrm>();
|
||||
|
||||
// OAuth providers
|
||||
public DbSet<OAuthConfig> OAuthConfigs => Set<OAuthConfig>();
|
||||
|
||||
// Gamification
|
||||
public DbSet<Quest> Quests => Set<Quest>();
|
||||
public DbSet<UserQuestProgress> UserQuestProgresses => Set<UserQuestProgress>();
|
||||
@@ -56,6 +59,12 @@ public class IdentityDbContext(DbContextOptions<IdentityDbContext> options) : Db
|
||||
e.ToTable("user_crm");
|
||||
e.HasKey(x => x.UserId);
|
||||
});
|
||||
|
||||
mb.Entity<OAuthConfig>(e =>
|
||||
{
|
||||
e.ToTable("oauth_config");
|
||||
e.HasKey(x => x.Provider);
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureTenants(ModelBuilder mb)
|
||||
|
||||
@@ -14,6 +14,12 @@ public record CrmAnalyticsResponse(
|
||||
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 ────────────────────────────────────────────────
|
||||
|
||||
public record PlanStatRow(string PlanName, int Total, int Active, long RevenueMinor);
|
||||
|
||||
@@ -71,7 +71,8 @@ builder.Services.AddAuthorization();
|
||||
|
||||
// ── Services ──────────────────────────────────────────────────────────────
|
||||
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<ITenantService, TenantService>();
|
||||
builder.Services.AddScoped<IPlanService, PlanService>();
|
||||
@@ -79,6 +80,8 @@ builder.Services.AddScoped<IDiscountService, DiscountService>();
|
||||
builder.Services.AddScoped<IGamificationService, GamificationService>();
|
||||
builder.Services.AddScoped<IPaymentService, PaymentService>();
|
||||
builder.Services.AddScoped<AdminService>();
|
||||
builder.Services.AddScoped<OAuthService>();
|
||||
builder.Services.AddHttpClient();
|
||||
|
||||
// ── HTTP clients ───────────────────────────────────────────────────────────
|
||||
builder.Services.AddHttpClient("zarinpal", client =>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { IntegrationsAdmin } from "@/components/admin/IntegrationsAdmin";
|
||||
|
||||
export default function Page() {
|
||||
return <IntegrationsAdmin />;
|
||||
}
|
||||
@@ -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") },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user