Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
## Progress

- [x] Repo reconnaissance: reviewed `useAccessCheck`, `access-check` screen, `guild detail` screen, and core access-related hooks.
- [ ] Locate where backend roles/eligibility requirements are computed for access-check.
- [ ] Implement resolver module + integration points.
- [ ] Add tests.
- [ ] Add docs.
- [x] Locate where backend roles/eligibility requirements are computed for access-check.
- [x] Implement resolver module + integration points.
- [x] Add tests (`roleEligibilityResolver.test.ts`, `useMultiChainRoleEligibility.test.ts`, `accessCheckScreen.test.tsx`).
- [x] Add docs (`docs/multi-chain-role-eligibility.md`).
16 changes: 16 additions & 0 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,22 @@ export default function RootLayout() {
>
<View className="flex-1 bg-background dark:bg-slate-900">
<WalletConnectProvider>
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colorScheme === 'dark' ? '#0f172a' : '#f8fafc' },
}}
>
<Stack.Screen name="index" />
<Stack.Screen name="onboarding" />
<Stack.Screen name="profile" />
<Stack.Screen name="guilds" />
<Stack.Screen name="guilds/[guildId]" />
<Stack.Screen name="access-check" />
<Stack.Screen name="access-scanner" />
<Stack.Screen name="settings" />
<Stack.Screen name="deep-link-error" />
</Stack>
<DeepLinkHandler />
<ScreenErrorBoundary screenName="app-stack">
<Stack
Expand Down
41 changes: 41 additions & 0 deletions app/access-check.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ const statusCopy: Record<PerChainRoleEligibilityResolution["status"], string> =
};

