From 04cef57489fad86ac006d4d3a861d5a0d377ce6b Mon Sep 17 00:00:00 2001 From: Kostiantyn Miakshyn Date: Fri, 28 Nov 2025 16:55:05 +0100 Subject: [PATCH] feat: Create invitation links Signed-off-by: Kostiantyn Miakshyn --- appinfo/routes.php | 1 + lib/Controller/PageController.php | 13 ++ .../CircleDetails/CircleConfigs.vue | 56 +------- .../CircleConfigCheckboxesList.vue | 88 ++++++++++++ .../CircleConfigInvitationLink.vue | 119 ++++++++++++++++ .../CircleDetails/CircleSettings.vue | 56 +------- src/components/JoinInvitation.vue | 127 ++++++++++++++++++ src/join-invitation.js | 18 +++ src/models/circle.ts | 8 ++ src/models/constants.ts | 39 ++++-- src/services/circles.ts | 31 +++++ src/store/circles.js | 21 +++ templates/join-invitation.php | 10 ++ vite.config.js | 1 + 14 files changed, 473 insertions(+), 115 deletions(-) create mode 100644 src/components/CircleDetails/CircleConfigs/CircleConfigCheckboxesList.vue create mode 100644 src/components/CircleDetails/CircleConfigs/CircleConfigInvitationLink.vue create mode 100644 src/components/JoinInvitation.vue create mode 100644 src/join-invitation.js create mode 100644 templates/join-invitation.php diff --git a/appinfo/routes.php b/appinfo/routes.php index 635003352..bed9f86b1 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -10,6 +10,7 @@ ['name' => 'contacts#direct', 'url' => '/direct/contact/{contact}', 'verb' => 'GET'], ['name' => 'contacts#directcircle', 'url' => '/direct/circle/{singleId}', 'verb' => 'GET'], ['name' => 'page#index', 'url' => '/', 'verb' => 'GET'], + ['name' => 'page#joinInvitation', 'url' => '/join/{invitationCode}', 'verb' => 'GET'], ['name' => 'page#index', 'url' => '/{group}', 'verb' => 'GET', 'postfix' => 'group'], ['name' => 'page#index', 'url' => '/{group}/{contact}', 'verb' => 'GET', 'postfix' => 'group.contact'], ['name' => 'social_api#update_contact', 'url' => '/api/v1/social/avatar/{network}/{addressbookId}/{contactId}', 'verb' => 'PUT'], diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 93b7da979..bf9332e9b 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -83,4 +83,17 @@ public function index(): TemplateResponse { return new TemplateResponse(Application::APP_ID, 'main'); } + + /** + * @NoAdminRequired + * @NoCSRFRequired + */ + public function joinInvitation(string $invitationCode): TemplateResponse { + $this->initialStateService->provideInitialState(Application::APP_ID, 'invitationCode', $invitationCode); + + Util::addStyle(Application::APP_ID, 'contacts-join-invitation'); + Util::addScript(Application::APP_ID, 'contacts-join-invitation'); + + return new TemplateResponse(Application::APP_ID, 'join-invitation'); + } } diff --git a/src/components/CircleDetails/CircleConfigs.vue b/src/components/CircleDetails/CircleConfigs.vue index 27684b4c7..a2660b58e 100644 --- a/src/components/CircleDetails/CircleConfigs.vue +++ b/src/components/CircleDetails/CircleConfigs.vue @@ -5,40 +5,25 @@ diff --git a/src/components/CircleDetails/CircleConfigs/CircleConfigCheckboxesList.vue b/src/components/CircleDetails/CircleConfigs/CircleConfigCheckboxesList.vue new file mode 100644 index 000000000..0e9a05cd6 --- /dev/null +++ b/src/components/CircleDetails/CircleConfigs/CircleConfigCheckboxesList.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/src/components/CircleDetails/CircleConfigs/CircleConfigInvitationLink.vue b/src/components/CircleDetails/CircleConfigs/CircleConfigInvitationLink.vue new file mode 100644 index 000000000..b6f8f73e7 --- /dev/null +++ b/src/components/CircleDetails/CircleConfigs/CircleConfigInvitationLink.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/src/components/CircleDetails/CircleSettings.vue b/src/components/CircleDetails/CircleSettings.vue index 1e3e8286b..86bcd4107 100644 --- a/src/components/CircleDetails/CircleSettings.vue +++ b/src/components/CircleDetails/CircleSettings.vue @@ -6,23 +6,12 @@ @@ -139,7 +89,7 @@ export default defineComponent({ display: flex; flex-direction: column; gap: 16px; - max-width: 320px; + max-width: 400px; } .circle-config { diff --git a/src/components/JoinInvitation.vue b/src/components/JoinInvitation.vue new file mode 100644 index 000000000..d1e77b757 --- /dev/null +++ b/src/components/JoinInvitation.vue @@ -0,0 +1,127 @@ + + + + + + + diff --git a/src/join-invitation.js b/src/join-invitation.js new file mode 100644 index 000000000..28086ea50 --- /dev/null +++ b/src/join-invitation.js @@ -0,0 +1,18 @@ +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { createApp } from 'vue' +import JoinInvitation from './components/JoinInvitation.vue' +import LegacyGlobalMixin from './mixins/LegacyGlobalMixin.js' + +import 'vite/modulepreload-polyfill' + +document.addEventListener('DOMContentLoaded', main) + +function main() { + const app = createApp(JoinInvitation) + app.mixin(LegacyGlobalMixin) + app.mount('#join-invitation') +} diff --git a/src/models/circle.ts b/src/models/circle.ts index 5dae65dac..e6ab0e642 100644 --- a/src/models/circle.ts +++ b/src/models/circle.ts @@ -292,6 +292,14 @@ export default class Circle { || (this.config & CircleConfigs.FRIEND) !== 0 } + get invitationCode() { + return this._data.invitationCode + } + + set invitationCode(invitationCode: string) { + this._data.invitationCode = invitationCode + } + // PARAMS --------------------------------------------- /** * Vue router param diff --git a/src/models/constants.ts b/src/models/constants.ts index 4da974e51..481ef2f8d 100644 --- a/src/models/constants.ts +++ b/src/models/constants.ts @@ -6,6 +6,8 @@ import { translate as t } from '@nextcloud/l10n' import { ShareType } from '@nextcloud/sharing' +import CircleConfigCheckboxesList from '../components/CircleDetails/CircleConfigs/CircleConfigCheckboxesList.vue' +import CircleConfigInvitationLink from '../components/CircleDetails/CircleConfigs/CircleConfigInvitationLink.vue' export type DefaultGroup = string export type DefaultChart = string @@ -92,20 +94,41 @@ export const CIRCLES_MEMBER_LEVELS = { // Available circle configs in the circle details view export const PUBLIC_CIRCLE_CONFIG = { [t('contacts', 'Invites')]: { - [CIRCLE_CONFIG_OPEN]: t('contacts', 'Anyone can request membership'), - [CIRCLE_CONFIG_INVITE]: t('contacts', 'Members need to accept invitation'), - [CIRCLE_CONFIG_REQUEST]: t('contacts', 'Memberships must be confirmed/accepted by a Moderator (requires "Anyone can request membership")'), - [CIRCLE_CONFIG_FRIEND]: t('contacts', 'Members can also invite'), + component: CircleConfigCheckboxesList, + props: { + configs: { + [CIRCLE_CONFIG_OPEN]: t('contacts', 'Anyone can request membership'), + [CIRCLE_CONFIG_INVITE]: t('contacts', 'Members need to accept invitation'), + [CIRCLE_CONFIG_REQUEST]: t('contacts', 'Memberships must be confirmed/accepted by a Moderator (requires "Anyone can request membership")'), + [CIRCLE_CONFIG_FRIEND]: t('contacts', 'Members can also invite'), + }, + }, + }, + + [t('contacts', 'Invitation links')]: { + component: CircleConfigInvitationLink, + props: {}, }, [t('contacts', 'Membership')]: { - // TODO: implement backend - // [CIRCLE_CONFIG_CIRCLE_INVITE]: t('contacts', 'Team must confirm when invited in another circle'), - [CIRCLE_CONFIG_ROOT]: t('contacts', 'Prevent teams from being a member of another team'), + component: CircleConfigCheckboxesList, + props: { + configs: { + + // TODO: implement backend + // [CIRCLE_CONFIG_CIRCLE_INVITE]: t('contacts', 'Team must confirm when invited in another circle'), + [CIRCLE_CONFIG_ROOT]: t('contacts', 'Prevent teams from being a member of another team'), + }, + }, }, [t('contacts', 'Privacy')]: { - [CIRCLE_CONFIG_VISIBLE]: t('contacts', 'Visible to everyone'), + component: CircleConfigCheckboxesList, + props: { + configs: { + [CIRCLE_CONFIG_VISIBLE]: t('contacts', 'Visible to everyone'), + }, + }, }, } diff --git a/src/services/circles.ts b/src/services/circles.ts index d833200f5..7fb02d624 100644 --- a/src/services/circles.ts +++ b/src/services/circles.ts @@ -47,6 +47,28 @@ export async function getCircle(circleId: string) { return response.data.ocs.data } +/** + * Get a specific invitation + * + * @param invitationCode + * @return + */ +export async function getInvitation(invitationCode: string) { + const response = await axios.get(generateOcsUrl('apps/circles/invitations/{invitationCode}', { invitationCode })) + return response.data.ocs.data +} + +/** + * Join to a circle using an invitation + * + * @param invitationCode + * @return + */ +export async function joinInvitation(invitationCode: string) { + const response = await axios.post(generateOcsUrl('apps/circles/invitations/{invitationCode}', { invitationCode })) + return response.data.ocs.data +} + /** * Create a new circle * @@ -197,3 +219,12 @@ export async function editCircleSetting(circleId: string, setting: CircleSetting ) return response.data.ocs.data } + +export async function createInvitationLink(circleId: string) { + const response = await axios.put(generateOcsUrl('apps/circles/circles/{circleId}/invitation', { circleId })) + return response.data.ocs.data +} +export async function revokeInvitationLink(circleId: string) { + const response = await axios.delete(generateOcsUrl('apps/circles/circles/{circleId}/invitation', { circleId })) + return response.data.ocs.data +} diff --git a/src/store/circles.js b/src/store/circles.js index 3107482f8..27373dad5 100644 --- a/src/store/circles.js +++ b/src/store/circles.js @@ -10,6 +10,7 @@ import { acceptMember, addMembers, createCircle, + createInvitationLink, deleteCircle, deleteMember, editCircleSetting, @@ -17,6 +18,7 @@ import { getCircleMembers, getCircles, leaveCircle, + revokeInvitationLink, } from '../services/circles.ts' import logger from '../services/logger.js' @@ -91,6 +93,10 @@ const mutations = { setCircleSettings(state, { circleId, settings }) { state.circles[circleId]._data.settings = settings }, + + setInvitationCode(state, { circleId, invitationCode }) { + state.circles[circleId]._data.invitationCode = invitationCode + }, } const getters = { @@ -277,6 +283,21 @@ const actions = { }) }, + async createInvitationLink(context, { circleId }) { + const { invitationCode } = await createInvitationLink(circleId) + await context.commit('setInvitationCode', { + circleId, + invitationCode, + }) + }, + + async revokeInvitationLink(context, { circleId }) { + const { invitationCode } = await revokeInvitationLink(circleId) + await context.commit('setInvitationCode', { + circleId, + invitationCode, + }) + }, } export default { state, mutations, getters, actions } diff --git a/templates/join-invitation.php b/templates/join-invitation.php new file mode 100644 index 000000000..697894c46 --- /dev/null +++ b/templates/join-invitation.php @@ -0,0 +1,10 @@ + + +
diff --git a/vite.config.js b/vite.config.js index 7aeb675d2..db8c8a0f4 100644 --- a/vite.config.js +++ b/vite.config.js @@ -10,6 +10,7 @@ export default createAppConfig({ 'main': path.join(__dirname, 'src', 'main.js'), 'files-action': path.join(__dirname, 'src', 'files-action.js'), 'admin-settings': path.join(__dirname, 'src', 'admin-settings.js'), + 'join-invitation': path.join(__dirname, 'src', 'join-invitation.js'), 'oca': path.join(__dirname, 'src', 'oca.ts'), }, { inlineCSS: false,