mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-06-12 21:21:02 +00:00
Add session timeout and route guards
Route Guards (web/src/components/RouteGuards.tsx): - CitizenRoute: KYC approval required - ValidatorRoute: Validator pool membership required - EducatorRoute: Educator Tiki role required - ModeratorRoute: Moderator Tiki role required - AdminRoute: Supabase admin role required - Beautiful error screens with icons and clear messages Guards Library (shared/lib/guards.ts): - checkCitizenStatus(): KYC approval check - checkValidatorStatus(): Validator pool check - checkTikiRole(): Specific Tiki role check - checkEducatorRole(): Educator roles check - checkModeratorRole(): Moderator roles check - getUserPermissions(): Get all permissions at once - 44 Tiki roles mapped from blockchain Session Timeout (AuthContext.tsx): - 30 minute inactivity timeout - Track user activity (mouse, keyboard, scroll, touch) - Check every 1 minute for timeout - Auto-logout on inactivity - Clear activity timestamp on logout Security enhancement for production readiness.
This commit is contained in:
@@ -0,0 +1,382 @@
|
|||||||
|
// ========================================
|
||||||
|
// Route Guards & Permission Checking
|
||||||
|
// ========================================
|
||||||
|
// Functions to check user permissions for protected routes
|
||||||
|
|
||||||
|
import type { ApiPromise } from '@polkadot/api';
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// CITIZENSHIP STATUS CHECK
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has approved citizenship (KYC approved)
|
||||||
|
* Required for: Voting, Education, Validator Pool, etc.
|
||||||
|
*/
|
||||||
|
export async function checkCitizenStatus(
|
||||||
|
api: ApiPromise | null,
|
||||||
|
address: string | null | undefined
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!api || !address) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if Identity KYC pallet exists
|
||||||
|
if (!api.query?.identityKyc?.kycStatuses) {
|
||||||
|
console.warn('Identity KYC pallet not available');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kycStatus = await api.query.identityKyc.kycStatuses(address);
|
||||||
|
|
||||||
|
if (kycStatus.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusStr = kycStatus.toString();
|
||||||
|
return statusStr === 'Approved';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking citizen status:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// VALIDATOR POOL STATUS CHECK
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is registered in validator pool
|
||||||
|
* Required for: Validator Pool dashboard, validator settings
|
||||||
|
*/
|
||||||
|
export async function checkValidatorStatus(
|
||||||
|
api: ApiPromise | null,
|
||||||
|
address: string | null | undefined
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!api || !address) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if ValidatorPool pallet exists
|
||||||
|
if (!api.query?.validatorPool?.poolMembers) {
|
||||||
|
console.warn('ValidatorPool pallet not available');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const poolMember = await api.query.validatorPool.poolMembers(address);
|
||||||
|
return !poolMember.isEmpty;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking validator status:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// TIKI ROLE CHECK
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// Tiki role enum mapping (from pallet-tiki)
|
||||||
|
const TIKI_ROLES = [
|
||||||
|
'Hemwelatî', // 0 - Citizen
|
||||||
|
'Parlementer', // 1 - Parliament Member
|
||||||
|
'SerokiMeclise', // 2 - Speaker of Parliament
|
||||||
|
'Serok', // 3 - President
|
||||||
|
'Wezir', // 4 - Minister
|
||||||
|
'EndameDiwane', // 5 - Dîwan Member (Constitutional Court)
|
||||||
|
'Dadger', // 6 - Judge
|
||||||
|
'Dozger', // 7 - Prosecutor
|
||||||
|
'Hiquqnas', // 8 - Lawyer
|
||||||
|
'Noter', // 9 - Notary
|
||||||
|
'Xezinedar', // 10 - Treasurer
|
||||||
|
'Bacgir', // 11 - Tax Collector
|
||||||
|
'GerinendeyeCavkaniye',// 12 - Resource Manager
|
||||||
|
'OperatorêTorê', // 13 - Network Operator
|
||||||
|
'PisporêEwlehiyaSîber',// 14 - Cyber Security Expert
|
||||||
|
'GerinendeyeDaneye', // 15 - Data Manager
|
||||||
|
'Berdevk', // 16 - Spokesperson
|
||||||
|
'Qeydkar', // 17 - Registrar
|
||||||
|
'Balyoz', // 18 - Ambassador
|
||||||
|
'Navbeynkar', // 19 - Mediator
|
||||||
|
'ParêzvaneÇandî', // 20 - Cultural Protector
|
||||||
|
'Mufetîs', // 21 - Inspector
|
||||||
|
'KalîteKontrolker', // 22 - Quality Controller
|
||||||
|
'Mela', // 23 - Mullah
|
||||||
|
'Feqî', // 24 - Religious Scholar
|
||||||
|
'Perwerdekar', // 25 - Educator
|
||||||
|
'Rewsenbîr', // 26 - Intellectual
|
||||||
|
'RêveberêProjeyê', // 27 - Project Manager
|
||||||
|
'SerokêKomele', // 28 - Community Leader
|
||||||
|
'ModeratorêCivakê', // 29 - Society Moderator
|
||||||
|
'Axa', // 30 - Lord/Landowner
|
||||||
|
'Pêseng', // 31 - Pioneer
|
||||||
|
'Sêwirmend', // 32 - Counselor
|
||||||
|
'Hekem', // 33 - Wise Person
|
||||||
|
'Mamoste', // 34 - Teacher
|
||||||
|
'Bazargan', // 35 - Merchant
|
||||||
|
'SerokWeziran', // 36 - Prime Minister
|
||||||
|
'WezireDarayiye', // 37 - Finance Minister
|
||||||
|
'WezireParez', // 38 - Defense Minister
|
||||||
|
'WezireDad', // 39 - Justice Minister
|
||||||
|
'WezireBelaw', // 40 - Publication Minister
|
||||||
|
'WezireTend', // 41 - Health Minister
|
||||||
|
'WezireAva', // 42 - Infrastructure Minister
|
||||||
|
'WezireCand', // 43 - Education Minister
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has specific Tiki role
|
||||||
|
* @param role - Kurdish name of role (e.g., 'Hemwelatî', 'Perwerdekar')
|
||||||
|
*/
|
||||||
|
export async function checkTikiRole(
|
||||||
|
api: ApiPromise | null,
|
||||||
|
address: string | null | undefined,
|
||||||
|
role: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!api || !address) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if Tiki pallet exists
|
||||||
|
if (!api.query?.tiki?.userTikis) {
|
||||||
|
console.warn('Tiki pallet not available');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tikis = await api.query.tiki.userTikis(address);
|
||||||
|
|
||||||
|
if (tikis.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// userTikis returns BoundedVec of Tiki enum indices
|
||||||
|
const tikiIndices = tikis.toJSON() as number[];
|
||||||
|
|
||||||
|
// Find role index
|
||||||
|
const roleIndex = TIKI_ROLES.indexOf(role);
|
||||||
|
if (roleIndex === -1) {
|
||||||
|
console.warn(`Unknown Tiki role: ${role}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has this role
|
||||||
|
return tikiIndices.includes(roleIndex);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking Tiki role:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has ANY Tiki role from a list
|
||||||
|
* Useful for checking multiple acceptable roles
|
||||||
|
*/
|
||||||
|
export async function checkAnyTikiRole(
|
||||||
|
api: ApiPromise | null,
|
||||||
|
address: string | null | undefined,
|
||||||
|
roles: string[]
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!api || !address) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const role of roles) {
|
||||||
|
const hasRole = await checkTikiRole(api, address, role);
|
||||||
|
if (hasRole) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking any Tiki role:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is an educator (Perwerdekar)
|
||||||
|
* Required for: Creating courses in Perwerde
|
||||||
|
*/
|
||||||
|
export async function checkEducatorRole(
|
||||||
|
api: ApiPromise | null,
|
||||||
|
address: string | null | undefined
|
||||||
|
): Promise<boolean> {
|
||||||
|
return checkAnyTikiRole(api, address, [
|
||||||
|
'Perwerdekar', // Educator
|
||||||
|
'Mamoste', // Teacher
|
||||||
|
'WezireCand', // Education Minister
|
||||||
|
'Rewsenbîr', // Intellectual
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user can moderate (ModeratorêCivakê or higher)
|
||||||
|
* Required for: Forum moderation, governance moderation
|
||||||
|
*/
|
||||||
|
export async function checkModeratorRole(
|
||||||
|
api: ApiPromise | null,
|
||||||
|
address: string | null | undefined
|
||||||
|
): Promise<boolean> {
|
||||||
|
return checkAnyTikiRole(api, address, [
|
||||||
|
'ModeratorêCivakê', // Society Moderator
|
||||||
|
'Berdevk', // Spokesperson
|
||||||
|
'Serok', // President
|
||||||
|
'SerokWeziran', // Prime Minister
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user can participate in governance (citizen or higher)
|
||||||
|
* Required for: Voting, proposing, elections
|
||||||
|
*/
|
||||||
|
export async function checkGovernanceParticipation(
|
||||||
|
api: ApiPromise | null,
|
||||||
|
address: string | null | undefined
|
||||||
|
): Promise<boolean> {
|
||||||
|
// Any citizen with approved KYC can participate
|
||||||
|
return checkCitizenStatus(api, address);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user can create proposals
|
||||||
|
* Required for: Creating referendum proposals
|
||||||
|
*/
|
||||||
|
export async function checkProposalCreationRights(
|
||||||
|
api: ApiPromise | null,
|
||||||
|
address: string | null | undefined
|
||||||
|
): Promise<boolean> {
|
||||||
|
// Citizen + certain roles can create proposals
|
||||||
|
const isCitizen = await checkCitizenStatus(api, address);
|
||||||
|
if (!isCitizen) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional check: has any leadership role
|
||||||
|
return checkAnyTikiRole(api, address, [
|
||||||
|
'Parlementer', // Parliament Member
|
||||||
|
'SerokiMeclise', // Speaker
|
||||||
|
'Serok', // President
|
||||||
|
'SerokWeziran', // Prime Minister
|
||||||
|
'Wezir', // Minister
|
||||||
|
'SerokêKomele', // Community Leader
|
||||||
|
'RêveberêProjeyê', // Project Manager
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// STAKING SCORE CHECK
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has started staking score tracking
|
||||||
|
* Required for: Advanced staking features
|
||||||
|
*/
|
||||||
|
export async function checkStakingScoreTracking(
|
||||||
|
api: ApiPromise | null,
|
||||||
|
address: string | null | undefined
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!api || !address) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!api.query?.stakingScore?.stakingStartBlock) {
|
||||||
|
console.warn('Staking score pallet not available');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startBlock = await api.query.stakingScore.stakingStartBlock(address);
|
||||||
|
return !startBlock.isNone;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking staking score tracking:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// COMBINED PERMISSION CHECKS
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export interface UserPermissions {
|
||||||
|
isCitizen: boolean;
|
||||||
|
isValidator: boolean;
|
||||||
|
hasStakingScore: boolean;
|
||||||
|
canVote: boolean;
|
||||||
|
canCreateProposals: boolean;
|
||||||
|
canModerate: boolean;
|
||||||
|
canCreateCourses: boolean;
|
||||||
|
tikis: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all user permissions at once
|
||||||
|
* Useful for dashboard/profile pages
|
||||||
|
*/
|
||||||
|
export async function getUserPermissions(
|
||||||
|
api: ApiPromise | null,
|
||||||
|
address: string | null | undefined
|
||||||
|
): Promise<UserPermissions> {
|
||||||
|
if (!api || !address) {
|
||||||
|
return {
|
||||||
|
isCitizen: false,
|
||||||
|
isValidator: false,
|
||||||
|
hasStakingScore: false,
|
||||||
|
canVote: false,
|
||||||
|
canCreateProposals: false,
|
||||||
|
canModerate: false,
|
||||||
|
canCreateCourses: false,
|
||||||
|
tikis: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch all in parallel
|
||||||
|
const [
|
||||||
|
isCitizen,
|
||||||
|
isValidator,
|
||||||
|
hasStakingScore,
|
||||||
|
canCreateProposals,
|
||||||
|
canModerate,
|
||||||
|
canCreateCourses,
|
||||||
|
tikiData,
|
||||||
|
] = await Promise.all([
|
||||||
|
checkCitizenStatus(api, address),
|
||||||
|
checkValidatorStatus(api, address),
|
||||||
|
checkStakingScoreTracking(api, address),
|
||||||
|
checkProposalCreationRights(api, address),
|
||||||
|
checkModeratorRole(api, address),
|
||||||
|
checkEducatorRole(api, address),
|
||||||
|
api.query?.tiki?.userTikis?.(address),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Parse tikis
|
||||||
|
const tikiIndices = tikiData?.isEmpty ? [] : (tikiData?.toJSON() as number[]);
|
||||||
|
const tikis = tikiIndices.map((index) => TIKI_ROLES[index] || `Unknown(${index})`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isCitizen,
|
||||||
|
isValidator,
|
||||||
|
hasStakingScore,
|
||||||
|
canVote: isCitizen, // Citizens can vote
|
||||||
|
canCreateProposals,
|
||||||
|
canModerate,
|
||||||
|
canCreateCourses,
|
||||||
|
tikis,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting user permissions:', error);
|
||||||
|
return {
|
||||||
|
isCitizen: false,
|
||||||
|
isValidator: false,
|
||||||
|
hasStakingScore: false,
|
||||||
|
canVote: false,
|
||||||
|
canCreateProposals: false,
|
||||||
|
canModerate: false,
|
||||||
|
canCreateCourses: false,
|
||||||
|
tikis: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
// ========================================
|
||||||
|
// Route Guard Components
|
||||||
|
// ========================================
|
||||||
|
// Protected route wrappers that check user permissions
|
||||||
|
|
||||||
|
import React, { useEffect, useState, ReactNode } from 'react';
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { usePolkadot } from '@/contexts/PolkadotContext';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import {
|
||||||
|
checkCitizenStatus,
|
||||||
|
checkValidatorStatus,
|
||||||
|
checkEducatorRole,
|
||||||
|
checkModeratorRole,
|
||||||
|
} from '@pezkuwi/lib/guards';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { Loader2, AlertCircle, Users, GraduationCap, Shield } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface RouteGuardProps {
|
||||||
|
children: ReactNode;
|
||||||
|
fallbackPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// LOADING COMPONENT
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
const LoadingGuard: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
|
||||||
|
<Card className="bg-gray-900 border-gray-800 p-8">
|
||||||
|
<CardContent className="flex flex-col items-center gap-4">
|
||||||
|
<Loader2 className="w-12 h-12 text-green-500 animate-spin" />
|
||||||
|
<p className="text-gray-400">Checking permissions...</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// CITIZEN ROUTE GUARD
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CitizenRoute - Requires approved KYC (citizenship)
|
||||||
|
* Use for: Voting, Education, Elections, etc.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <Route path="/vote" element={
|
||||||
|
* <CitizenRoute>
|
||||||
|
* <VotingPage />
|
||||||
|
* </CitizenRoute>
|
||||||
|
* } />
|
||||||
|
*/
|
||||||
|
export const CitizenRoute: React.FC<RouteGuardProps> = ({
|
||||||
|
children,
|
||||||
|
fallbackPath = '/be-citizen',
|
||||||
|
}) => {
|
||||||
|
const { api, isApiReady, selectedAccount } = usePolkadot();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [isCitizen, setIsCitizen] = useState<boolean | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkPermission = async () => {
|
||||||
|
if (!isApiReady || !api) {
|
||||||
|
setLoading(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedAccount?.address) {
|
||||||
|
setIsCitizen(false);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const citizenStatus = await checkCitizenStatus(api, selectedAccount.address);
|
||||||
|
setIsCitizen(citizenStatus);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Citizen check failed:', error);
|
||||||
|
setIsCitizen(false);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkPermission();
|
||||||
|
}, [api, isApiReady, selectedAccount]);
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (loading || !isApiReady) {
|
||||||
|
return <LoadingGuard />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not connected to wallet
|
||||||
|
if (!selectedAccount) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-950 flex items-center justify-center p-4">
|
||||||
|
<Card className="bg-gray-900 border-gray-800 max-w-md">
|
||||||
|
<CardContent className="p-8">
|
||||||
|
<div className="flex flex-col items-center gap-4 text-center">
|
||||||
|
<Users className="w-16 h-16 text-yellow-500" />
|
||||||
|
<h2 className="text-2xl font-bold text-white">Wallet Not Connected</h2>
|
||||||
|
<p className="text-gray-400">
|
||||||
|
Please connect your Polkadot wallet to access this feature.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => window.location.href = '/'}
|
||||||
|
className="bg-green-600 hover:bg-green-700"
|
||||||
|
>
|
||||||
|
Go to Home
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not a citizen
|
||||||
|
if (isCitizen === false) {
|
||||||
|
return <Navigate to={fallbackPath} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorized
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// VALIDATOR ROUTE GUARD
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ValidatorRoute - Requires validator pool membership
|
||||||
|
* Use for: Validator pool dashboard, validator settings
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <Route path="/validator-pool" element={
|
||||||
|
* <ValidatorRoute>
|
||||||
|
* <ValidatorPoolDashboard />
|
||||||
|
* </ValidatorRoute>
|
||||||
|
* } />
|
||||||
|
*/
|
||||||
|
export const ValidatorRoute: React.FC<RouteGuardProps> = ({
|
||||||
|
children,
|
||||||
|
fallbackPath = '/staking',
|
||||||
|
}) => {
|
||||||
|
const { api, isApiReady, selectedAccount } = usePolkadot();
|
||||||
|
const [isValidator, setIsValidator] = useState<boolean | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkPermission = async () => {
|
||||||
|
if (!isApiReady || !api) {
|
||||||
|
setLoading(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedAccount?.address) {
|
||||||
|
setIsValidator(false);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const validatorStatus = await checkValidatorStatus(api, selectedAccount.address);
|
||||||
|
setIsValidator(validatorStatus);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Validator check failed:', error);
|
||||||
|
setIsValidator(false);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkPermission();
|
||||||
|
}, [api, isApiReady, selectedAccount]);
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (loading || !isApiReady) {
|
||||||
|
return <LoadingGuard />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not connected to wallet
|
||||||
|
if (!selectedAccount) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not in validator pool
|
||||||
|
if (isValidator === false) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-950 flex items-center justify-center p-4">
|
||||||
|
<Card className="bg-gray-900 border-gray-800 max-w-md">
|
||||||
|
<CardContent className="p-8">
|
||||||
|
<Alert className="bg-red-900/20 border-red-500">
|
||||||
|
<AlertCircle className="h-5 w-5 text-red-400" />
|
||||||
|
<AlertDescription className="text-gray-300">
|
||||||
|
<strong className="block mb-2">Validator Access Required</strong>
|
||||||
|
You must be registered in the Validator Pool to access this feature.
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button
|
||||||
|
onClick={() => window.location.href = fallbackPath}
|
||||||
|
className="bg-green-600 hover:bg-green-700"
|
||||||
|
>
|
||||||
|
Go to Staking
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorized
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// EDUCATOR ROUTE GUARD
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EducatorRoute - Requires educator Tiki role
|
||||||
|
* Use for: Creating courses in Perwerde (Education platform)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <Route path="/education/create-course" element={
|
||||||
|
* <EducatorRoute>
|
||||||
|
* <CourseCreator />
|
||||||
|
* </EducatorRoute>
|
||||||
|
* } />
|
||||||
|
*/
|
||||||
|
export const EducatorRoute: React.FC<RouteGuardProps> = ({
|
||||||
|
children,
|
||||||
|
fallbackPath = '/education',
|
||||||
|
}) => {
|
||||||
|
const { api, isApiReady, selectedAccount } = usePolkadot();
|
||||||
|
const [isEducator, setIsEducator] = useState<boolean | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkPermission = async () => {
|
||||||
|
if (!isApiReady || !api) {
|
||||||
|
setLoading(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedAccount?.address) {
|
||||||
|
setIsEducator(false);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const educatorStatus = await checkEducatorRole(api, selectedAccount.address);
|
||||||
|
setIsEducator(educatorStatus);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Educator check failed:', error);
|
||||||
|
setIsEducator(false);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkPermission();
|
||||||
|
}, [api, isApiReady, selectedAccount]);
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (loading || !isApiReady) {
|
||||||
|
return <LoadingGuard />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not connected to wallet
|
||||||
|
if (!selectedAccount) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not an educator
|
||||||
|
if (isEducator === false) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-950 flex items-center justify-center p-4">
|
||||||
|
<Card className="bg-gray-900 border-gray-800 max-w-md">
|
||||||
|
<CardContent className="p-8">
|
||||||
|
<Alert className="bg-red-900/20 border-red-500">
|
||||||
|
<GraduationCap className="h-5 w-5 text-red-400" />
|
||||||
|
<AlertDescription className="text-gray-300">
|
||||||
|
<strong className="block mb-2">Educator Role Required</strong>
|
||||||
|
You need one of these Tiki roles to create courses:
|
||||||
|
<ul className="list-disc list-inside mt-2 text-sm">
|
||||||
|
<li>Perwerdekar (Educator)</li>
|
||||||
|
<li>Mamoste (Teacher)</li>
|
||||||
|
<li>WezireCand (Education Minister)</li>
|
||||||
|
<li>Rewsenbîr (Intellectual)</li>
|
||||||
|
</ul>
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button
|
||||||
|
onClick={() => window.location.href = fallbackPath}
|
||||||
|
className="bg-green-600 hover:bg-green-700"
|
||||||
|
>
|
||||||
|
Browse Courses
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorized
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// MODERATOR ROUTE GUARD
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ModeratorRoute - Requires moderator Tiki role
|
||||||
|
* Use for: Forum moderation, governance moderation
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <Route path="/moderate" element={
|
||||||
|
* <ModeratorRoute>
|
||||||
|
* <ModerationPanel />
|
||||||
|
* </ModeratorRoute>
|
||||||
|
* } />
|
||||||
|
*/
|
||||||
|
export const ModeratorRoute: React.FC<RouteGuardProps> = ({
|
||||||
|
children,
|
||||||
|
fallbackPath = '/',
|
||||||
|
}) => {
|
||||||
|
const { api, isApiReady, selectedAccount } = usePolkadot();
|
||||||
|
const [isModerator, setIsModerator] = useState<boolean | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkPermission = async () => {
|
||||||
|
if (!isApiReady || !api) {
|
||||||
|
setLoading(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedAccount?.address) {
|
||||||
|
setIsModerator(false);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const moderatorStatus = await checkModeratorRole(api, selectedAccount.address);
|
||||||
|
setIsModerator(moderatorStatus);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Moderator check failed:', error);
|
||||||
|
setIsModerator(false);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkPermission();
|
||||||
|
}, [api, isApiReady, selectedAccount]);
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (loading || !isApiReady) {
|
||||||
|
return <LoadingGuard />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not connected to wallet
|
||||||
|
if (!selectedAccount) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not a moderator
|
||||||
|
if (isModerator === false) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-950 flex items-center justify-center p-4">
|
||||||
|
<Card className="bg-gray-900 border-gray-800 max-w-md">
|
||||||
|
<CardContent className="p-8">
|
||||||
|
<Alert className="bg-red-900/20 border-red-500">
|
||||||
|
<Shield className="h-5 w-5 text-red-400" />
|
||||||
|
<AlertDescription className="text-gray-300">
|
||||||
|
<strong className="block mb-2">Moderator Access Required</strong>
|
||||||
|
You need moderator privileges to access this feature.
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button
|
||||||
|
onClick={() => window.location.href = fallbackPath}
|
||||||
|
className="bg-green-600 hover:bg-green-700"
|
||||||
|
>
|
||||||
|
Go to Home
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorized
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// ADMIN ROUTE GUARD (Supabase-based)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AdminRoute - Requires Supabase admin role
|
||||||
|
* Use for: Admin panel, system settings
|
||||||
|
* Note: This is separate from blockchain permissions
|
||||||
|
*/
|
||||||
|
export const AdminRoute: React.FC<RouteGuardProps> = ({
|
||||||
|
children,
|
||||||
|
fallbackPath = '/',
|
||||||
|
}) => {
|
||||||
|
const { user, isAdmin, loading } = useAuth();
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (loading) {
|
||||||
|
return <LoadingGuard />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not logged in
|
||||||
|
if (!user) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not admin
|
||||||
|
if (!isAdmin) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-950 flex items-center justify-center p-4">
|
||||||
|
<Card className="bg-gray-900 border-gray-800 max-w-md">
|
||||||
|
<CardContent className="p-8">
|
||||||
|
<Alert className="bg-red-900/20 border-red-500">
|
||||||
|
<AlertCircle className="h-5 w-5 text-red-400" />
|
||||||
|
<AlertDescription className="text-gray-300">
|
||||||
|
<strong className="block mb-2">Admin Access Required</strong>
|
||||||
|
You do not have permission to access the admin panel.
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button
|
||||||
|
onClick={() => window.location.href = fallbackPath}
|
||||||
|
className="bg-green-600 hover:bg-green-700"
|
||||||
|
>
|
||||||
|
Go to Home
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorized
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
import { User } from '@supabase/supabase-js';
|
import { User } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
// Session timeout configuration
|
||||||
|
const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
||||||
|
const ACTIVITY_CHECK_INTERVAL_MS = 60 * 1000; // Check every 1 minute
|
||||||
|
const LAST_ACTIVITY_KEY = 'last_activity_timestamp';
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -27,6 +32,66 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// SESSION TIMEOUT MANAGEMENT
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// Update last activity timestamp
|
||||||
|
const updateLastActivity = useCallback(() => {
|
||||||
|
localStorage.setItem(LAST_ACTIVITY_KEY, Date.now().toString());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Check if session has timed out
|
||||||
|
const checkSessionTimeout = useCallback(async () => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const lastActivity = localStorage.getItem(LAST_ACTIVITY_KEY);
|
||||||
|
if (!lastActivity) {
|
||||||
|
updateLastActivity();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastActivityTime = parseInt(lastActivity, 10);
|
||||||
|
const now = Date.now();
|
||||||
|
const inactiveTime = now - lastActivityTime;
|
||||||
|
|
||||||
|
if (inactiveTime >= SESSION_TIMEOUT_MS) {
|
||||||
|
console.log('⏱️ Session timeout - logging out due to inactivity');
|
||||||
|
await signOut();
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
// Setup activity listeners
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
// Update activity on user interactions
|
||||||
|
const activityEvents = ['mousedown', 'keydown', 'scroll', 'touchstart'];
|
||||||
|
|
||||||
|
const handleActivity = () => {
|
||||||
|
updateLastActivity();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register event listeners
|
||||||
|
activityEvents.forEach((event) => {
|
||||||
|
window.addEventListener(event, handleActivity);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial activity timestamp
|
||||||
|
updateLastActivity();
|
||||||
|
|
||||||
|
// Check for timeout periodically
|
||||||
|
const timeoutChecker = setInterval(checkSessionTimeout, ACTIVITY_CHECK_INTERVAL_MS);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
return () => {
|
||||||
|
activityEvents.forEach((event) => {
|
||||||
|
window.removeEventListener(event, handleActivity);
|
||||||
|
});
|
||||||
|
clearInterval(timeoutChecker);
|
||||||
|
};
|
||||||
|
}, [user, updateLastActivity, checkSessionTimeout]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check active sessions and sets the user
|
// Check active sessions and sets the user
|
||||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||||
@@ -134,6 +199,8 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
|
|
||||||
const signOut = async () => {
|
const signOut = async () => {
|
||||||
setIsAdmin(false);
|
setIsAdmin(false);
|
||||||
|
setUser(null);
|
||||||
|
localStorage.removeItem(LAST_ACTIVITY_KEY);
|
||||||
await supabase.auth.signOut();
|
await supabase.auth.signOut();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user