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
5 changes: 5 additions & 0 deletions src/application/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@
"roles": {
"Read": "Reader",
"Write": "Writer"
},
"removeMemberConfirmationTitle": "Remove member",
"removeMemberConfirmationBody": "Are you sure you want to remove user from the team?",
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.

Grammar: the confirmation text is missing an article and reads a bit awkwardly.

Suggested change
"removeMemberConfirmationBody": "Are you sure you want to remove user from the team?",
"removeMemberConfirmationBody": "Are you sure you want to remove the user from the team?",

Copilot uses AI. Check for mistakes.
"ContextMenu": {
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 i18n key "ContextMenu" uses PascalCase, which is inconsistent with the surrounding noteSettings.team keys (mostly lowerCamelCase). Consider renaming it to "contextMenu" (or similar) and updating usages (e.g., t('noteSettings.team.contextMenu.remove')) to keep the messages structure consistent.

Suggested change
"ContextMenu": {
"contextMenu": {

Copilot uses AI. Check for mistakes.
"remove": "Remove"
}
}
},
Expand Down
17 changes: 17 additions & 0 deletions src/application/services/useNoteSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ 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
*/
removeMemberByUserId: (id: NoteId, userId: UserId) => Promise<void>;
}

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

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

return {
updateCover,
setParent,
Expand All @@ -198,5 +214,6 @@ export default function (): UseNoteSettingsComposableState {
revokeHash,
changeRole,
deleteNoteById,
removeMemberByUserId,
};
}
7 changes: 7 additions & 0 deletions src/domain/noteSettings.repository.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,11 @@ 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
*/
removeMemberByUserId(id: NoteId, userId: UserId): Promise<void>;
}
9 changes: 9 additions & 0 deletions src/domain/noteSettings.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,13 @@ 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
*/
public async removeMemberByUserId(id: NoteId, userId: UserId): Promise<void> {
return await this.noteSettingsRepository.removeMemberByUserId(id, userId);
}
}
11 changes: 11 additions & 0 deletions src/infrastructure/noteSettings.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,15 @@ 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
*/
public async removeMemberByUserId(id: NoteId, userId: UserId): Promise<void> {
const data = { userId };

await this.transport.delete<boolean>(`/note-settings/${id}/team`, data);
}
}
100 changes: 100 additions & 0 deletions src/presentation/components/team/MoreActions.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<template>
<button
ref="triggerButton"
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 +19 to +21
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: () => {
handleRemove(props.teamMember);
hide();
Comment on lines +43 to +45
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.

This callback triggers the async removal flow but does not await or handle errors from handleRemove(). If removeMemberByUserId throws (network/403/etc.), this can become an unhandled promise rejection and the user gets no feedback. Please make the activation path handle the promise (e.g., mark onActivate async and await, or attach a .catch that shows an error).

Suggested change
onActivate: () => {
handleRemove(props.teamMember);
hide();
onActivate: async () => {
hide();
try {
await handleRemove(props.teamMember);
} catch (error) {
console.error('Failed to remove team member', error);
}

Copilot uses AI. Check for mistakes.
},
},
];

const emit = defineEmits<{
teamMemberRemoved: [];
}>();

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')
);

if (shouldRemove) {
await removeMemberByUserId(props.noteId, member.user.id);
emit('teamMemberRemoved');
}
};
</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>
15 changes: 15 additions & 0 deletions 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 @@ -40,6 +45,7 @@ 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: [];
}>();

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 = () => {
emit('teamMemberRemoved');
};
</script>

<style scoped>
Expand Down
8 changes: 8 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 @@ -221,6 +222,13 @@ onMounted(async () => {
parentURL.value = getParentURL(parentNote.value?.id);
});

/**
* Handle team member removal by refreshing the note settings
*/
async function handleTeamMemberRemoved() {
Copy link
Collaborator

Choose a reason for hiding this comment

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

i think that instead of reloading the page completely we could just update noteSettings composable

then we would need to handle boolean that is returned by the api endpoint

await loadSettings(props.id);
}

</script>

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