mirror of
https://github.com/colanode/colanode.git
synced 2026-05-18 13:15:12 +02:00
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:
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }]}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user