Skip to content

Commit d073a9b

Browse files
committed
feat: Create invitation links
Signed-off-by: Kostiantyn Miakshyn <molodchick@gmail.com>
1 parent d4ca7e0 commit d073a9b

File tree

15 files changed

+577
-117
lines changed

15 files changed

+577
-117
lines changed

appinfo/routes.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
['name' => 'contacts#direct', 'url' => '/direct/contact/{contact}', 'verb' => 'GET'],
1111
['name' => 'contacts#directcircle', 'url' => '/direct/circle/{singleId}', 'verb' => 'GET'],
1212
['name' => 'page#index', 'url' => '/', 'verb' => 'GET'],
13+
14+
['name' => 'invitation#viewInvitation', 'url' => '/join/{invitationCode}', 'verb' => 'GET'],
15+
['name' => 'invitation#acceptInvitation', 'url' => '/api/v1/join/{invitationCode}', 'verb' => 'POST'],
16+
1317
['name' => 'page#index', 'url' => '/{group}', 'verb' => 'GET', 'postfix' => 'group'],
1418
['name' => 'page#index', 'url' => '/{group}/{contact}', 'verb' => 'GET', 'postfix' => 'group.contact'],
1519
['name' => 'social_api#update_contact', 'url' => '/api/v1/social/avatar/{network}/{addressbookId}/{contactId}', 'verb' => 'PUT'],
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
namespace OCA\Contacts\Controller;
9+
10+
use OC\DB\Connection;
11+
use OCA\Circles\Db\CircleRequest;
12+
use OCA\Circles\Db\MembershipRequest;
13+
use OCA\Circles\Exceptions\FrontendException;
14+
use OCA\Circles\Exceptions\MembershipNotFoundException;
15+
use OCA\Circles\FederatedItems\CircleJoin;
16+
use OCA\Circles\Model\Federated\FederatedEvent;
17+
use OCA\Circles\Service\ConfigService;
18+
use OCA\Circles\Service\FederatedUserService;
19+
use OCA\Circles\Tools\Traits\TDeserialize;
20+
use OCA\Circles\Tools\Traits\TNCLogger;
21+
use OCA\Contacts\AppInfo\Application;
22+
use OCP\AppFramework\ApiController;
23+
use OCP\AppFramework\Http\Attribute\UserRateLimit;
24+
use OCP\AppFramework\Http\DataResponse;
25+
use OCP\AppFramework\Http\TemplateResponse;
26+
use OCP\AppFramework\OCS\OCSException;
27+
use OCP\AppFramework\Services\IInitialState;
28+
use OCP\IL10N;
29+
use OCP\IRequest;
30+
use OCP\IUserSession;
31+
32+
class InvitationController extends ApiController {
33+
private const SETTING_KEY_INVITATION_CODE = 'invitationCode';
34+
35+
use TDeserialize;
36+
use TNCLogger;
37+
38+
public function __construct(
39+
IRequest $request,
40+
private IUserSession $userSession,
41+
private ConfigService $configService,
42+
private FederatedUserService $federatedUserService,
43+
private Connection $connection,
44+
private IInitialState $initialState,
45+
private CircleRequest $circleRequest,
46+
private MembershipRequest $membershipRequest,
47+
private CircleJoin $circleJoin,
48+
private IL10N $l10n,
49+
) {
50+
parent::__construct(Application::APP_ID, $request);
51+
}
52+
53+
/**
54+
* @NoAdminRequired
55+
* @NoCSRFRequired
56+
*
57+
* @UserRateThrottle(limit=10, period=3600)
58+
*/
59+
#[UserRateLimit(limit: 10, period: 3600)]
60+
public function viewInvitation(string $invitationCode): TemplateResponse {
61+
$this->setCurrentFederatedUser();
62+
63+
try {
64+
$invitation = $this->getInvitation($invitationCode);
65+
} catch (\OutOfBoundsException $e) {
66+
return new TemplateResponse(Application::APP_ID,
67+
'message',
68+
['message' => $this->l10n->t('Link or team does not exist anymore')],
69+
TemplateResponse::RENDER_AS_USER,
70+
404,
71+
);
72+
}
73+
74+
$circle = $this->circleRequest->getCircle($invitation['circle_id']);
75+
$federatedUser = $this->federatedUserService->getLocalFederatedUser($this->userSession->getUser()->getUID());
76+
77+
try {
78+
$this->membershipRequest->getMembership($invitation['circle_id'], $federatedUser->getSingleId());
79+
$isAlreadyMemberOfCircle = true;
80+
} catch (MembershipNotFoundException) {
81+
$isAlreadyMemberOfCircle = false;
82+
}
83+
84+
$this->initialState->provideInitialState('circleIdToJoin', $invitation['circle_id']);
85+
$this->initialState->provideInitialState('circleNameToJoin', $circle->getName());
86+
$this->initialState->provideInitialState('invitationCode', $invitationCode);
87+
$this->initialState->provideInitialState('isAlreadyMemberOfCircle', $isAlreadyMemberOfCircle);
88+
89+
return new TemplateResponse(Application::APP_ID, 'join');
90+
}
91+
92+
/**
93+
* @NoAdminRequired
94+
* @NoCSRFRequired
95+
*
96+
* @UserRateThrottle(limit=10, period=3600)
97+
*/
98+
#[UserRateLimit(limit: 10, period: 3600)]
99+
public function acceptInvitation(string $invitationCode): DataResponse {
100+
$joined = false;
101+
try {
102+
$invitation = $this->getInvitation($invitationCode);
103+
104+
$circle = $this->circleRequest->getCircle($invitation['circle_id']);
105+
$userId = $this->userSession->getUser()->getUID();
106+
$federatedInvitedUser = $this->federatedUserService->getLocalFederatedUser($userId);
107+
$federatedInvitedMember = $this->federatedUserService->getFederatedMember($userId);
108+
109+
try {
110+
$this->membershipRequest->getMembership($invitation['circle_id'], $federatedInvitedUser->getSingleId());
111+
} catch (MembershipNotFoundException) {
112+
$event = new FederatedEvent(CircleJoin::class);
113+
$event->setCircle($circle);
114+
$federatedInvitedMember->setInvitedBy($federatedInvitedUser);
115+
$circle->setInitiator($federatedInvitedMember);
116+
117+
// fixme: implement
118+
$this->circleJoin->lightJoin($event);
119+
120+
$joined = true;
121+
}
122+
123+
return new DataResponse(['joined' => $joined]);
124+
} catch (Exception $e) {
125+
$this->e($e, ['circleId' => $invitation]);
126+
throw new OCSException($e->getMessage(), (int)$e->getCode());
127+
}
128+
}
129+
130+
private function setCurrentFederatedUser(): void {
131+
if (!$this->configService->getAppValueBool(ConfigService::FRONTEND_ENABLED)) {
132+
throw new FrontendException('frontend disabled');
133+
}
134+
135+
$user = $this->userSession->getUser();
136+
$this->federatedUserService->setLocalCurrentUser($user);
137+
}
138+
139+
/**
140+
* @param string $invitationCode
141+
* @return array{circle_id: string, created_by: string}
142+
*/
143+
private function getInvitation(string $invitationCode): array {
144+
$invitationCode = str_replace('-', '', $invitationCode);
145+
146+
$qb = $this->connection->getQueryBuilder();
147+
$row = $qb->select('circle_id', 'created_by')
148+
->from('contacts_circle_invitations')
149+
->where($qb->expr()->eq('invitation_code', $qb->createNamedParameter($invitationCode)))
150+
->executeQuery()
151+
->fetch();
152+
153+
if (empty($row)) {
154+
throw new \OutOfBoundsException('Invitation code not found');
155+
}
156+
157+
return $row;
158+
}
159+
}

