Skip to content
Open
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
6 changes: 6 additions & 0 deletions src/application/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@
"roles": {
"Read": "Reader",
"Write": "Writer"
},
"removeMemberConfirmationTitle": "Remove member",
"removeMemberConfirmationBody": "Are you sure you want to remove '{username}' from the team?",
"contextMenu": {
"title": "More actions",
"remove": "Remove"
}
}
},
Expand Down
19 changes: 19 additions & 0 deletions src/application/services/useNoteSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ interface UseNoteSettingsComposableState {
* @param newParentURL - New parent note URL
*/
setParent: (id: NoteId, newParentURL: string) => Promise<void>;

/**
* Delete team member by user id
* @param id - Note id
* @param userId - User id
* @returns true if user was removed
*/
removeMemberByUserId: (id: NoteId, userId: UserId) => Promise<boolean>;
}

/**
Expand Down Expand Up @@ -188,6 +196,16 @@ export default function (): UseNoteSettingsComposableState {
}
};

/**
* Delete team member by user id
* @param id - Note id
* @param userId - User id
* @returns true if user was removed
*/
const removeMemberByUserId = async (id: NoteId, userId: UserId): Promise<boolean> => {
return await noteSettingsService.removeMemberByUserId(id, userId);
};

return {
updateCover,
setParent,
Expand All @@ -198,5 +216,6 @@ export default function (): UseNoteSettingsComposableState {
revokeHash,
changeRole,
deleteNoteById,
removeMemberByUserId,
};
}
8 changes: 8 additions & 0 deletions src/domain/noteSettings.repository.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,12 @@ export default interface NoteSettingsRepositoryInterface {
* @param id - Note id
*/
deleteNote(id: NoteId): Promise<void>;

/**
* Delete team member by user id
* @param id - Note id
* @param userId - User id
* @returns true if user was removed
*/
removeMemberByUserId(id: NoteId, userId: UserId): Promise<boolean>;
}
10 changes: 10 additions & 0 deletions src/domain/noteSettings.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,14 @@ export default class NoteSettingsService {
public async deleteNote(id: NoteId): Promise<void> {
return await this.noteSettingsRepository.deleteNote(id);
}

/**
* Delete team member by user id
* @param id - Note id
* @param userId - User id
* @returns true if user was removed
*/
public async removeMemberByUserId(id: NoteId, userId: UserId): Promise<boolean> {
return await this.noteSettingsRepository.removeMemberByUserId(id, userId);
}
}
13 changes: 13 additions & 0 deletions src/infrastructure/noteSettings.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,17 @@ export default class NoteSettingsRepository implements NoteSettingsRepositoryInt
public async deleteNote(id: NoteId): Promise<void> {
await this.transport.delete<boolean>(`/note/` + id);
}

/**
* Delete team member by user id
* @param id - Note id
* @param userId - User id
* @returns true if user was removed
*/
public async removeMemberByUserId(id: NoteId, userId: UserId): Promise<boolean> {
const data = { userId };
const response = await this.transport.delete<number>(`/note-settings/${id}/team`, data);

return response === userId;
}
}
104 changes: 104 additions & 0 deletions src/presentation/components/team/MoreActions.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<template>
<button
ref="triggerButton"
:title="t('noteSettings.team.contextMenu.title')"
class="more-actions-button"
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The icon-only needs an accessible name and should explicitly set type="button" to avoid default submit behavior when used inside a . Consider adding an aria-label/title (e.g., "More actions") and type="button" on this button element.

Suggested change
class="more-actions-button"
class="more-actions-button"
type="button"
:aria-label="t('noteSettings.team.moreActions')"
:title="t('noteSettings.team.moreActions')"

Copilot uses AI. Check for mistakes.
@click="handleButtonClick"
>
<Icon
name="EtcVertical"
/>
</button>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { Icon, ContextMenu, usePopover, useConfirm, type ContextMenuItem } from '@codexteam/ui/vue';
import { type TeamMember } from '@/domain/entities/Team';
import { useI18n } from 'vue-i18n';
import { NoteId } from '@/domain/entities/Note';
import useNoteSettings from '@/application/services/useNoteSettings';

const { removeMemberByUserId } = useNoteSettings();
Comment on lines +20 to +22
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MoreActions is instantiated per team member row, but it calls useNoteSettings(), which creates its own composable state (noteSettings refs, parentNote refs, router, etc.) per instance. This is unnecessary overhead and can lead to duplicated state; consider passing removeMemberByUserId down from the parent, or extracting a lighter-weight service call that doesn't allocate the full composable state for each row.

Copilot uses AI. Check for mistakes.

const props = defineProps<{
/**
* Team member data
*/
teamMember: TeamMember;
/**
* Id of the current note
*/
noteId: NoteId;
}>();

