authentication authorization
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { motion, useReducedMotion, AnimatePresence } from 'framer-motion';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
@@ -44,13 +45,42 @@ import { SponsorDashboardViewModel } from '@/lib/view-models/SponsorDashboardVie
|
||||
|
||||
export default function SponsorDashboardPage() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const { sponsorService } = useServices();
|
||||
const { sponsorService, policyService } = useServices();
|
||||
const [timeRange, setTimeRange] = useState<'7d' | '30d' | '90d' | 'all'>('30d');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [data, setData] = useState<SponsorDashboardViewModel | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
data: policySnapshot,
|
||||
isLoading: policyLoading,
|
||||
isError: policyError,
|
||||
} = useQuery({
|
||||
queryKey: ['policySnapshot'],
|
||||
queryFn: () => policyService.getSnapshot(),
|
||||
staleTime: 60_000,
|
||||
gcTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const sponsorPortalState = policySnapshot
|
||||
? policyService.getCapabilityState(policySnapshot, 'sponsors.portal')
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (policyLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (policyError || sponsorPortalState !== 'enabled') {
|
||||
setError(
|
||||
sponsorPortalState === 'coming_soon'
|
||||
? 'Sponsor portal is coming soon.'
|
||||
: 'Sponsor portal is currently unavailable.',
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadDashboard = async () => {
|
||||
try {
|
||||
const dashboardData = await sponsorService.getSponsorDashboard('demo-sponsor-1');
|
||||
@@ -67,8 +97,8 @@ export default function SponsorDashboardPage() {
|
||||
}
|
||||
};
|
||||
|
||||
loadDashboard();
|
||||
}, []);
|
||||
void loadDashboard();
|
||||
}, [policyLoading, policyError, sponsorPortalState, sponsorService]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
||||
@@ -7,6 +7,7 @@ import Link from 'next/link';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
|
||||
import { CapabilityGate } from '@/components/shared/CapabilityGate';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
||||
import { DriverViewModel as DriverViewModelClass } from '@/lib/view-models/DriverViewModel';
|
||||
@@ -190,40 +191,54 @@ export default function UserPill() {
|
||||
</div>
|
||||
|
||||
{/* Menu Items */}
|
||||
<div className="py-2 text-sm text-gray-200">
|
||||
<Link
|
||||
href="/sponsor"
|
||||
className="flex items-center gap-3 px-4 py-2.5 hover:bg-iron-gray/50 transition-colors"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<BarChart3 className="h-4 w-4 text-performance-green" />
|
||||
<span>Dashboard</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/sponsor/campaigns"
|
||||
className="flex items-center gap-3 px-4 py-2.5 hover:bg-iron-gray/50 transition-colors"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<Megaphone className="h-4 w-4 text-primary-blue" />
|
||||
<span>My Sponsorships</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/sponsor/billing"
|
||||
className="flex items-center gap-3 px-4 py-2.5 hover:bg-iron-gray/50 transition-colors"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<CreditCard className="h-4 w-4 text-warning-amber" />
|
||||
<span>Billing</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/sponsor/settings"
|
||||
className="flex items-center gap-3 px-4 py-2.5 hover:bg-iron-gray/50 transition-colors"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<Settings className="h-4 w-4 text-gray-400" />
|
||||
<span>Settings</span>
|
||||
</Link>
|
||||
</div>
|
||||
<CapabilityGate
|
||||
capabilityKey="sponsors.portal"
|
||||
fallback={
|
||||
<div className="py-2 px-4 text-xs text-gray-500">
|
||||
Sponsor portal is currently unavailable.
|
||||
</div>
|
||||
}
|
||||
comingSoon={
|
||||
<div className="py-2 px-4 text-xs text-gray-500">
|
||||
Sponsor portal is coming soon.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="py-2 text-sm text-gray-200">
|
||||
<Link
|
||||
href="/sponsor"
|
||||
className="flex items-center gap-3 px-4 py-2.5 hover:bg-iron-gray/50 transition-colors"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<BarChart3 className="h-4 w-4 text-performance-green" />
|
||||
<span>Dashboard</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/sponsor/campaigns"
|
||||
className="flex items-center gap-3 px-4 py-2.5 hover:bg-iron-gray/50 transition-colors"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<Megaphone className="h-4 w-4 text-primary-blue" />
|
||||
<span>My Sponsorships</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/sponsor/billing"
|
||||
className="flex items-center gap-3 px-4 py-2.5 hover:bg-iron-gray/50 transition-colors"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<CreditCard className="h-4 w-4 text-warning-amber" />
|
||||
<span>Billing</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/sponsor/settings"
|
||||
className="flex items-center gap-3 px-4 py-2.5 hover:bg-iron-gray/50 transition-colors"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<Settings className="h-4 w-4 text-gray-400" />
|
||||
<span>Settings</span>
|
||||
</Link>
|
||||
</div>
|
||||
</CapabilityGate>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-charcoal-outline">
|
||||
|
||||
44
apps/website/components/shared/CapabilityGate.tsx
Normal file
44
apps/website/components/shared/CapabilityGate.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
|
||||
type CapabilityGateProps = {
|
||||
capabilityKey: string;
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
comingSoon?: ReactNode;
|
||||
};
|
||||
|
||||
export function CapabilityGate({
|
||||
capabilityKey,
|
||||
children,
|
||||
fallback = null,
|
||||
comingSoon = null,
|
||||
}: CapabilityGateProps) {
|
||||
const { policyService } = useServices();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['policySnapshot'],
|
||||
queryFn: () => policyService.getSnapshot(),
|
||||
staleTime: 60_000,
|
||||
gcTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
if (isLoading || isError || !data) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
const state = policyService.getCapabilityState(data, capabilityKey);
|
||||
|
||||
if (state === 'enabled') {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (state === 'coming_soon') {
|
||||
return <>{comingSoon ?? fallback}</>;
|
||||
}
|
||||
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
28
apps/website/lib/api/policy/PolicyApiClient.ts
Normal file
28
apps/website/lib/api/policy/PolicyApiClient.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { BaseApiClient } from '../base/BaseApiClient';
|
||||
import type { ErrorReporter } from '../../interfaces/ErrorReporter';
|
||||
import type { Logger } from '../../interfaces/Logger';
|
||||
|
||||
export type OperationalMode = 'normal' | 'maintenance' | 'test';
|
||||
export type FeatureState = 'enabled' | 'disabled' | 'coming_soon' | 'hidden';
|
||||
|
||||
export type PolicySnapshotDto = {
|
||||
policyVersion: number;
|
||||
operationalMode: OperationalMode;
|
||||
maintenanceAllowlist: {
|
||||
view: string[];
|
||||
mutate: string[];
|
||||
};
|
||||
capabilities: Record<string, FeatureState>;
|
||||
loadedFrom: 'env' | 'file' | 'defaults';
|
||||
loadedAtIso: string;
|
||||
};
|
||||
|
||||
export class PolicyApiClient extends BaseApiClient {
|
||||
constructor(baseUrl: string, errorReporter: ErrorReporter, logger: Logger) {
|
||||
super(baseUrl, errorReporter, logger);
|
||||
}
|
||||
|
||||
getSnapshot(): Promise<PolicySnapshotDto> {
|
||||
return this.get<PolicySnapshotDto>('/policy/snapshot');
|
||||
}
|
||||
}
|
||||
66
apps/website/lib/blockers/CapabilityBlocker.ts
Normal file
66
apps/website/lib/blockers/CapabilityBlocker.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Blocker } from './Blocker';
|
||||
import type { PolicySnapshotDto } from '../api/policy/PolicyApiClient';
|
||||
import { PolicyService } from '../services/policy/PolicyService';
|
||||
|
||||
export type CapabilityBlockReason = 'loading' | 'enabled' | 'coming_soon' | 'disabled' | 'hidden';
|
||||
|
||||
export class CapabilityBlocker extends Blocker {
|
||||
private snapshot: PolicySnapshotDto | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly policyService: PolicyService,
|
||||
private readonly capabilityKey: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
updateSnapshot(snapshot: PolicySnapshotDto | null): void {
|
||||
this.snapshot = snapshot;
|
||||
}
|
||||
|
||||
canExecute(): boolean {
|
||||
return this.getReason() === 'enabled';
|
||||
}
|
||||
|
||||
getReason(): CapabilityBlockReason {
|
||||
if (!this.snapshot) {
|
||||
return 'loading';
|
||||
}
|
||||
|
||||
return this.policyService.getCapabilityState(this.snapshot, this.capabilityKey);
|
||||
}
|
||||
|
||||
block(): void {
|
||||
this.snapshot = {
|
||||
...(this.snapshot ?? {
|
||||
policyVersion: 0,
|
||||
operationalMode: 'normal',
|
||||
maintenanceAllowlist: { view: [], mutate: [] },
|
||||
capabilities: {},
|
||||
loadedFrom: 'defaults',
|
||||
loadedAtIso: new Date().toISOString(),
|
||||
}),
|
||||
capabilities: {
|
||||
...(this.snapshot?.capabilities ?? {}),
|
||||
[this.capabilityKey]: 'disabled',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
release(): void {
|
||||
this.snapshot = {
|
||||
...(this.snapshot ?? {
|
||||
policyVersion: 0,
|
||||
operationalMode: 'normal',
|
||||
maintenanceAllowlist: { view: [], mutate: [] },
|
||||
capabilities: {},
|
||||
loadedFrom: 'defaults',
|
||||
loadedAtIso: new Date().toISOString(),
|
||||
}),
|
||||
capabilities: {
|
||||
...(this.snapshot?.capabilities ?? {}),
|
||||
[this.capabilityKey]: 'enabled',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { Blocker } from './Blocker';
|
||||
export { CapabilityBlocker } from './CapabilityBlocker';
|
||||
export { SubmitBlocker } from './SubmitBlocker';
|
||||
export { ThrottleBlocker } from './ThrottleBlocker';
|
||||
@@ -9,6 +9,7 @@ import { AuthApiClient } from '../api/auth/AuthApiClient';
|
||||
import { AnalyticsApiClient } from '../api/analytics/AnalyticsApiClient';
|
||||
import { MediaApiClient } from '../api/media/MediaApiClient';
|
||||
import { DashboardApiClient } from '../api/dashboard/DashboardApiClient';
|
||||
import { PolicyApiClient } from '../api/policy/PolicyApiClient';
|
||||
import { ProtestsApiClient } from '../api/protests/ProtestsApiClient';
|
||||
import { PenaltiesApiClient } from '../api/penalties/PenaltiesApiClient';
|
||||
import { PenaltyService } from './penalties/PenaltyService';
|
||||
@@ -42,6 +43,7 @@ import { MembershipFeeService } from './payments/MembershipFeeService';
|
||||
import { AuthService } from './auth/AuthService';
|
||||
import { SessionService } from './auth/SessionService';
|
||||
import { ProtestService } from './protests/ProtestService';
|
||||
import { PolicyService } from './policy/PolicyService';
|
||||
import { OnboardingService } from './onboarding/OnboardingService';
|
||||
|
||||
/**
|
||||
@@ -67,6 +69,7 @@ export class ServiceFactory {
|
||||
analytics: AnalyticsApiClient;
|
||||
media: MediaApiClient;
|
||||
dashboard: DashboardApiClient;
|
||||
policy: PolicyApiClient;
|
||||
protests: ProtestsApiClient;
|
||||
penalties: PenaltiesApiClient;
|
||||
};
|
||||
@@ -85,6 +88,7 @@ export class ServiceFactory {
|
||||
analytics: new AnalyticsApiClient(baseUrl, this.errorReporter, this.logger),
|
||||
media: new MediaApiClient(baseUrl, this.errorReporter, this.logger),
|
||||
dashboard: new DashboardApiClient(baseUrl, this.errorReporter, this.logger),
|
||||
policy: new PolicyApiClient(baseUrl, this.errorReporter, this.logger),
|
||||
protests: new ProtestsApiClient(baseUrl, this.errorReporter, this.logger),
|
||||
penalties: new PenaltiesApiClient(baseUrl, this.errorReporter, this.logger),
|
||||
};
|
||||
@@ -237,12 +241,19 @@ export class ServiceFactory {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create DashboardService instance
|
||||
* Create PolicyService instance
|
||||
*/
|
||||
createDashboardService(): DashboardService {
|
||||
return new DashboardService(this.apiClients.dashboard);
|
||||
createPolicyService(): PolicyService {
|
||||
return new PolicyService(this.apiClients.policy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create DashboardService instance
|
||||
*/
|
||||
createDashboardService(): DashboardService {
|
||||
return new DashboardService(this.apiClients.dashboard);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create MediaService instance
|
||||
*/
|
||||
|
||||
@@ -31,6 +31,7 @@ import { SponsorshipService } from './sponsors/SponsorshipService';
|
||||
import { TeamJoinService } from './teams/TeamJoinService';
|
||||
import { TeamService } from './teams/TeamService';
|
||||
import { OnboardingService } from './onboarding/OnboardingService';
|
||||
import { PolicyService } from './policy/PolicyService';
|
||||
import { LandingService } from './landing/LandingService';
|
||||
|
||||
export interface Services {
|
||||
@@ -60,6 +61,7 @@ export interface Services {
|
||||
protestService: ProtestService;
|
||||
penaltyService: PenaltyService;
|
||||
onboardingService: OnboardingService;
|
||||
policyService: PolicyService;
|
||||
landingService: LandingService;
|
||||
}
|
||||
|
||||
@@ -109,6 +111,7 @@ export function ServiceProvider({ children }: ServiceProviderProps) {
|
||||
protestService: serviceFactory.createProtestService(),
|
||||
penaltyService: serviceFactory.createPenaltyService(),
|
||||
onboardingService: serviceFactory.createOnboardingService(),
|
||||
policyService: serviceFactory.createPolicyService(),
|
||||
landingService: serviceFactory.createLandingService(),
|
||||
};
|
||||
}, []);
|
||||
|
||||
17
apps/website/lib/services/policy/PolicyService.ts
Normal file
17
apps/website/lib/services/policy/PolicyService.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { FeatureState, PolicyApiClient, PolicySnapshotDto } from '../../api/policy/PolicyApiClient';
|
||||
|
||||
export class PolicyService {
|
||||
constructor(private readonly apiClient: PolicyApiClient) {}
|
||||
|
||||
getSnapshot(): Promise<PolicySnapshotDto> {
|
||||
return this.apiClient.getSnapshot();
|
||||
}
|
||||
|
||||
getCapabilityState(snapshot: PolicySnapshotDto, capabilityKey: string): FeatureState {
|
||||
return snapshot.capabilities[capabilityKey] ?? 'hidden';
|
||||
}
|
||||
|
||||
isCapabilityEnabled(snapshot: PolicySnapshotDto, capabilityKey: string): boolean {
|
||||
return this.getCapabilityState(snapshot, capabilityKey) === 'enabled';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user