const statusClassName: Record<PerChainRoleEligibilityResolution["status"], string> = {
resolved: "bg-success/10 text-success border-success/30 dark:bg-green-900/30 dark:text-green-400 dark:border-green-600/50",
"timed-out": "bg-amber-100 text-amber-700 border-amber-300 dark:bg-amber-900/30 dark:text-amber-400 dark:border-amber-600/50",
error: "bg-error/10 text-error border-error/30 dark:bg-red-900/30 dark:text-red-400 dark:border-red-600/50",
resolved:
"bg-success/10 text-success border-success/30 dark:bg-green-900/30 dark:text-green-400 dark:border-green-600/50",
"timed-out":
Expand Down Expand Up @@ -74,6 +77,7 @@ function PerChainEligibilityList({
</View>

{roleEligibilityError ? (
<Text className="text-error dark:text-red-400 text-sm mb-3" testID="per-chain-eligibility-error">
<Text
className="text-error dark:text-red-400 text-sm mb-3"
testID="per-chain-eligibility-error"
Expand All @@ -82,6 +86,30 @@ function PerChainEligibilityList({
</Text>
) : null}

{perChainRoleEligibility.map((chain) => (
<View
key={`${chain.chainId}-${chain.status}`}
className="border border-border dark:border-slate-700 rounded-xl p-3 mb-2"
testID={`per-chain-eligibility-row-${chain.chainId}`}
>
<View className="flex-row items-center justify-between">
<Text className="text-text dark:text-slate-100 font-semibold">Chain {chain.chainId}</Text>
<Text
className={`px-2 py-1 rounded-full border text-xs font-semibold ${statusClassName[chain.status]}`}
>
{statusCopy[chain.status]}
</Text>
</View>
{chain.resolvedRoles && chain.resolvedRoles.length > 0 ? (
<Text className="text-text-muted dark:text-slate-400 text-xs mt-2">
Roles: {chain.resolvedRoles.join(", ")}
</Text>
) : null}
{chain.errorMessage ? (
<Text className="text-error dark:text-red-400 text-xs mt-2">{chain.errorMessage}</Text>
) : null}
</View>
))}
{perChainRoleEligibility.map((chain) => {
const isRetrying = retryingChainIds.includes(chain.chainId);
const canRetry = chain.chainId > 0 && chain.status !== "resolved" && onRetryChain;
Expand Down Expand Up @@ -409,6 +437,11 @@ export default function AccessCheck() {
accessibilityHint="Enter the guild identifier"
testID="access-check-guild-id-input"
/>
{guildIdError && <Text className="text-error dark:text-red-400 text-sm mt-1">{guildIdError}</Text>}
</View>

<View className="mt-4">
<Text className="text-text-muted dark:text-slate-400 mb-2 font-medium">Resource ID</Text>
{guildIdError && (
<Text className="text-error dark:text-red-400 text-sm mt-1">{guildIdError}</Text>
)}
Expand All @@ -427,6 +460,7 @@ export default function AccessCheck() {
accessibilityHint="Enter the resource identifier"
testID="access-check-resource-id-input"
/>
{resourceIdError && <Text className="text-error dark:text-red-400 text-sm mt-1">{resourceIdError}</Text>}
{resourceIdError && (
<Text className="text-error dark:text-red-400 text-sm mt-1">{resourceIdError}</Text>
)}
Expand Down Expand Up @@ -468,6 +502,7 @@ export default function AccessCheck() {
accessibilityLabel="Wallet address mismatch warning. This QR payload uses a different wallet address from your connected wallet."
>
<Text className="text-primary font-bold">Wallet address mismatch</Text>
<Text className="text-text dark:text-slate-100 text-sm mt-2">{walletMismatchWarning}</Text>
<Text className="text-text dark:text-slate-100 text-sm mt-2">
{walletMismatchWarning}
</Text>
Expand Down Expand Up @@ -512,6 +547,11 @@ export default function AccessCheck() {
</Text>
<View className="flex-row justify-between py-1">
<Text className="text-text-muted dark:text-slate-400">Guild ID</Text>
<Text className="text-text dark:text-slate-100 font-medium">{scannedPayload.guildId}</Text>
</View>
<View className="flex-row justify-between py-1">
<Text className="text-text-muted dark:text-slate-400">Resource ID</Text>
<Text className="text-text dark:text-slate-100 font-medium">{scannedPayload.resourceId}</Text>
<Text className="text-text dark:text-slate-100 font-medium">
{scannedPayload.guildId}
</Text>
Expand Down Expand Up @@ -556,6 +596,7 @@ export default function AccessCheck() {
}}
>
{countdown.isExpired && scannedPayload?.expiresAt && (
<Card className="mb-12 border-2 border-error bg-error/5 dark:border-red-600 dark:bg-red-900/30" accessibilityRole="alert">
<Card
className="mb-12 border-2 border-error bg-error/5 dark:border-red-600 dark:bg-red-900/30"
accessibilityRole="alert"
Expand Down
64 changes: 64 additions & 0 deletions app/access-scanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import * as Linking from "expo-linking";
import { AppHeader } from "../src/components/AppHeader";
import { Button } from "../src/components/Button";
import { Card } from "../src/components/Card";
import { verifyAndParseAccessQrPayload, QrValidationResult } from "../src/features/access/verifyQrPayload";
import { describeQrSignatureError, QR_SIGNATURE_ERROR_CODES, QrSignatureErrorCode, QrSignatureError } from "../src/features/access/qrSignature";
import { describeQrPayloadError, QR_PAYLOAD_ERROR_CODES, QrPayloadErrorCode, QrPayloadError } from "../src/features/access/qrPayload";
import { verifyAndParseAccessQrPayload } from "../src/features/access/verifyQrPayload";
import { describeQrSignatureError, QrSignatureError, QR_SIGNATURE_ERROR_CODES } from "../src/features/access/qrSignature";
import {
Expand Down Expand Up @@ -76,6 +79,7 @@ const isRecoverableError = (error: unknown): boolean => {
export default function AccessScanner() {
const router = useRouter();
const [permission, requestPermission] = useCameraPermissions();
const [scanError, setScanError] = useState<{ message: string; isUntrusted: boolean } | null>(null);
const [scanError, setScanError] = useState<{ message: string; isUntrusted: boolean } | null>(
null,
);
Expand Down Expand Up @@ -140,6 +144,19 @@ export default function AccessScanner() {
setScanError(null);
AccessibilityInfo.announceForAccessibility("Processing access QR code.");

const result = await verifyAndParseAccessQrPayload(data);

try {
AccessibilityInfo.announceForAccessibility("Processing access QR code.");
await verifyAndParseAccessQrPayload(data);
setVerificationSuccess(true);
AccessibilityInfo.announceForAccessibility("Signature verified. Opening access check.");

setTimeout(() => {
setVerificationSuccess(false);
router.replace({ pathname: "/access-check", params: { qrPayload: data } });
}, 1500);
return;
try {
AccessibilityInfo.announceForAccessibility("Processing access QR code.");
const result = await verifyAndParseAccessQrPayload(data);
Expand Down Expand Up @@ -194,6 +211,7 @@ export default function AccessScanner() {

if (error instanceof QrSignatureError) {
errorMessage = describeQrSignatureError(error.code);
isUntrusted = true;
isUntrusted = !isRecoverableError(error);
} else if (error instanceof QrPayloadError) {
if (
Expand All @@ -208,6 +226,8 @@ export default function AccessScanner() {
} else {
errorMessage = error.message;
}
} else if (error instanceof Error) {
errorMessage = error.message;
errorMessage = describeQrPayloadError(error.code);
isUntrusted = !isRecoverableError(error);
}
Expand Down Expand Up @@ -244,6 +264,7 @@ export default function AccessScanner() {
<AppHeader title="Scan Access QR" showBack />
<View className="flex-1 px-4 py-6">
<Card>
<Text accessibilityLiveRegion="polite" className="text-text-muted dark:text-slate-400">Checking camera permission...</Text>
<Text accessibilityLiveRegion="polite" className="text-text-muted dark:text-slate-400">
Checking camera permission...
</Text>
Expand All @@ -266,6 +287,9 @@ export default function AccessScanner() {
<AppHeader title="Scan Access QR" showBack />
<View className="flex-1 px-4 py-6">
<Card>
<Text accessibilityRole="header" className="text-xl font-bold text-text dark:text-slate-100 mb-2">Camera access needed</Text>
<Text accessibilityLiveRegion="polite" className="text-text-muted dark:text-slate-400 mb-6">
GuildPass needs camera permission to scan access check QR codes.
<Text
accessibilityRole="header"
className="text-xl font-bold text-text dark:text-slate-100 mb-2"
Expand All @@ -291,6 +315,20 @@ export default function AccessScanner() {
onPress={requestPermission}
/>
) : (
<>
<Text accessibilityRole="alert" accessibilityLiveRegion="assertive" className="text-error dark:text-red-400">
Camera permission was denied. Enable camera access in your device settings to scan
QR codes.
</Text>
<Text accessibilityRole="alert" accessibilityLiveRegion="assertive" className="text-error dark:text-red-400 mb-6">
Camera permission was denied. Open Settings to enable camera access for GuildPass.
</Text>
<Button
title="Open Settings"
onPress={() => Linking.openSettings()}
variant="outline"
/>
</>
<Button
title="Open Settings"
onPress={() => Linking.openSettings()}
Expand All @@ -305,6 +343,10 @@ export default function AccessScanner() {

if (isProcessingScan) {
return (
<View accessibilityLabel="Processing access QR code" accessibilityState={{ busy: true }} className="flex-1 bg-background dark:bg-slate-900 justify-center items-center">
<AppHeader title="Scan Access QR" showBack />
<ActivityIndicator size="large" accessibilityLabel="Processing access QR code" accessibilityLiveRegion="polite" />
<Text accessibilityLiveRegion="polite" className="mt-4 text-text dark:text-slate-100">Processing...</Text>
<View
accessibilityLabel="Verifying access QR code"
accessibilityState={{ busy: true }}
Expand Down Expand Up @@ -334,6 +376,15 @@ export default function AccessScanner() {

if (verificationSuccess) {
return (
<View accessibilityLabel="Signature verified" accessibilityState={{ busy: true }} className="flex-1 bg-background dark:bg-slate-900 justify-center items-center">
<AppHeader title="Scan Access QR" showBack />
<View className="flex-1 px-4 py-6 justify-center w-full">
<Card className="border-success dark:border-green-600 bg-success/5 dark:bg-green-900/30 items-center py-8">
<Text className="text-success dark:text-green-400 text-4xl mb-4 font-bold">βœ“</Text>
<Text className="text-success dark:text-green-400 font-bold text-xl">Signature verified</Text>
<Text className="text-success/80 dark:text-green-400/80 mt-2 text-center">Redirecting to access check...</Text>
</Card>
</View>
<View
accessibilityLabel="QR code verified successfully"
className="flex-1 bg-background dark:bg-slate-900 justify-center items-center"
Expand Down Expand Up @@ -375,6 +426,15 @@ export default function AccessScanner() {
<View className="flex-1 bg-background dark:bg-slate-900">
<AppHeader title="Scan Access QR" showBack />
<View className="flex-1 px-4 py-6">
<Card className={isUntrusted ? "border-amber-500 bg-amber-500/10 dark:border-amber-600 dark:bg-amber-900/30" : "border-error bg-error/5 dark:border-red-600 dark:bg-red-900/30"}>
<Text accessibilityRole="alert" accessibilityLiveRegion="assertive" className={isUntrusted ? "text-amber-600 dark:text-amber-400 font-bold text-lg" : "text-error dark:text-red-400 font-bold text-lg"}>
{isUntrusted ? "Untrusted QR code" : "QR code rejected"}
</Text>
<Text accessibilityRole="alert" accessibilityLiveRegion="assertive" className={isUntrusted ? "text-amber-700/80 dark:text-amber-300/80 text-sm mt-1 mb-4" : "text-error/80 dark:text-red-300/80 text-sm mt-1 mb-4"}>
{scanError.message}
</Text>
<Button title="Scan Again" onPress={handleScanAgain} variant="outline" />
</Card>
<Animated.View
style={{
transform: [{ translateY: errorSlide }],
Expand Down Expand Up @@ -457,6 +517,7 @@ export default function AccessScanner() {
/>
<View className="absolute left-4 right-4 bottom-4">
<Card className="mb-4">
<Text accessibilityLiveRegion="polite" className="text-text dark:text-slate-100 font-medium text-center">
<Text
accessibilityLiveRegion="polite"
className="text-text dark:text-slate-100 font-medium text-center"
Expand Down Expand Up @@ -488,6 +549,9 @@ export default function AccessScanner() {
<Button
title="Test: Malformed QR"
onPress={() => {
setScanError({ message: "This QR code has expired.", isUntrusted: false });
setIsProcessingScan(false);
scanInProgressRef.current = true;
void handleScanData(TEST_QR_PAYLOADS.malformedJson);
}}
className="py-2 px-3"
Expand Down
5 changes: 5 additions & 0 deletions app/deep-link-error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,16 @@ export default function DeepLinkError() {
"The link you followed is not supported or is malformed. Please check the URL and try again.";

return (
<View className="flex-1 bg-background dark:bg-slate-900">
<View className="flex-1 bg-background dark:bg-slate-900" testID="deep-link-error-screen">
<AppHeader title="Link Error" showBack={false} />
<View className="flex-1 px-4 py-6 justify-center">
<Card className="items-center py-8">
<Text className="text-4xl mb-4">πŸ”—</Text>
<Text className="text-2xl font-bold text-text dark:text-slate-100 mb-3 text-center">Invalid Link</Text>
<Text className="text-text-muted dark:text-slate-400 text-center mb-6 px-4">
The link you followed is not supported or is malformed. Please check the URL and try
again.
<Text
className="text-2xl font-bold text-text dark:text-slate-100 mb-3 text-center"
testID="deep-link-error-title"
Expand Down
56 changes: 56 additions & 0 deletions app/guilds.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,24 @@ import { useQueryClient } from "@tanstack/react-query";

export default function Guilds() {
const router = useRouter();
const colorScheme = useColorScheme();
const { walletAddress, disconnect } = useWallet();
const { useEnrichedMemberships } = useMembership(walletAddress);
const membershipsQuery = useEnrichedMemberships();
const { data: memberships, isLoading, error } = membershipsQuery;
const staleState = useStaleQuery(membershipsQuery);
const { walletAddress } = useWallet();
const { useWalletGuilds } = useGuilds();
const guildsQuery = useWalletGuilds(walletAddress);

if (!walletAddress) {
return (
<WalletRequired>
<View className="flex-1 bg-background dark:bg-slate-900" testID="guilds-screen">
<AppHeader title="My Guilds" showBack />
<GuildListSkeleton />
</View>
</WalletRequired>
<View className="flex-1 bg-background" testID="guilds-screen">
<AppHeader title="My Guilds" showBack />
<EmptyState
Expand All @@ -36,6 +48,15 @@ export default function Guilds() {

if (guildsQuery.isLoading) {
return (
<WalletRequired>
<View className="flex-1 bg-background dark:bg-slate-900" testID="guilds-screen">
<AppHeader title="My Guilds" showBack />
{staleState.isOffline ? (
<StaleDataBanner reason="offline" lastSyncedAt={staleState.lastSyncedAt} />
) : null}
<ErrorState message="Failed to load memberships" />
</View>
</WalletRequired>
<View className="flex-1 bg-background" testID="guilds-screen">
<AppHeader title="My Guilds" showBack />
<LoadingState message="Loading your guilds..." />
Expand All @@ -45,6 +66,41 @@ export default function Guilds() {

if (guildsQuery.isError) {
return (
<WalletRequired>
<View className="flex-1 bg-background dark:bg-slate-900" testID="guilds-screen">
<AppHeader title="My Guilds" showBack />
{staleState.isOffline ? (
<StaleDataBanner reason="offline" lastSyncedAt={staleState.lastSyncedAt} />
) : null}
<EmptyMembershipsState onConnectDifferentWallet={handleConnectDifferentWallet} />
</View>
</WalletRequired>
);
}

const staleBanner = staleState.isOffline ? (
<StaleDataBanner reason="offline" lastSyncedAt={staleState.lastSyncedAt} />
) : staleState.isStale && staleState.reason ? (
<StaleDataBanner reason={staleState.reason} lastSyncedAt={staleState.lastSyncedAt} />
) : null;

const searchHeader = (
<View>
{staleBanner}
<View className="px-4 pt-2 pb-1">
<View className="flex-row items-center bg-white dark:bg-slate-800 rounded-xl px-4 py-3 border border-border dark:border-slate-700">
<Text className="text-text-muted dark:text-slate-400 mr-2">πŸ”</Text>
<TextInput
className="flex-1 text-text dark:text-slate-100 text-base"
placeholder="Search guilds..."
placeholderTextColor={colorScheme === 'dark' ? '#94a3b8' : '#9ca3af'}
value={searchQuery}
onChangeText={setSearchQuery}
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
testID="guild-search-input"
accessibilityLabel="Search guilds by name"
<View className="flex-1 bg-background" testID="guilds-screen">
<AppHeader title="My Guilds" showBack />
<ErrorState
Expand Down
Loading