Mobile: use node-level permissions for UI action gating (#342)

Replace workspace-role checks with node-role checks derived from the
root space's collaborators map. This aligns mobile with the shared
permission model used by web/desktop: rename requires editor, delete
requires admin, create children requires editor, and message deletion
is allowed for the author or any admin.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ylber Gashi
2026-03-14 13:30:40 +01:00
committed by GitHub
parent 7c0a226bc4
commit 6bfa81f305
7 changed files with 90 additions and 40 deletions

View File

@@ -12,7 +12,7 @@ import {
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { WorkspaceRole } from '@colanode/core';
import { hasWorkspaceRole, WorkspaceRole } from '@colanode/core';
import { BackButton } from '@colanode/mobile/components/ui/back-button';
import { Button } from '@colanode/mobile/components/ui/button';
import { TextInput } from '@colanode/mobile/components/ui/text-input';
@@ -34,7 +34,8 @@ export default function InviteScreen() {
const router = useRouter();
const { colors } = useTheme();
const insets = useSafeAreaInsets();
const { userId } = useWorkspace();
const { userId, role: workspaceRole } = useWorkspace();
const canInvite = hasWorkspaceRole(workspaceRole, 'admin');
const { mutate, isPending } = useMutation();
const [email, setEmail] = useState('');
@@ -124,6 +125,25 @@ export default function InviteScreen() {
});
};
if (!canInvite) {
return (
<View style={[styles.flex, { backgroundColor: colors.background }]}>
<View style={[styles.header, { paddingTop: insets.top + 8 }]}>
<BackButton onPress={() => router.back()} />
<Text style={[styles.headerTitle, { color: colors.text }]}>
Invite Members
</Text>
<View style={styles.headerSpacer} />
</View>
<View style={styles.permissionDenied}>
<Text style={[styles.permissionDeniedText, { color: colors.textMuted }]}>
You do not have permission to invite members.
</Text>
</View>
</View>
);
}
return (
<KeyboardAvoidingView
style={[styles.flex, { backgroundColor: colors.background }]}
@@ -341,4 +361,14 @@ const styles = StyleSheet.create({
fontSize: 14,
fontWeight: '500',
},
permissionDenied: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 32,
},
permissionDeniedText: {
fontSize: 15,
textAlign: 'center',
},
});

View File

