Adds accounting-service PATCH/archive, fiscal helpers, COA templates and setup status, plus SuperApp Accounting UI (DS, scoreboard, masters, vouchers, ledger, ops modules) with session refresh and HTTPS public API URLs. Co-authored-by: Cursor <cursoragent@cursor.com>
331 lines
11 KiB
TypeScript
331 lines
11 KiB
TypeScript
/**
|
|
* مدیریت SSO — ارتباط با Identity Service و Keycloak.
|
|
* توکنها روی دامنهٔ پایه (.torbatyar.ir) در cookie ذخیره میشوند
|
|
* تا بین apex و سابدامین tenant مشترک باشند؛ روی localhost از sessionStorage.
|
|
*
|
|
* Access token کوتاهعمر است؛ refresh_token نشست را تمدید میکند.
|
|
*/
|
|
|
|
const IDENTITY_API =
|
|
process.env.NEXT_PUBLIC_IDENTITY_API_URL || "http://localhost:8001";
|
|
|
|
const TOKEN_KEY = "superapp_access_token";
|
|
const REFRESH_KEY = "superapp_refresh_token";
|
|
const EXPIRES_AT_KEY = "superapp_access_expires_at";
|
|
const POST_LOGIN_REDIRECT_KEY = "superapp_post_login_redirect";
|
|
/** Cookie lifetime for access token storage (refresh keeps JWT valid). */
|
|
const ACCESS_COOKIE_MAX_AGE = 60 * 60 * 12; // 12h
|
|
const REFRESH_COOKIE_MAX_AGE = 60 * 60 * 24 * 14; // 14d
|
|
const REFRESH_SKEW_MS = 60_000; // refresh 60s before exp
|
|
|
|
export interface AuthConfig {
|
|
issuer: string;
|
|
client_id: string;
|
|
authorization_endpoint: string;
|
|
token_endpoint: string;
|
|
logout_endpoint: string;
|
|
callback_url: string;
|
|
}
|
|
|
|
export interface LoginUrlResponse {
|
|
authorization_url: string;
|
|
state: string;
|
|
}
|
|
|
|
export interface TokenBundle {
|
|
access_token: string;
|
|
refresh_token?: string;
|
|
expires_in: number;
|
|
token_type: string;
|
|
}
|
|
|
|
function cookieDomain(): string | null {
|
|
if (typeof window === "undefined") return null;
|
|
const host = window.location.hostname;
|
|
if (host === "localhost" || host === "127.0.0.1") return null;
|
|
const base = process.env.NEXT_PUBLIC_PLATFORM_BASE_DOMAIN || "torbatyar.ir";
|
|
if (host === base || host.endsWith(`.${base}`)) return `.${base}`;
|
|
return null;
|
|
}
|
|
|
|
function setCookie(name: string, value: string, maxAge: number): void {
|
|
if (typeof document === "undefined") return;
|
|
const domain = cookieDomain();
|
|
const parts = [
|
|
`${encodeURIComponent(name)}=${encodeURIComponent(value)}`,
|
|
"Path=/",
|
|
`Max-Age=${maxAge}`,
|
|
"SameSite=Lax",
|
|
];
|
|
if (domain) {
|
|
parts.push(`Domain=${domain}`);
|
|
if (window.location.protocol === "https:") parts.push("Secure");
|
|
}
|
|
document.cookie = parts.join("; ");
|
|
}
|
|
|
|
function getCookie(name: string): string | null {
|
|
if (typeof document === "undefined") return null;
|
|
const prefix = `${encodeURIComponent(name)}=`;
|
|
for (const part of document.cookie.split(";")) {
|
|
const item = part.trim();
|
|
if (item.startsWith(prefix)) {
|
|
return decodeURIComponent(item.slice(prefix.length));
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function deleteCookie(name: string): void {
|
|
if (typeof document === "undefined") return;
|
|
const domain = cookieDomain();
|
|
const base = `${encodeURIComponent(name)}=; Path=/; Max-Age=0`;
|
|
document.cookie = base;
|
|
if (domain) {
|
|
document.cookie = `${base}; Domain=${domain}`;
|
|
}
|
|
}
|
|
|
|
function readJwtExpMs(token: string): number | null {
|
|
try {
|
|
const payload = token.split(".")[1];
|
|
if (!payload) return null;
|
|
const json = JSON.parse(
|
|
atob(payload.replace(/-/g, "+").replace(/_/g, "/"))
|
|
) as { exp?: number };
|
|
return typeof json.exp === "number" ? json.exp * 1000 : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function getStoredToken(): string | null {
|
|
if (typeof window === "undefined") return null;
|
|
return getCookie(TOKEN_KEY) || sessionStorage.getItem(TOKEN_KEY);
|
|
}
|
|
|
|
export function getStoredRefreshToken(): string | null {
|
|
if (typeof window === "undefined") return null;
|
|
return getCookie(REFRESH_KEY) || sessionStorage.getItem(REFRESH_KEY);
|
|
}
|
|
|
|
export function storeTokens(tokens: TokenBundle): void {
|
|
const expiresAt =
|
|
Date.now() + Math.max(30, tokens.expires_in || 300) * 1000;
|
|
sessionStorage.setItem(TOKEN_KEY, tokens.access_token);
|
|
sessionStorage.setItem(EXPIRES_AT_KEY, String(expiresAt));
|
|
// Cookie must outlive JWT so we can refresh after access token expires.
|
|
setCookie(TOKEN_KEY, tokens.access_token, ACCESS_COOKIE_MAX_AGE);
|
|
setCookie(EXPIRES_AT_KEY, String(expiresAt), ACCESS_COOKIE_MAX_AGE);
|
|
if (tokens.refresh_token) {
|
|
sessionStorage.setItem(REFRESH_KEY, tokens.refresh_token);
|
|
setCookie(REFRESH_KEY, tokens.refresh_token, REFRESH_COOKIE_MAX_AGE);
|
|
}
|
|
}
|
|
|
|
export function clearTokens(): void {
|
|
sessionStorage.removeItem(TOKEN_KEY);
|
|
sessionStorage.removeItem(REFRESH_KEY);
|
|
sessionStorage.removeItem(EXPIRES_AT_KEY);
|
|
deleteCookie(TOKEN_KEY);
|
|
deleteCookie(REFRESH_KEY);
|
|
deleteCookie(EXPIRES_AT_KEY);
|
|
}
|
|
|
|
let refreshInFlight: Promise<string | null> | null = null;
|
|
|
|
function accessExpiresAt(): number | null {
|
|
const raw = getCookie(EXPIRES_AT_KEY) || sessionStorage.getItem(EXPIRES_AT_KEY);
|
|
if (raw) {
|
|
const n = Number(raw);
|
|
if (!Number.isNaN(n)) return n;
|
|
}
|
|
const token = getStoredToken();
|
|
return token ? readJwtExpMs(token) : null;
|
|
}
|
|
|
|
function needsRefresh(token: string | null): boolean {
|
|
if (!token) return true;
|
|
const exp = accessExpiresAt() ?? readJwtExpMs(token);
|
|
if (!exp) return false;
|
|
return Date.now() >= exp - REFRESH_SKEW_MS;
|
|
}
|
|
|
|
/** Refresh access token via Identity BFF. Returns new access token or null. */
|
|
export async function refreshSession(): Promise<string | null> {
|
|
const refresh = getStoredRefreshToken();
|
|
if (!refresh) return null;
|
|
if (refreshInFlight) return refreshInFlight;
|
|
|
|
refreshInFlight = (async () => {
|
|
try {
|
|
const res = await fetch(`${IDENTITY_API}/api/v1/auth/refresh`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ refresh_token: refresh }),
|
|
});
|
|
if (!res.ok) return null;
|
|
const bundle = (await res.json()) as TokenBundle;
|
|
storeTokens(bundle);
|
|
return bundle.access_token;
|
|
} catch {
|
|
return null;
|
|
} finally {
|
|
refreshInFlight = null;
|
|
}
|
|
})();
|
|
|
|
return refreshInFlight;
|
|
}
|
|
|
|
/**
|
|
* Return a usable access token, refreshing when near/after expiry.
|
|
* Does not clear tokens on network errors.
|
|
*/
|
|
export async function getValidAccessToken(): Promise<string | null> {
|
|
const token = getStoredToken();
|
|
if (token && !needsRefresh(token)) return token;
|
|
if (!getStoredRefreshToken()) return token;
|
|
const refreshed = await refreshSession();
|
|
return refreshed || getStoredToken();
|
|
}
|
|
|
|
export async function fetchAuthConfig(): Promise<AuthConfig> {
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(`${IDENTITY_API}/api/v1/auth/config`);
|
|
} catch {
|
|
throw new Error(
|
|
`اتصال به سرویس احراز هویت برقرار نشد (${IDENTITY_API}). مطمئن شوید identity-access-service در حال اجراست.`
|
|
);
|
|
}
|
|
if (!res.ok) throw new Error("دریافت پیکربندی SSO ناموفق بود");
|
|
return res.json();
|
|
}
|
|
|
|
/** ذخیره مسیر/URL بازگشت بعد از لاگین (cookie مشترک بین سابدامینها). */
|
|
export function setPostLoginRedirect(pathOrUrl: string): void {
|
|
if (typeof window === "undefined") return;
|
|
sessionStorage.setItem(POST_LOGIN_REDIRECT_KEY, pathOrUrl);
|
|
setCookie(POST_LOGIN_REDIRECT_KEY, pathOrUrl, 60 * 30);
|
|
}
|
|
|
|
export function consumePostLoginRedirect(fallback = "/dashboard"): string {
|
|
if (typeof window === "undefined") return fallback;
|
|
const fromSession = sessionStorage.getItem(POST_LOGIN_REDIRECT_KEY);
|
|
const fromCookie = getCookie(POST_LOGIN_REDIRECT_KEY);
|
|
sessionStorage.removeItem(POST_LOGIN_REDIRECT_KEY);
|
|
deleteCookie(POST_LOGIN_REDIRECT_KEY);
|
|
return fromSession || fromCookie || fallback;
|
|
}
|
|
|
|
export async function loginWithRedirect(redirectPath?: string): Promise<void> {
|
|
const returnTo =
|
|
redirectPath ||
|
|
(typeof window !== "undefined" ? window.location.href : "/dashboard");
|
|
setPostLoginRedirect(returnTo);
|
|
const base = process.env.NEXT_PUBLIC_PLATFORM_BASE_DOMAIN || "torbatyar.ir";
|
|
const protocol = typeof window !== "undefined" ? window.location.protocol : "https:";
|
|
const apex = `${protocol}//${base}`;
|
|
const host = typeof window !== "undefined" ? window.location.hostname : "";
|
|
if (host && host !== base && host !== `www.${base}` && !host.includes("localhost")) {
|
|
window.location.href = `${apex}/login?redirect=${encodeURIComponent(returnTo)}`;
|
|
return;
|
|
}
|
|
const qs = `?redirect=${encodeURIComponent(returnTo)}`;
|
|
window.location.href = `/login${qs}`;
|
|
}
|
|
|
|
export async function fetchLoginUrl(): Promise<string> {
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(`${IDENTITY_API}/api/v1/auth/login-url`);
|
|
} catch {
|
|
throw new Error(
|
|
`اتصال به سرویس احراز هویت برقرار نشد (${IDENTITY_API}). مطمئن شوید identity-access-service در حال اجراست.`
|
|
);
|
|
}
|
|
if (!res.ok) throw new Error("ساخت آدرس ورود ناموفق بود");
|
|
const data: LoginUrlResponse = await res.json();
|
|
return data.authorization_url;
|
|
}
|
|
|
|
export async function redeemHandoff(handoffCode: string): Promise<TokenBundle> {
|
|
const res = await fetch(`${IDENTITY_API}/api/v1/auth/session/redeem`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ handoff_code: handoffCode }),
|
|
});
|
|
if (!res.ok) {
|
|
let detail = "";
|
|
try {
|
|
const body = await res.json();
|
|
detail = body?.error?.message || body?.detail || "";
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
throw new Error(detail || "تبدیل session ناموفق بود");
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
export async function exchangeCode(
|
|
code: string,
|
|
redirectUri: string,
|
|
state: string | null
|
|
): Promise<TokenBundle> {
|
|
const res = await fetch(`${IDENTITY_API}/api/v1/auth/token`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
code,
|
|
redirect_uri: redirectUri,
|
|
state,
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
let detail = "";
|
|
try {
|
|
const body = await res.json();
|
|
detail = body?.error?.message || body?.detail || "";
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
throw new Error(
|
|
detail ? `تبدیل code به token ناموفق بود: ${detail}` : "تبدیل code به token ناموفق بود"
|
|
);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
export async function fetchMe(token: string) {
|
|
const res = await fetch(`${IDENTITY_API}/api/v1/auth/me`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
if (!res.ok) throw new Error("دریافت اطلاعات کاربر ناموفق بود");
|
|
return res.json() as Promise<{
|
|
user_id: string;
|
|
email: string | null;
|
|
username: string | null;
|
|
mobile: string | null;
|
|
mobile_verified: boolean;
|
|
requires_mobile_verification: boolean;
|
|
roles: string[];
|
|
}>;
|
|
}
|
|
|
|
export async function logout(config: AuthConfig): Promise<void> {
|
|
clearTokens();
|
|
const base = process.env.NEXT_PUBLIC_PLATFORM_BASE_DOMAIN || "torbatyar.ir";
|
|
const protocol = window.location.protocol;
|
|
const postLogout =
|
|
window.location.hostname.endsWith(base) || window.location.hostname === base
|
|
? `${protocol}//${base}`
|
|
: window.location.origin;
|
|
const params = new URLSearchParams({
|
|
client_id: config.client_id,
|
|
post_logout_redirect_uri: postLogout,
|
|
});
|
|
window.location.href = `${config.logout_endpoint}?${params.toString()}`;
|
|
}
|