src/components/CircleDetails/CircleConfigs.vue

Lines changed: 2 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -5,40 +5,25 @@
55

66
<template>
77
<ul>
8-
<li v-for="(configs, title) in PUBLIC_CIRCLE_CONFIG" :key="title" class="circle-config">
8+
<li v-for="(config, title) in PUBLIC_CIRCLE_CONFIG" :key="title" class="circle-config">
99
<ContentHeading class="circle-config__title">
1010
{{ title }}
1111
</ContentHeading>
1212

13-
<ul class="circle-config__list">
14-
<CheckboxRadioSwitch
15-
v-for="(label, config) in configs"
16-
:key="'circle-config' + config"
17-
:model-value="isChecked(config)"
18-
:loading="loading === config"
19-
:disabled="loading !== false"
20-
wrapper-element="li"
21-
@update:model-value="onChange(config, $event)">
22-
{{ label }}
23-
</CheckboxRadioSwitch>
24-
</ul>
13+
<component :is="config.component" v-bind="config.props" :circle="circle" />
2514
</li>
2615
</ul>
2716
</template>
2817

2918
<script>
30-
import { showError } from '@nextcloud/dialogs'
31-
import { NcCheckboxRadioSwitch as CheckboxRadioSwitch } from '@nextcloud/vue'
3219
import ContentHeading from './ContentHeading.vue'
3320
import Circle from '../../models/circle.ts'
3421
import { PUBLIC_CIRCLE_CONFIG } from '../../models/constants.ts'
35-
import { CircleEdit, editCircle } from '../../services/circles.ts'
3622
3723
export default {
3824
name: 'CircleConfigs',
3925
4026
components: {
41-
CheckboxRadioSwitch,
4227
ContentHeading,
4328
},
4429
@@ -52,45 +37,8 @@ export default {
5237
data() {
5338
return {
5439
PUBLIC_CIRCLE_CONFIG,
55-
56-
loading: false,
5740
}
5841
},
59-
60-
methods: {
61-
isChecked(config) {
62-
return (this.circle.config & config) !== 0
63-
},
64-
65-
/**
66-
* On toggle, add or remove the config bitwise
67-
*
68-
* @param {CircleConfig} config the circle config to manage
69-
* @param {boolean} checked checked or not
70-
*/
71-
async onChange(config, checked) {
72-
this.logger.debug(`Circle config ${config} is set to ${checked}`)
73-
74-
this.loading = config
75-
const prevConfig = this.circle.config
76-
if (checked) {
77-
config = prevConfig | config
78-
} else {
79-
config = prevConfig & ~config
80-
}
81-
82-
try {
83-
const circleData = await editCircle(this.circle.id, CircleEdit.Config, config)
84-
// eslint-disable-next-line vue/no-mutating-props
85-
this.circle.config = circleData.config
86-
} catch (error) {
87-
console.error('Unable to edit circle config', prevConfig, config, error)
88-
showError(t('contacts', 'An error happened during the config change'))
89-
} finally {
90-
this.loading = false
91-
}
92-
},
93-
},
9442
}
9543
</script>
9644

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<template>
2+
<ul class="circle-config__list">
3+
<NcCheckboxRadioSwitch
4+
v-for="(label, config) in configs"
5+
:key="'circle-config' + config"
6+
:model-value="isChecked(config)"
7+
:loading="loading === config"
8+
:disabled="loading !== false"
9+
wrapper-element="li"
10+
@update:model-value="onChange(config, $event)">
11+
{{ label }}
12+
</NcCheckboxRadioSwitch>
13+
</ul>
14+
</template>
15+
16+
<script>
17+
import { showError } from '@nextcloud/dialogs'
18+
import { t } from '@nextcloud/l10n'
19+
import { NcCheckboxRadioSwitch } from '@nextcloud/vue'
20+
import Circle from '../../../models/circle.ts'
21+
import { CircleEdit, editCircle } from '../../../services/circles.ts'
22+
23+
export default {
24+
name: 'CircleConfigCheckboxesList',
25+
26+
components: {
27+
NcCheckboxRadioSwitch,
28+
},
29+
30+
props: {
31+
circle: {
32+
type: Circle,
33+
required: true,
34+
},
35+
36+
configs: {
37+
type: Object,
38+
required: true,
39+
},
40+
},
41+
42+
data() {
43+
return {
44+
loading: false,
45+
}
46+
},
47+
48+
methods: {
49+
isChecked(config) {
50+
return (this.circle.config & config) !== 0
51+
},
52+
53+
/**
54+
* On toggle, add or remove the config bitwise
55+
*
56+
* @param {CircleConfig} config the circle config to manage
57+
* @param {boolean} checked checked or not
58+
*/
59+
async onChange(config, checked) {
60+
this.logger.debug(`Circle config ${config} is set to ${checked}`)
61+
62+
this.loading = config
63+
const prevConfig = this.circle.config
64+
if (checked) {
65+
config = prevConfig | config
66+
} else {
67+
config = prevConfig & ~config
68+
}
69+
70+
try {
71+
const circleData = await editCircle(this.circle.id, CircleEdit.Config, config)
72+
// eslint-disable-next-line vue/no-mutating-props
73+
this.circle.config = circleData.config
74+
} catch (error) {
75+
console.error('Unable to edit circle config', prevConfig, config, error)
76+
showError(t('contacts', 'An error happened during the config change'))
77+
} finally {
78+
this.loading = false
79+
}
80+
},
81+
},
82+
}
83+
</script>

0 commit comments

Comments
 (0)