const { t } = useI18n();
const { showPopover, hide } = usePopover();
const { confirm } = useConfirm();

const triggerButton = ref<HTMLButtonElement>();

const menuItems: ContextMenuItem[] = [
{
title: t('noteSettings.team.contextMenu.remove'),
onActivate: async () => {
hide();
await handleRemove(props.teamMember);
},
},
];

const emit = defineEmits<{
teamMemberRemoved: [userId: TeamMember['user']['id']];
}>();

const handleButtonClick = (): void => {
if (triggerButton.value) {
showPopover({
targetEl: triggerButton.value,
with: {
component: ContextMenu,
props: {
items: menuItems,
},
},
align: {
vertically: 'below',
horizontally: 'right',
},
width: 'auto',
});
}
};

/**
* Remove team member by user id
*
* @param member - team member to remove
*/
const handleRemove = async (member: TeamMember): Promise<void> => {
const shouldRemove = await confirm(
t('noteSettings.team.removeMemberConfirmationTitle'),
t('noteSettings.team.removeMemberConfirmationBody', { username: member.user.name })
);

if (shouldRemove) {
const isDeleted = await removeMemberByUserId(props.noteId, member.user.id);

if (isDeleted) {
emit('teamMemberRemoved', member.user.id);
}
}
};
</script>

<style scoped>
.more-actions-button {
color: var(--ct-text-color-primary);
display: flex;
align-items: center;
justify-content: center;
border: none;
cursor: pointer;
}
</style>
17 changes: 16 additions & 1 deletion src/presentation/components/team/Team.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
:note-id="noteId"
:team-member="member"
/>
<MoreActions
:note-id="noteId"
:team-member="member"
@team-member-removed="handleMemberRemoved"
/>
Comment on lines +18 to +22
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MoreActions is rendered for every team member without any permission/self/creator checks. Per the PR description, users should only be able to remove other members and only when they have "write" access; additionally, creator/self removal typically should be blocked in UI. Consider conditionally rendering/disabling this component based on current user role/ID and note creator ID (similar to RoleSelect).

Copilot uses AI. Check for mistakes.
</template>

<template #left>
Expand All @@ -34,12 +39,13 @@

<script setup lang="ts">
import { computed } from 'vue';
import { Team, MemberRole } from '@/domain/entities/Team';
import { Team, MemberRole, TeamMember } from '@/domain/entities/Team';
import { Note, NoteId } from '@/domain/entities/Note';
import { Section, Row, Avatar } from '@codexteam/ui/vue';
import RoleSelect from './RoleSelect.vue';
import { useI18n } from 'vue-i18n';
import useNote from '@/application/services/useNote.ts';
import MoreActions from './MoreActions.vue';

const props = defineProps<{
/**
Expand All @@ -52,6 +58,10 @@ const props = defineProps<{
noteId: NoteId;
}>();

const emit = defineEmits<{
teamMemberRemoved: [id: TeamMember['user']['id']];
}>();

const { t } = useI18n();
const { note } = useNote({ id: props.noteId });

Expand Down Expand Up @@ -79,6 +89,11 @@ const sortedTeam = computed(() => {
return roleOrder[a.role] - roleOrder[b.role];
});
});

// Listen for teamMemberRemoved event from child component and bubble them up
const handleMemberRemoved = (userId: TeamMember['user']['id']): void => {
emit('teamMemberRemoved', userId);
};
</script>

<style scoped>
Expand Down
15 changes: 15 additions & 0 deletions src/presentation/pages/NoteSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
<Team
:note-id="id"
:team="noteSettings.team"
@team-member-removed="handleTeamMemberRemoved"
/>
<InviteLink
:id="props.id"
Expand Down Expand Up @@ -111,6 +112,7 @@ import { getTimeFromNow } from '@/infrastructure/utils/date';
import InviteLink from '@/presentation/components/noteSettings/InviteLink.vue';
import useNavbar from '@/application/services/useNavbar';
import { useRoute } from 'vue-router';
import { TeamMember } from '@/domain/entities/Team';

const { t } = useI18n();

Expand Down Expand Up @@ -221,6 +223,19 @@ onMounted(async () => {
parentURL.value = getParentURL(parentNote.value?.id);
});

/**
* Handle team member removal by refreshing the note settings and removing the member from the team
*
* @param userId - user id of the member to remove
*/
async function handleTeamMemberRemoved(userId: TeamMember['user']['id']) {
if (noteSettings.value !== null) {
noteSettings.value = {
...noteSettings.value,
team: noteSettings.value.team.filter(member => member.user.id !== userId),
};
}
}
</script>

<style setup lang="postcss" scoped>
Expand Down
Loading