diff --git a/shared/lib/guards.ts b/shared/lib/guards.ts new file mode 100644 index 00000000..18eb7352 --- /dev/null +++ b/shared/lib/guards.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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 { + // 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 { + 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 { + 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: [], + }; + } +} diff --git a/web/src/components/RouteGuards.tsx b/web/src/components/RouteGuards.tsx new file mode 100644 index 00000000..67fe2763 --- /dev/null +++ b/web/src/components/RouteGuards.tsx @@ -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 ( +
+ + + +

Checking permissions...

+
+
+
+ ); +}; + +// ======================================== +// CITIZEN ROUTE GUARD +// ======================================== + +/** + * CitizenRoute - Requires approved KYC (citizenship) + * Use for: Voting, Education, Elections, etc. + * + * @example + * + * + * + * } /> + */ +export const CitizenRoute: React.FC = ({ + children, + fallbackPath = '/be-citizen', +}) => { + const { api, isApiReady, selectedAccount } = usePolkadot(); + const { user } = useAuth(); + const [isCitizen, setIsCitizen] = useState(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 ; + } + + // Not connected to wallet + if (!selectedAccount) { + return ( +
+ + +
+ +

Wallet Not Connected

+

+ Please connect your Polkadot wallet to access this feature. +

+ +
+
+
+
+ ); + } + + // Not a citizen + if (isCitizen === false) { + return ; + } + + // Authorized + return <>{children}; +}; + +// ======================================== +// VALIDATOR ROUTE GUARD +// ======================================== + +/** + * ValidatorRoute - Requires validator pool membership + * Use for: Validator pool dashboard, validator settings + * + * @example + * + * + * + * } /> + */ +export const ValidatorRoute: React.FC = ({ + children, + fallbackPath = '/staking', +}) => { + const { api, isApiReady, selectedAccount } = usePolkadot(); + const [isValidator, setIsValidator] = useState(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 ; + } + + // Not connected to wallet + if (!selectedAccount) { + return ; + } + + // Not in validator pool + if (isValidator === false) { + return ( +
+ + + + + + Validator Access Required + You must be registered in the Validator Pool to access this feature. +
+ +
+
+
+
+
+
+ ); + } + + // Authorized + return <>{children}; +}; + +// ======================================== +// EDUCATOR ROUTE GUARD +// ======================================== + +/** + * EducatorRoute - Requires educator Tiki role + * Use for: Creating courses in Perwerde (Education platform) + * + * @example + * + * + * + * } /> + */ +export const EducatorRoute: React.FC = ({ + children, + fallbackPath = '/education', +}) => { + const { api, isApiReady, selectedAccount } = usePolkadot(); + const [isEducator, setIsEducator] = useState(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 ; + } + + // Not connected to wallet + if (!selectedAccount) { + return ; + } + + // Not an educator + if (isEducator === false) { + return ( +
+ + + + + + Educator Role Required + You need one of these Tiki roles to create courses: +
    +
  • Perwerdekar (Educator)
  • +
  • Mamoste (Teacher)
  • +
  • WezireCand (Education Minister)
  • +
  • Rewsenbîr (Intellectual)
  • +
+
+ +
+
+
+
+
+
+ ); + } + + // Authorized + return <>{children}; +}; + +// ======================================== +// MODERATOR ROUTE GUARD +// ======================================== + +/** + * ModeratorRoute - Requires moderator Tiki role + * Use for: Forum moderation, governance moderation + * + * @example + * + * + * + * } /> + */ +export const ModeratorRoute: React.FC = ({ + children, + fallbackPath = '/', +}) => { + const { api, isApiReady, selectedAccount } = usePolkadot(); + const [isModerator, setIsModerator] = useState(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 ; + } + + // Not connected to wallet + if (!selectedAccount) { + return ; + } + + // Not a moderator + if (isModerator === false) { + return ( +
+ + + + + + Moderator Access Required + You need moderator privileges to access this feature. +
+ +
+
+
+
+
+
+ ); + } + + // 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 = ({ + children, + fallbackPath = '/', +}) => { + const { user, isAdmin, loading } = useAuth(); + + // Loading state + if (loading) { + return ; + } + + // Not logged in + if (!user) { + return ; + } + + // Not admin + if (!isAdmin) { + return ( +
+ + + + + + Admin Access Required + You do not have permission to access the admin panel. +
+ +
+
+
+
+
+
+ ); + } + + // Authorized + return <>{children}; +}; diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index 3894dc4a..50fa3992 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -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 { 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 { user: User | null; loading: boolean; @@ -27,6 +32,66 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children const [loading, setLoading] = useState(true); 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(() => { // Check active sessions and sets the user supabase.auth.getSession().then(({ data: { session } }) => { @@ -134,6 +199,8 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children const signOut = async () => { setIsAdmin(false); + setUser(null); + localStorage.removeItem(LAST_ACTIVITY_KEY); await supabase.auth.signOut(); };