@@ -1,9 +1,11 @@
import { useLocalSearchParams, useRouter } from 'expo-router';
import { LocalChannelNode } from '@colanode/client/types/nodes';
import { hasNodeRole } from '@colanode/core';
import { ConversationScreen } from '@colanode/mobile/components/conversation/conversation-screen';
import { useWorkspace } from '@colanode/mobile/contexts/workspace';
import { useNodeQuery } from '@colanode/mobile/hooks/use-node-query';
import { useNodeRole } from '@colanode/mobile/hooks/use-node-role';
export default function ChannelScreen() {
const router = useRouter();
@@ -11,13 +13,16 @@ export default function ChannelScreen() {
const { userId } = useWorkspace();
const { data: channel } = useNodeQuery<LocalChannelNode>(userId, channelId, 'channel');
const { role: nodeRole, canEdit: canRename } = useNodeRole(userId, channel);
const isAdmin = nodeRole !== null && hasNodeRole(nodeRole, 'admin');
return (
<ConversationScreen
nodeId={channelId!}
isAdmin={isAdmin}
title={`# ${channel?.name ?? 'Channel'}`}
onGoBack={() => router.back()}
renamableNode={channel}
renamableNode={canRename ? channel : undefined}
/>
);
}

View File

@@ -5,7 +5,7 @@ import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { LocalFolderNode, LocalNode } from '@colanode/client/types/nodes';
import { hasWorkspaceRole } from '@colanode/core';
import { hasNodeRole } from '@colanode/core';
import { NodeActionSheet } from '@colanode/mobile/components/nodes/node-action-sheet';
import { NodeChildList } from '@colanode/mobile/components/nodes/node-child-list';
import { RenameNodeSheet } from '@colanode/mobile/components/nodes/rename-node-sheet';
@@ -15,6 +15,7 @@ import { useWorkspace } from '@colanode/mobile/contexts/workspace';
import { useFolderFileUpload } from '@colanode/mobile/hooks/use-folder-file-upload';
import { useNodeListQuery } from '@colanode/mobile/hooks/use-node-list-query';
import { useNodeQuery } from '@colanode/mobile/hooks/use-node-query';
import { useNodeRole } from '@colanode/mobile/hooks/use-node-role';
import { navigateToNode } from '@colanode/mobile/lib/navigation-utils';
import { getNodeDisplayName } from '@colanode/mobile/lib/node-utils';
@@ -22,7 +23,7 @@ export default function FolderScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const { folderId } = useLocalSearchParams<{ folderId: string }>();
const { userId, role } = useWorkspace();
const { userId } = useWorkspace();
const { colors } = useTheme();
const [showRename, setShowRename] = useState(false);
const [actionNode, setActionNode] = useState<LocalNode | null>(null);
@@ -36,21 +37,30 @@ export default function FolderScreen() {
[{ field: ['createdAt'], direction: 'asc', nulls: 'last' }]
);
const canDelete = role === 'owner' || role === 'admin';
const canUploadFiles = hasWorkspaceRole(role, 'collaborator');
const { role: nodeRole, canEdit: canRename } = useNodeRole(userId, folder);
const canDelete = nodeRole !== null && hasNodeRole(nodeRole, 'admin');
const canUploadFiles = canRename;
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<View style={[styles.header, { paddingTop: insets.top + 8, borderBottomColor: colors.border }]}>
<BackButton onPress={() => router.back()} />
<Pressable
onPress={() => folder && setShowRename(true)}
style={styles.headerTitleContainer}
>
<Text style={[styles.headerTitle, { color: colors.text }]} numberOfLines={1}>
{folder?.name ?? 'Folder'}
</Text>
</Pressable>
{canRename ? (
<Pressable
onPress={() => folder && setShowRename(true)}
style={styles.headerTitleContainer}
>
<Text style={[styles.headerTitle, { color: colors.text }]} numberOfLines={1}>
{folder?.name ?? 'Folder'}
</Text>
</Pressable>
) : (
<View style={styles.headerTitleContainer}>
<Text style={[styles.headerTitle, { color: colors.text }]} numberOfLines={1}>
{folder?.name ?? 'Folder'}
</Text>
</View>
)}
{canUploadFiles ? (
<Pressable
style={[styles.addButton, { backgroundColor: colors.primary }]}

View File

@@ -58,7 +58,6 @@ const ReadOnlyPageView = ({
const router = useRouter();
const { colors } = useTheme();
const insets = useSafeAreaInsets();
const [showRename, setShowRename] = useState(false);
const { data: document, isLoading, refetch, isRefetching } = useLiveQuery({
type: 'document.get',
@@ -86,14 +85,11 @@ const ReadOnlyPageView = ({
<View style={[styles.container, { backgroundColor: colors.background }]}>
<View style={[styles.header, { borderBottomColor: colors.border, paddingTop: insets.top + 8 }]}>
<BackButton onPress={() => router.back()} />
<Pressable
onPress={() => page && setShowRename(true)}
style={styles.headerTitleContainer}
>
<View style={styles.headerTitleContainer}>
<Text style={[styles.headerTitle, { color: colors.text }]} numberOfLines={1}>
{page?.name ?? 'Page'}
</Text>
</Pressable>
</View>
<View style={styles.headerSpacer} />
</View>
<ScrollView
@@ -109,12 +105,6 @@ const ReadOnlyPageView = ({
<Text style={[styles.emptyText, { color: colors.textMuted }]}>This page is empty</Text>
)}
</ScrollView>
<RenameNodeSheet
visible={showRename}
node={page ?? null}
userId={userId}
onClose={() => setShowRename(false)}
/>
</View>
);
};

View File

@@ -5,7 +5,7 @@ import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { LocalNode, LocalSpaceNode } from '@colanode/client/types/nodes';
import { hasWorkspaceRole } from '@colanode/core';
import { extractNodeRole, hasNodeRole } from '@colanode/core';
import { CreateNodeSheet } from '@colanode/mobile/components/nodes/create-node-sheet';
import { NodeActionSheet } from '@colanode/mobile/components/nodes/node-action-sheet';
import { NodeChildList } from '@colanode/mobile/components/nodes/node-child-list';
@@ -22,7 +22,7 @@ export default function SpaceScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const { spaceId } = useLocalSearchParams<{ spaceId: string }>();
const { userId, role } = useWorkspace();
const { userId } = useWorkspace();
const { colors } = useTheme();
const [showCreate, setShowCreate] = useState(false);
const [showRename, setShowRename] = useState(false);
@@ -36,21 +36,31 @@ export default function SpaceScreen() {
[{ field: ['createdAt'], direction: 'asc', nulls: 'last' }]
);
const canDelete = role === 'owner' || role === 'admin';
const canCreateChildren = hasWorkspaceRole(role, 'collaborator');
const nodeRole = space ? extractNodeRole(space, userId) : null;
const canDelete = nodeRole !== null && hasNodeRole(nodeRole, 'admin');
const canRename = canDelete;
const canCreateChildren = nodeRole !== null && hasNodeRole(nodeRole, 'editor');
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<View style={[styles.header, { paddingTop: insets.top + 8, borderBottomColor: colors.border }]}>
<BackButton onPress={() => router.back()} />
<Pressable
onPress={() => space && setShowRename(true)}
style={styles.headerTitleContainer}
>
<Text style={[styles.headerTitle, { color: colors.text }]} numberOfLines={1}>
{space?.name ?? 'Space'}
</Text>
</Pressable>
{canRename ? (
<Pressable
onPress={() => space && setShowRename(true)}
style={styles.headerTitleContainer}
>
<Text style={[styles.headerTitle, { color: colors.text }]} numberOfLines={1}>
{space?.name ?? 'Space'}
</Text>
</Pressable>
) : (
<View style={styles.headerTitleContainer}>
<Text style={[styles.headerTitle, { color: colors.text }]} numberOfLines={1}>
{space?.name ?? 'Space'}
</Text>
</View>
)}
<View style={styles.headerSpacer} />
</View>
<NodeChildList

View File

@@ -33,6 +33,7 @@ import { getMessageText } from '@colanode/mobile/lib/message-utils';
interface ConversationScreenProps {
nodeId: string;
isAdmin?: boolean;
title: string;
onGoBack: () => void;
renamableNode?: LocalNode | null;
@@ -40,6 +41,7 @@ interface ConversationScreenProps {
export const ConversationScreen = ({
nodeId,
isAdmin = false,
title,
onGoBack,
renamableNode,
@@ -187,6 +189,7 @@ export const ConversationScreen = ({
message={actionTarget?.message ?? null}
authorName={actionTarget?.authorName ?? ''}
isOwnMessage={actionTarget?.message.createdBy === userId}
canDelete={actionTarget?.message.createdBy === userId || isAdmin}
userId={userId}
onClose={() => setActionTarget(null)}
onReply={handleReply}

View File

@@ -16,6 +16,7 @@ interface MessageActionSheetProps {
message: LocalMessageNode | null;
authorName: string;
isOwnMessage: boolean;
canDelete: boolean;
userId: string;
onClose: () => void;
onReply: () => void;
@@ -28,6 +29,7 @@ export const MessageActionSheet = ({
visible,
message,
isOwnMessage,
canDelete,
userId,
onClose,
onReply,
@@ -101,7 +103,7 @@ export const MessageActionSheet = ({
</Pressable>
)}
{isOwnMessage && (
{canDelete && (
<Pressable
style={({ pressed }) => [styles.action, pressed && { backgroundColor: colors.surfaceHover }]}
onPress={handleDelete}