Frontend Authorization UI - Implementation Guide¶
Version: 3.0 (Phase 3)
Updated: 2024-01-20
Status: Complete ✅
Overview¶
This guide documents the Phase 3 implementation of role-based UI components that work with the new single-source-of-truth architecture where user roles are stored in Firestore, not Firebase Auth claims.
Architecture: Single Source of Truth¶
Where Roles Are Stored (By Layer):¶
Frontend (React):
└─ useUserRole Hook (caches from Firestore)
└─ Reads from: /users/{uid} Firestore document
- NEVER from Auth claims
- Caches for 5 minutes for performance
- Real-time listener for immediate updates
Cloud Functions (Authorization):
└─ Always reads /users/{uid} before authorizing
└─ Verifies role server-side
└─ Sets Auth claims (minimal: tenant_id only)
Firestore Rules:
└─ Helper functions read /users/{uid}
└─ `getUserRole()` - returns role from document
└─ Enforces read-only wallet data from clients
Why This Works:¶
- Single Source of Truth: Role only in
/users/{uid}, never duplicate/sync - No Stale Auth Claims: Role changes in Firestore immediately reflected in UI
- Security: Client cannot escalate role (stored server-side, rules prevent client writes)
- Real-Time: Firestore listener ensures instant updates when admin changes role
Component Reference¶
1. useUserRole Hook¶
File: webapp/src/hooks/useUserRole.ts
Type: React Hook
Purpose: Read user role and onboarding status from Firestore with caching
Hook Signature:¶
function useUserRole(): {
user: UserInfo | null;
loading: boolean;
error: string | null;
role: UserRole | null;
tenant_id: string | null;
onboardingCompleted: boolean;
isAuthenticated: boolean;
isEndUser: boolean;
hasRole: (requiredRoles: UserRole[]) => boolean;
isAdmin: () => boolean;
isSuperAdmin: () => boolean;
}
User Data Structure:¶
interface UserInfo {
uid: string;
email: string | null;
displayName: string | null;
role: UserRole; // END_USER | ADMIN | CLUB_ADMIN | KIOSK_ADMIN | SUPER_ADMIN
tenant_id: string | null;
memberID: string | null;
user_wallet_id: string | null;
onboardingCompleted: boolean;
}
How It Works:¶
Step 1: User Logs In
// Firebase Auth handles login
await signInWithEmailAndPassword(auth, email, password);
Step 2: useUserRole Hook Activates
// When hook mounts, sets up auth listener
onAuthStateChanged(auth, async (authUser) => {
if (authUser) {
// Checks cache first (5-min validity)
const cacheValid = (now - cacheRef.current.timestamp < CACHE_DURATION);
if (cacheValid) { setUser(cacheRef.current.data); }
// Sets up real-time Firestore listener
onSnapshot(doc(db, "users", authUser.uid), (docSnapshot) => {
const userData = {
uid: authUser.uid,
role: docSnapshot.data().role, // ✅ FROM FIRESTORE
tenant_id: docSnapshot.data().tenant_id,
onboardingCompleted: docSnapshot.data().onboardingCompleted,
// ... other fields
};
setUser(userData);
// Update cache for next load
cacheRef.current = { data: userData, timestamp: now };
});
}
});
Step 3: Components Use Hook
function MyComponent() {
const { role, onboardingCompleted, hasRole } = useUserRole();
if (hasRole(["ADMIN"])) {
// Show admin UI
}
if (!onboardingCompleted) {
// Show onboarding modal
}
}
Caching Strategy:¶
Request 1: Load useUserRole hook
├─ Check cache (timestamp valid? 5 min?)
├─ If valid: Return cached data immediately
├─ Always: Set up real-time listener
└─ If Firestore changes: Update cache + state
Request 2 (within 5 min): Load hook again
├─ Check cache (still valid)
├─ Return cached data
├─ Set up listener again (duplicate, but fine)
└─ Data stays in sync
Request 3 (after 5 min): Load hook again
├─ Check cache (expired)
├─ Clear cache, fetch fresh
├─ Set up listener
└─ Cache refreshed with new data
Helper Methods:¶
const { role, hasRole, isAdmin, isSuperAdmin } = useUserRole();
// Check specific role
if (hasRole(["ADMIN", "CLUB_ADMIN"])) { /* ... */ }
// Check if any admin role
if (isAdmin()) { /* ... */ }
// Check if super admin
if (isSuperAdmin()) { /* ... */ }
2. ProtectedRoute Component¶
File: webapp/src/components/ProtectedRoute.tsx
Type: React Component
Purpose: Protect routes based on authentication, onboarding, and role
Component Signature:¶
<ProtectedRoute
children={React.ReactNode}
requiredRoles?: UserRole[] // Default: all roles allowed
requireOnboarding?: boolean // Default: true
fallback?: string // Default: "/wallet"
loadingFallback?: React.ReactNode // Default: spinner
>
{/* Component to protect */}
</ProtectedRoute>
Usage Examples:¶
// Protect route - all checks (auth + onboarding + default roles)
<Route
path="/wallet"
element={
<ProtectedRoute>
<WalletPage />
</ProtectedRoute>
}
/>
// Admin-only route
<Route
path="/admin"
element={
<ProtectedRoute requiredRoles={["ADMIN", "CLUB_ADMIN", "SUPER_ADMIN"]}>
<AdminDashboard />
</ProtectedRoute>
}
/>
// KIOSK_ADMIN only (for QR scanning)
<Route
path="/qr-scanner"
element={
<ProtectedRoute requiredRoles={["KIOSK_ADMIN"]}>
<QRScanner />
</ProtectedRoute>
}
/>
// Skip onboarding requirement for public page
<Route
path="/club-info"
element={
<ProtectedRoute requireOnboarding={false}>
<ClubInfo />
</ProtectedRoute>
}
/>
How It Works:¶
Request to protected route:
│
├─ Is loading? Show spinner
├─ Is not authenticated? Redirect to /login
├─ Is not onboarded? Redirect to /onboarding
├─ Does not have required role? Show access denied
└─ All checks pass → Render component ✅
Diagram:¶
ProtectedRoute
│
├─ loading? ───→ <Spinner />
│
├─ !isAuthenticated? ───→ Navigate("/login")
│
├─ requireOnboarding && !onboardingCompleted? ───→ Navigate("/onboarding")
│
├─ requiredRoles.length > 0 && !hasRole? ───→ <AccessDenied />
│
└─ All checks pass ───→ <>{children}</>
3. OnboardingModal Component¶
File: webapp/src/components/OnboardingModal.tsx
Type: React Modal Component
Purpose: Guide new users through onboarding (select club, enter member #)
Component Signature:¶
<OnboardingModal
isOpen: boolean // Is modal displayed?
onComplete?: () => void // Callback when onboarding done
/>
Usage in LoginPage:¶
// In LoginPage.tsx
const { onboardingCompleted, role } = useUserRole();
const [showModal, setShowModal] = useState(false);
// Show modal if user logged in but not onboarded
useEffect(() => {
if (!roleLoading && !isSignUp && auth.currentUser && !onboardingCompleted) {
setShowModal(true);
}
}, [roleLoading, onboardingCompleted]);
// Render modal
<OnboardingModal
isOpen={showModal}
onComplete={() => {
setShowModal(false);
// Redirect based on role
if (role === "END_USER") navigate("/wallet");
else navigate("/admin");
}}
/>
Modal Flow:¶
User logs in
│
├─ useUserRole loads
│ └─ onboardingCompleted = false
│
├─ useEffect detects incomplete onboarding
│ └─ setShowOnboardingModal(true)
│
├─ <OnboardingModal> renders
│ │
│ ├─ Fetch tenant list (getTenantsList Cloud Function)
│ │
│ ├─ User selects club
│ │ └─ Dropdown shows: [Ski golfklubb, Test Club, ...]
│ │
│ ├─ User enters member number
│ │ ├─ Validates: 1-7 digits only
│ │ └─ Preview shows: "73-000524"
│ │
│ ├─ User clicks "Complete Setup"
│ │ │
│ │ ├─ Call completeOnboarding(tenantId, memberID)
│ │ │ └─ Cloud Function:
│ │ │ ├─ Validate tenant exists ✓
│ │ │ ├─ Check member # not duplicate ✓
│ │ │ ├─ Update /users/{uid}:
│ │ │ │ ├─ tenant_id
│ │ │ │ ├─ memberID
│ │ │ │ ├─ user_wallet_id
│ │ │ │ └─ onboardingCompleted = true
│ │ │ └─ Set Auth claims (tenant_id)
│ │ │
│ │ ├─ Success response received
│ │ └─ Call onComplete() callback
│ │
│ └─ Modal closes
│
├─ LoginPage.handleOnboardingComplete() executes
│ │
│ ├─ useUserRole re-fetches from Firestore
│ │ └─ onboardingCompleted = true now
│ │
│ ├─ Redirect based on role:
│ │ ├─ END_USER → /wallet
│ │ ├─ KIOSK_ADMIN → /qr-scanner
│ │ └─ ADMIN/CLUB_ADMIN/SUPER_ADMIN → /admin
│ │
│ └─ User navigated to correct dashboard ✅
Modal UI:¶
┌─────────────────────────────────────┐
│ Welcome to GaveKort │
├─────────────────────────────────────┤
│ │
│ Select Your Club * │
│ [Dropdown: "Choose a club..."] │
│ - Ski golfklubb │
│ - Test Club │
│ - ... │
│ │
│ Member Number * │
│ [Text input: "____"] │
│ Example: 524 (will be 000524) │
│ │
│ ┌───────────────────────────────┐ │
│ │ Your Wallet ID: │ │
│ │ 73-000524 │ │
│ └───────────────────────────────┘ │
│ │
│ [Complete Setup Button] │
│ │
│ Your member number is used to link… │
└─────────────────────────────────────┘
Input Validation:¶
// Member number: Only 1-7 digits
handleMemberNumberChange = (e) => {
const value = e.target.value.replace(/\D/g, ""); // Remove non-digits
if (value.length <= 7) setMemberNumber(value);
};
// Format for preview: "524" → "000524"
formatMemberNumber = (num) => num.padStart(7, "0");
// Wallet ID: "{tenantId}-{formatted}"
getWalletIdPreview = () => `${selectedTenantId}-${formatMemberNumber(memberNumber)}`;
Error Handling:¶
Error Codes → User Messages:
"not-found" → "Club not found. Please select a valid club."
"already-exists" → "This member number is already in use for this club."
"invalid-argument" → "Invalid member number format (1-7 digits only)."
"unauthenticated" → "Session expired. Please log in again."
<other> → "An error occurred during onboarding"
Integration Points¶
Cloud Functions Called:¶
1. getTenantsList (HTTP Endpoint)¶
Purpose: Get list of active clubs for dropdown
Called From: OnboardingModal useEffect
Request:
const getTenantsList = httpsCallable(functions, "get_tenants_list");
const response = await getTenantsList();
Response:
[
{ id: "73", name: "Ski golfklubb", logo?: "url", memberCount: 150 },
{ id: "test-golf-club", name: "Test Club", logo?: "url", memberCount: 5 },
// ...
]
2. completeOnboarding (Callable Function)¶
Purpose: Complete user onboarding, update Firestore
Called From: OnboardingModal.handleSubmit
Request:
const completeOnboarding = httpsCallable(functions, "complete_onboarding");
const response = await completeOnboarding({
tenantId: "73",
memberID: "524",
});
Response (Success):
{
success: true,
message: "Onboarding completed",
user: {
uid: "user-123",
tenant_id: "73",
memberID: "000524",
user_wallet_id: "73-000524",
onboardingCompleted: true,
}
}
Response (Error):
{
success: false,
error: "Member number already in use"
// Cloud Function returns with specific error code
}
Firestore Integration¶
Collections Read:¶
1. /users/{uid}¶
Purpose: Source of truth for role, onboarding status
Read By: useUserRole hook via real-time listener
Fields Used:
{
uid: string,
role: UserRole, // END_USER | ADMIN | CLUB_ADMIN | KIOSK_ADMIN | SUPER_ADMIN
tenant_id: string | null,
memberID: string | null,
user_wallet_id: string | null,
onboardingCompleted: boolean,
email: string,
displayName: string,
}
Security: - Users can read own document (Firestore rules) - Users cannot write to role, tenant_id, onboardingCompleted - Only Cloud Functions can update these fields
Collections Written:¶
1. /users/{uid}¶
Written By: Cloud Function completeOnboarding
Fields Updated:
{
tenant_id: "73", // Set to selected tenant
memberID: "524", // Set to entered member #
user_wallet_id: "73-000524", // Formatted wallet ID
onboardingCompleted: true, // Flag user as onboarded
}
Firebase Authentication Integration¶
Auth Claims (Minimal):¶
After Phase 2 implementation, Auth custom claims only contain:
{
tenant_id: "73" // User's tenant, null until onboarding
}
NOT in claims anymore:
- role ← Now only in Firestore
- memberID ← Now only in Firestore
- user_wallet_id ← Now only in Firestore
Why Separate?¶
- Auth claims: Immutable per token lifetime (~1 hour)
- Firestore: Real-time updates, changes immediate
- Strategy: Use Firestore for changeable data (role), Auth for stable data (tenant)
React Router Integration¶
Example Route Configuration:¶
// App.tsx or router.ts
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import { LoginPage } from "./pages/LoginPage";
import { WalletPage } from "./pages/WalletPage";
import { AdminDashboard } from "./pages/AdminDashboard";
import { QRScanner } from "./pages/QRScanner";
import { ProtectedRoute } from "./components/ProtectedRoute";
export function App() {
return (
<Router>
<Routes>
{/* Public Routes */}
<Route path="/login" element={<LoginPage />} />
{/* Protected Routes - Wallet (all users) */}
<Route
path="/wallet"
element={
<ProtectedRoute>
<WalletPage />
</ProtectedRoute>
}
/>
{/* Protected Routes - QR Scanner (KIOSK_ADMIN only) */}
<Route
path="/qr-scanner"
element={
<ProtectedRoute requiredRoles={["KIOSK_ADMIN"]}>
<QRScanner />
</ProtectedRoute>
}
/>
{/* Protected Routes - Admin Dashboard (admins only) */}
<Route
path="/admin"
element={
<ProtectedRoute requiredRoles={["ADMIN", "CLUB_ADMIN", "SUPER_ADMIN"]}>
<AdminDashboard />
</ProtectedRoute>
}
/>
{/* Catch-all - Redirect to login */}
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
</Router>
);
}
Deployment Checklist¶
Before deploying Phase 3 to production:
- [ ] Phase 1 Firestore Rules deployed ✅
- [ ] Phase 2 Cloud Functions deployed ✅
- [ ] Phase 3 Frontend components created/updated ✅
- [ ] Test users created in Firebase ✅
- [ ] Test tenant 73 created in Firestore
- [ ] LoginPage onboarding modal integrated ✅
- [ ] All routes protected with ProtectedRoute ✅
- [ ] Error pages created (access denied, etc.)
- [ ] CSS styling applied to OnboardingModal
- [ ] E2E tests pass (Cypress)
- [ ] Manual testing in Firebase environment (not emulator)
- [ ] Git commit with Phase 3 changes
- [ ] Documentation deployed to gh-pages
- [ ] Marketing/user communication about new onboarding flow
Troubleshooting¶
Issue: useUserRole hook returns null for role¶
Symptom: const { role } = useUserRole() → role is null
Causes:
1. User not authenticated → Check auth state first
2. /users/{uid} document doesn't exist → onAuthCreate function didn't run
3. User logged out between reads → Check useEffect cleanup
Solution:
const { user, role, loading } = useUserRole();
if (loading) return <div>Loading...</div>; // Wait for data
if (!user) return <Navigate to="/login" />; // Not authenticated
if (!role) return <div>User data not found</div>; // Doc missing
Issue: OnboardingModal stays open after submission¶
Symptom: User completes onboarding but modal doesn't close
Causes:
1. onComplete callback not called → Check Cloud Function response
2. Modal isOpen prop not updated → Check LoginPage state
3. Error in handleOnboardingComplete → Check browser console
Solution:
// In LoginPage
const handleOnboardingComplete = () => {
console.log("Onboarding complete, closing modal");
setShowOnboardingModal(false);
console.log("Redirecting based on role:", role);
// Then navigate...
};
Issue: Role changes but UI doesn't update¶
Symptom: Admin changes user role in Firebase, UI doesn't reflect it
Causes:
1. Real-time listener not set up → Check useUserRole mount
2. Cache preventing updates → Cache should auto-refresh on Firestore change
3. Wrong Firestore path → Verify /users/{uid} correct
Solution: - Force refresh: Clear browser cache and reload - Manual refresh: Sign out and sign back in - Check Firestore listener in DevTools (React DevTools extension)
Performance Optimization¶
Caching Strategy (5 Minutes):¶
Benefit:
- Reduces Firestore reads (especially on app init)
- Faster subsequent renders
- Better offline experience (cached data available)
Trade-off:
- Real-time listener still active (not blocked by cache)
- Cache is "optimistic" - Firestore changes update it immediately
- No stale data: Cache acts as fallback, not replacement
Example:
Time 0: User logs in
└─ Cache miss → Fetch from Firestore → Cache + State
Time 1min: User navigates away and back
└─ Cache hit (still valid) → Instant data
└─ Listener still running (maybe got updates)
Time 6min: User navigates again
└─ Cache expired → Fresh fetch from Firestore
└─ Listener still running
Real-Time Listener (Always On):¶
Why always on:
- Catches role changes admin makes
- Catches onboarding completions
- Catches other user data updates
Cost:
- Open WebSocket connection per user
- Firestore doesn't charge for updates (only reads)
- Data only sent if changed
Type Safety¶
User Roles:¶
type UserRole = "END_USER" | "ADMIN" | "CLUB_ADMIN" | "KIOSK_ADMIN" | "SUPER_ADMIN" | null;
Role Hierarchy:
SUPER_ADMIN
└─ ADMIN (can manage clubs)
├─ CLUB_ADMIN (can manage single club)
└─ KIOSK_ADMIN (QR scanning only)
└─ END_USER (basic wallet access)
Interface Usage:¶
// Safe type checking
const { user } = useUserRole();
if (user?.role === "ADMIN") {
// TypeScript knows: user is not null, role is ADMIN
}
// Type-safe role array
const requiredRoles: UserRole[] = ["ADMIN", "CLUB_ADMIN"];
// TypeScript error if you try: ["INVALID_ROLE"]
Next Steps (Phase 4)¶
- Create Test Tenant 73 in Firestore (referenced by test users)
- Deploy Phase 2 Functions to Firebase (currently in code only)
- Run End-to-End Tests via Cypress
- Test Modal Flow in actual Firebase environment
- Verify Role-Based Redirects for all 5 roles
- Test Cross-Tab Updates (admin changes role in one tab, see update in another)
- Performance Testing (measure listener latency, cache effectiveness)
- User Documentation (update help docs for new onboarding flow)
Document Version: 3.0
Last Updated: 2024-01-20
Author: GitHub Copilot (Phase 3 Implementation)
Status: Complete and Ready for Deployment ✅