Added partner app

This commit is contained in:
2026-09-28 16:13:42 +02:00
parent 242a4c6fbb
commit 7d510d9fbc
36 changed files with 5920 additions and 0 deletions
@@ -0,0 +1,250 @@
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { UserManager, WebStorageStateStore } from 'oidc-client-ts';
import { getSecuritySettings, loadRuntimeConfig } from '../config';
import { parseJwtClaims } from './jwt';
import { syncUserAccount, clearUserSyncInBrowser, isActivationPending } from '../services/userSyncService';
import { onSessionExpired, clearSessionAndRedirectToLanding } from '../services/sessionExpiration';
export interface AuthUserProfile {
id: string;
sub: string;
name: string;
email: string;
role: string;
tenant: string;
tenant_id: number | string;
}
interface AuthContextType {
user: AuthUserProfile | null;
sub: string;
claims: Record<string, any>;
accessToken: string | null;
isAuthenticated: boolean;
isLoading: boolean;
isPendingActivation: boolean;
login: () => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
/**
* Derives the user profile strictly from JWT token claims.
* If name, email, tenant, or tenant_id are not present, returns null (user is signed out).
*/
export function deriveUserProfileFromTokenClaims(claims: Record<string, any>, rawSub?: string): AuthUserProfile | null {
if (!claims || typeof claims !== 'object' || Object.keys(claims).length === 0) {
return null;
}
// Derive name
const name =
claims.name ||
claims.given_name ||
claims.preferred_username ||
claims.nickname ||
(claims.first_name && claims.last_name ? `${claims.first_name} ${claims.last_name}` : undefined);
// Derive email
const email =
claims.email ||
claims.upn ||
claims.email_address ||
claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'];
// Derive tenant (tenant name)
const tenant =
claims.tenant ||
claims.tenant_name ||
claims.tenantName ||
claims.organisation ||
claims.org ||
claims.organization;
// Derive tenant_id
const rawTenantId =
claims.tenant_id ??
claims.tenantId ??
claims.tenantid ??
claims.org_id ??
claims.organisation_id ??
claims.organization_id ??
claims.tid;
// If any required property is missing, user is signed out
if (!name || !email || !tenant || rawTenantId === undefined || rawTenantId === null || String(rawTenantId).trim() === '') {
return null;
}
const sub = claims.sub || rawSub || String(rawTenantId);
const role =
claims.role ||
(Array.isArray(claims.roles) ? claims.roles[0] : claims.roles) ||
'Partner';
return {
id: String(sub),
sub: String(sub),
name: String(name).trim(),
email: String(email).trim(),
role: String(role).trim(),
tenant: String(tenant).trim(),
tenant_id: typeof rawTenantId === 'number' ? rawTenantId : String(rawTenantId).trim(),
};
}
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [user, setUser] = useState<AuthUserProfile | null>(null);
const [accessToken, setAccessToken] = useState<string | null>(() => {
return typeof window !== 'undefined' ? localStorage.getItem('pfm_access_token') : null;
});
const [claims, setClaims] = useState<Record<string, any>>({});
const [isLoading, setIsLoading] = useState(true);
const [userManager, setUserManager] = useState<UserManager | null>(null);
useEffect(() => {
let active = true;
async function initOidc() {
try {
await loadRuntimeConfig();
const sec = getSecuritySettings();
const authorityUrl = sec.Authority.replace(/\/+$/, '');
const mgr = new UserManager({
authority: authorityUrl,
client_id: sec.ClientId,
client_secret: sec.ClientSecret,
redirect_uri: window.location.origin,
post_logout_redirect_uri: window.location.origin,
response_type: 'code',
scope: sec.Scope || 'openid profile email roles pfm-api-dev',
userStore: new WebStorageStateStore({ store: window.localStorage }),
metadata: {
issuer: authorityUrl,
authorization_endpoint: `${authorityUrl}/connect/authorize`,
token_endpoint: `${authorityUrl}/connect/token`,
userinfo_endpoint: `${authorityUrl}/connect/userinfo`,
end_session_endpoint: `${authorityUrl}/connect/endsession`,
jwks_uri: `${authorityUrl}/.well-known/openid-configuration/jwks`,
},
});
if (!active) return;
setUserManager(mgr);
// 1. Process OIDC redirect code if returning from STS
if (window.location.search.includes('code=')) {
try {
const oidcUser = await mgr.signinCallback();
if (oidcUser && oidcUser.access_token) {
const tokenClaims = parseJwtClaims(oidcUser.access_token);
const combinedClaims = { ...tokenClaims, ...(oidcUser.profile || {}) };
const derivedProfile = deriveUserProfileFromTokenClaims(combinedClaims, oidcUser.profile?.sub);
if (derivedProfile) {
localStorage.setItem('pfm_access_token', oidcUser.access_token);
localStorage.setItem('pfm_user_sub', derivedProfile.sub);
localStorage.setItem('pfm_auth_user', JSON.stringify(derivedProfile));
localStorage.setItem('pfm_user_claims', JSON.stringify(combinedClaims));
setAccessToken(oidcUser.access_token);
setClaims(combinedClaims);
setUser(derivedProfile);
try {
await syncUserAccount(derivedProfile.sub, oidcUser.access_token, derivedProfile);
} catch {}
} else {
clearUserSyncInBrowser();
localStorage.removeItem('pfm_access_token');
localStorage.removeItem('pfm_auth_user');
setAccessToken(null);
setUser(null);
}
}
window.history.replaceState({}, document.title, window.location.pathname);
} catch (err) {
console.error('[Auth] Callback error:', err);
}
} else {
// 2. Evaluate existing stored token
const storedToken = localStorage.getItem('pfm_access_token');
if (storedToken) {
const tokenClaims = parseJwtClaims(storedToken);
const derivedProfile = deriveUserProfileFromTokenClaims(tokenClaims);
if (derivedProfile) {
setAccessToken(storedToken);
setClaims(tokenClaims);
setUser(derivedProfile);
localStorage.setItem('pfm_auth_user', JSON.stringify(derivedProfile));
} else {
clearUserSyncInBrowser();
localStorage.removeItem('pfm_access_token');
localStorage.removeItem('pfm_auth_user');
setAccessToken(null);
setUser(null);
}
} else {
setUser(null);
setAccessToken(null);
}
}
} catch (err) {
console.warn('[Auth] OIDC initialization notice:', err);
setUser(null);
setAccessToken(null);
} finally {
if (active) setIsLoading(false);
}
}
initOidc();
const unsubSession = onSessionExpired(() => clearSessionAndRedirectToLanding());
return () => {
active = false;
unsubSession();
};
}, []);
const login = async () => {
if (userManager) {
try {
await userManager.signinRedirect();
} catch (err) {
console.error('[Auth] Redirect error:', err);
}
}
};
const logout = async () => {
clearUserSyncInBrowser();
clearSessionAndRedirectToLanding();
};
return (
<AuthContext.Provider
value={{
user,
sub: user?.sub || '',
claims,
accessToken,
isAuthenticated: !!user && !!accessToken,
isLoading,
isPendingActivation: isActivationPending(),
login,
logout,
}}
>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
};