diff --git a/lib/Controller/PublicController.php b/lib/Controller/PublicController.php index 1799088a46..51bf26e8df 100644 --- a/lib/Controller/PublicController.php +++ b/lib/Controller/PublicController.php @@ -73,7 +73,8 @@ public function __construct( #[NoCSRFRequired] #[ShareTokenRequired] #[OpenAPI(OpenAPI::SCOPE_IGNORE)] - #[FrontpageRoute(verb: 'GET', url: '/s/{token}')] + #[FrontpageRoute(verb: 'GET', url: '/s/{token}', postfix: 'public')] + #[FrontpageRoute(verb: 'GET', url: '/s/{token}/register', postfix: 'register')] public function votePage() { Util::addScript(Application::APP_ID, AssetLoader::getScript('main')); if ($this->userSession->getIsLoggedIn()) { @@ -491,7 +492,7 @@ public function deleteEmailAddress(string $token): JSONResponse { #[FrontpageRoute(verb: 'POST', url: '/s/{token}/register')] public function register(string $token, string $displayName, string $emailAddress = '', string $timeZone = ''): JSONResponse { return $this->response(fn () => [ - 'share' => $this->shareService->register($token, $displayName, $emailAddress, $timeZone), + 'share' => $this->shareService->registerGuest($token, $displayName, $emailAddress, $timeZone), ], Http::STATUS_CREATED); } diff --git a/lib/Controller/ShareApiController.php b/lib/Controller/ShareApiController.php index be996e5025..285848ee3c 100644 --- a/lib/Controller/ShareApiController.php +++ b/lib/Controller/ShareApiController.php @@ -67,7 +67,7 @@ public function get(string $token): DataResponse { #[ApiRoute(verb: 'POST', url: 'api/v1.0/s/{token}/register')] public function register(string $token, string $displayName, string $emailAddress = '', string $timeZone = ''): DataResponse { return $this->response(fn () => [ - 'share' => $this->shareService->register($token, $displayName, $emailAddress, $timeZone), + 'share' => $this->shareService->registerGuest($token, $displayName, $emailAddress, $timeZone), ], Http::STATUS_CREATED); } diff --git a/lib/Db/Share.php b/lib/Db/Share.php index 241dc8431e..b68b11b975 100644 --- a/lib/Db/Share.php +++ b/lib/Db/Share.php @@ -321,7 +321,7 @@ public function getURL(): string { ); } elseif ($this->token) { return $this->urlGenerator->linkToRouteAbsolute( - Application::APP_ID . '.public.votePage', + Application::APP_ID . '.public.votePagepublic', ['token' => $this->token] ); } else { diff --git a/lib/Model/Settings/AppSettings.php b/lib/Model/Settings/AppSettings.php index cc2f9927f7..2954e22008 100644 --- a/lib/Model/Settings/AppSettings.php +++ b/lib/Model/Settings/AppSettings.php @@ -99,7 +99,6 @@ public function getAppSettings(): array { 'finalPrivacyUrl' => '', 'finalImprintUrl' => '', 'useLogin' => true, - 'useSiteLegalTerms' => true, 'useActivity' => false, 'navigationPollsInList' => false, 'updateType' => $this->getUpdateType(), diff --git a/lib/Service/ShareService.php b/lib/Service/ShareService.php index b4bbdb0600..1a55531a6c 100644 --- a/lib/Service/ShareService.php +++ b/lib/Service/ShareService.php @@ -360,18 +360,6 @@ private function convertDependingObjects(string $userId, string $replacementId, $this->commentMapper->renameUserId($userId, $replacementId, $pollId); } - /** - * @deprecated Use registerGuest() instead - */ - public function register( - string $publicShareToken, - string $displayName, - string $emailAddress = '', - string $timeZone = '', - ): Share { - return $this->registerGuest($publicShareToken, $displayName, $emailAddress, $timeZone); - } - /** * Delete or restore share by Token * @param string $token Share of token to delete diff --git a/src/App.vue b/src/App.vue index 9146dd5b26..cefa5e32a7 100644 --- a/src/App.vue +++ b/src/App.vue @@ -136,8 +136,12 @@ onUnmounted(() => { diff --git a/src/assets/scss/globals.scss b/src/assets/scss/globals.scss index 148b89d099..cadfe5dcb7 100644 --- a/src/assets/scss/globals.scss +++ b/src/assets/scss/globals.scss @@ -19,48 +19,6 @@ padding: 0 !important; } -// global modal settings -.modal__content { - padding: 14px; - display: flex; - flex-direction: column; - color: var(--color-main-text); -} - -.modal__buttons__spacer { - flex: 1; -} - -.modal__buttons { - display: flex; - gap: 8px; - justify-content: flex-end; - flex-wrap: wrap-reverse; - align-items: center; - margin-top: 36px; - - .left { - display: flex; - flex: 1; - gap: 8px; - } - - .right { - display: flex; - flex: 0; - justify-content: flex-end; - gap: 8px; - } - - .button { - margin-inline: 10px 0; - } -} - -.modal__buttons__link { - text-decoration: underline; -} - .sortable { * { cursor: grab; diff --git a/src/assets/scss/hacks.scss b/src/assets/scss/hacks.scss index 5f451b8c96..2da8a3c7b3 100644 --- a/src/assets/scss/hacks.scss +++ b/src/assets/scss/hacks.scss @@ -21,20 +21,11 @@ height: var(--avatar-size); } -// h2 in notecard gets a margin-top of -6px instead of 24px for better alignment -.notecard h2 { - margin-top: -6px; -} - // relative position to be able to get sticky toolbar .app-content { position: relative !important; } -.app-sidebar { - top: 0 !important; -} - .action-item.action-item--single.app-navigation-toggle.undefined.has-tooltip { top: 4px; margin-inline-end: -50px; @@ -56,7 +47,3 @@ display: inline !important; } } - -.modal-wrapper .modal-container { - overflow: scroll !important; -} diff --git a/src/assets/scss/vars.scss b/src/assets/scss/vars.scss index 4cc3b1de8b..78f4d5896f 100644 --- a/src/assets/scss/vars.scss +++ b/src/assets/scss/vars.scss @@ -8,6 +8,7 @@ --cap-width: 49rem; --shadow-height: 0.7rem; --shadow-height-inverted: -0.7rem; + --scroll-fade-shadow: rgba(0, 0, 0, 0.1); --color-polls-foreground-yes: #2d7b41; --color-polls-foreground-no: #db0606; --color-polls-foreground-maybe: #a37200; @@ -26,6 +27,7 @@ --color-polls-background-no: #451c1c; --color-polls-background-maybe: #2e2914; --container-background-light: #14262e; + --scroll-fade-shadow: rgba(255, 255, 255, 0.15); } } @@ -37,4 +39,5 @@ --color-polls-background-no: #451c1c; --color-polls-background-maybe: #2e2914; --container-background-light: #14262e; + --scroll-fade-shadow: rgba(255, 255, 255, 0.15); } diff --git a/src/components/Actions/modules/ActionRegister.vue b/src/components/Actions/modules/ActionRegister.vue deleted file mode 100644 index d451e78176..0000000000 --- a/src/components/Actions/modules/ActionRegister.vue +++ /dev/null @@ -1,37 +0,0 @@ - - - - - diff --git a/src/components/Actions/modules/ActionSendConfirmed.vue b/src/components/Actions/modules/ActionSendConfirmed.vue index 63267d9762..a15979978e 100644 --- a/src/components/Actions/modules/ActionSendConfirmed.vue +++ b/src/components/Actions/modules/ActionSendConfirmed.vue @@ -8,7 +8,7 @@ import { useRoute } from 'vue-router' import { ref } from 'vue' import { t, n } from '@nextcloud/l10n' -import NcModal from '@nextcloud/vue/components/NcModal' +import NcModal from '../../Base/modules/CustomNcModal.vue' import NcButton from '@nextcloud/vue/components/NcButton' import EmailCheckIcon from 'vue-material-design-icons/EmailCheckOutline.vue' // view-comfy-outline diff --git a/src/components/Base/modules/ButtonModal.vue b/src/components/Base/modules/ButtonModal.vue index 02c09af30c..1f25d55413 100644 --- a/src/components/Base/modules/ButtonModal.vue +++ b/src/components/Base/modules/ButtonModal.vue @@ -7,7 +7,7 @@ import { t } from '@nextcloud/l10n' import NcButton, { ButtonVariant } from '@nextcloud/vue/components/NcButton' -import NcModal from '@nextcloud/vue/components/NcModal' +import NcModal from '../../Base/modules/CustomNcModal.vue' import NcActionButton from '@nextcloud/vue/components/NcActionButton' import NcAppNavigationNew from '@nextcloud/vue/components/NcAppNavigationNew' diff --git a/src/components/Base/modules/CustomNcModal.vue b/src/components/Base/modules/CustomNcModal.vue new file mode 100644 index 0000000000..f1003dc521 --- /dev/null +++ b/src/components/Base/modules/CustomNcModal.vue @@ -0,0 +1,66 @@ + + + + + + + diff --git a/src/components/Cards/CardRegister.vue b/src/components/Cards/CardRegister.vue deleted file mode 100644 index 4a4fd351c0..0000000000 --- a/src/components/Cards/CardRegister.vue +++ /dev/null @@ -1,40 +0,0 @@ - - - - - diff --git a/src/components/Cards/VoteInfoCards.vue b/src/components/Cards/VoteInfoCards.vue index 6e2875eef7..42ef9a65bf 100644 --- a/src/components/Cards/VoteInfoCards.vue +++ b/src/components/Cards/VoteInfoCards.vue @@ -9,7 +9,6 @@ import CardAddProposals from './CardAddProposals.vue' import CardClosedPoll from './CardClosedPoll.vue' import CardLimitedVotes from './CardLimitedVotes.vue' import CardLocked from './CardLocked.vue' -import CardRegister from './CardRegister.vue' import CardSendConfirmations from './CardSendConfirmations.vue' import CardTimezone from './CardTimezone.vue' import CardUnpublishedPoll from './CardUnpublishedPoll.vue' @@ -17,12 +16,10 @@ import CardUnpublishedPoll from './CardUnpublishedPoll.vue' import { usePollStore } from '../../stores/poll' import { useOptionsStore } from '../../stores/options' import { useSharesStore } from '../../stores/shares' -import { useSessionStore } from '../../stores/session' const pollStore = usePollStore() const optionsStore = useOptionsStore() const sharesStore = useSharesStore() -const sessionStore = useSessionStore() const showTimezoneHint = computed( () => @@ -60,16 +57,6 @@ const showLimitCard = computed( && (pollStore.configuration.maxVotesPerOption || pollStore.configuration.maxVotesPerUser), ) -const showRegisterCard = computed( - () => - sessionStore.route.name === 'publicVote' - && ['public', 'email', 'contact'].includes( - pollStore.currentUserStatus.userRole, - ) - && !pollStore.isClosed - && !pollStore.currentUserStatus.isLocked - && !!pollStore.id, -) diff --git a/src/components/Modals/OptionsAddModal.vue b/src/components/Modals/OptionsAddModal.vue index 00096b2d18..a9b7627d0a 100644 --- a/src/components/Modals/OptionsAddModal.vue +++ b/src/components/Modals/OptionsAddModal.vue @@ -7,7 +7,7 @@ import { ref, onMounted, onUnmounted } from 'vue' import { t } from '@nextcloud/l10n' import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' -import NcModal from '@nextcloud/vue/components/NcModal' +import NcModal from '../Base/modules/CustomNcModal.vue' import { subscribe, unsubscribe } from '@nextcloud/event-bus' import DatePollIcon from 'vue-material-design-icons/CalendarBlankOutline.vue' @@ -34,7 +34,7 @@ onUnmounted(() => { diff --git a/src/components/Settings/AdminSettings/AdminLegal.vue b/src/components/Settings/AdminSettings/AdminLegal.vue index 4946dbf642..bd3574d19b 100644 --- a/src/components/Settings/AdminSettings/AdminLegal.vue +++ b/src/components/Settings/AdminSettings/AdminLegal.vue @@ -35,12 +35,7 @@ const placeholder = computed(() => { v-model="appSettingsStore.useSiteLegalTerms" type="switch" @update:model-value="appSettingsStore.write()"> - {{ - t( - 'polls', - 'Use the default terms for public polls and enable the default footer', - ) - }} + {{ t('polls', 'Use the default terms for public polls') }}
diff --git a/src/components/Shares/SharesList.vue b/src/components/Shares/SharesList.vue index 79999b5e11..63c59a1215 100644 --- a/src/components/Shares/SharesList.vue +++ b/src/components/Shares/SharesList.vue @@ -8,7 +8,7 @@ import { ref } from 'vue' import { t } from '@nextcloud/l10n' import { showError } from '@nextcloud/dialogs' -import NcModal from '@nextcloud/vue/components/NcModal' +import NcModal from '../Base/modules/CustomNcModal.vue' import ShareIcon from 'vue-material-design-icons/ShareVariantOutline.vue' diff --git a/src/helpers/modules/InlineLink.ts b/src/helpers/modules/InlineLink.ts new file mode 100644 index 0000000000..89b4d7e5d9 --- /dev/null +++ b/src/helpers/modules/InlineLink.ts @@ -0,0 +1,63 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { h } from 'vue' + +/** + * Renders a translated string where [link]...[/link] is replaced by an anchor element. + * Square brackets are used so t() does not strip the markers as HTML tags. + * + * Usage: + * + * + * + */ +const InlineLink = { + inheritAttrs: false, + + props: { + text: { + type: String, + default: '', + }, + href: { + type: String, + default: '#', + }, + target: { + type: String, + default: null, + }, + }, + + setup( + props: { text: string; href: string; target: string | null }, + { attrs }: { attrs: Record }, + ) { + return () => { + const match = props.text.match( + /^([\s\S]*)\[link\]([\s\S]*)\[\/link\]([\s\S]*)$/, + ) + + if (!match) { + return h('span', props.text) + } + + const [, before, linkText, after] = match + + return h('span', [ + before, + h( + 'a', + { href: props.href, target: props.target, ...attrs }, + linkText, + ), + after, + ]) + } + }, +} + +export { InlineLink } diff --git a/src/router.ts b/src/router.ts index 431ca8bc22..ccdb885b9c 100644 --- a/src/router.ts +++ b/src/router.ts @@ -67,6 +67,7 @@ const Forbidden = () => import('./views/Forbidden.vue') const List = () => import('./views/PollList.vue') const NotFound = () => import('./views/NotFound.vue') const Vote = () => import('./views/Vote.vue') +const PublicRegisterView = () => import('./views/PublicRegisterView.vue') const SideBar = () => import('./views/SideBar.vue') const SideBarPollGroup = () => import('./views/SideBarPollGroup.vue') @@ -160,6 +161,19 @@ const routes: RouteRecordRaw[] = [ votePage: true, }, }, + { + name: 'publicRegister', + path: '/s/:token/register', + components: { + default: PublicRegisterView, + }, + beforeEnter: validateToken, + props: true, + meta: { + publicPage: true, + registerPage: true, + }, + }, { name: 'root', path: '/', @@ -243,6 +257,31 @@ router.beforeResolve(async (to: RouteLocationNormalized) => { await pollsStore.load(!watcherActive) } + if (to.meta.registerPage) { + const pollStore = usePollStore() + const optionsStore = useOptionsStore() + + try { + await Promise.all([pollStore.load(), optionsStore.load()]) + } catch { + return { name: 'notfound' } + } + + // If the user no longer needs to register, redirect to the vote page + const needsRegistration = + pollStore.currentUserStatus.userRole === 'public' + && !pollStore.isClosed + && !pollStore.currentUserStatus.isLocked + + if (!needsRegistration) { + return { + name: 'publicVote', + params: { token: to.params.token }, + replace: true, + } + } + } + if (to.meta.votePage) { const pollStore = usePollStore() const votesStore = useVotesStore() @@ -255,6 +294,21 @@ router.beforeResolve(async (to: RouteLocationNormalized) => { return { name: 'notfound' } } + // Redirect unregistered public users to the registration page + if (to.name === 'publicVote') { + const needsRegistration = + pollStore.currentUserStatus.userRole === 'public' + && !pollStore.isClosed + && !pollStore.currentUserStatus.isLocked + + if (needsRegistration) { + return { + name: 'publicRegister', + params: { token: to.params.token }, + } + } + } + await Promise.allSettled([ votesStore.load(), optionsStore.load(), diff --git a/src/stores/options.ts b/src/stores/options.ts index a9486ae346..9413e5c104 100644 --- a/src/stores/options.ts +++ b/src/stores/options.ts @@ -115,7 +115,10 @@ export const useOptionsStore = defineStore('options', { const sessionStore = useSessionStore() try { const response = await (() => { - if (sessionStore.route.name === 'publicVote') { + if ( + sessionStore.route.name === 'publicVote' + || sessionStore.route.name === 'publicRegister' + ) { return PublicAPI.getOptions( sessionStore.route.params.token as string, ) diff --git a/src/stores/poll.ts b/src/stores/poll.ts index 541bf88c70..24534b9773 100644 --- a/src/stores/poll.ts +++ b/src/stores/poll.ts @@ -266,7 +266,7 @@ export const usePollStore = defineStore('poll', { try { const response = await (() => { - if (sessionStore.route.name === 'publicVote') { + if (sessionStore.route.name === 'publicVote' || sessionStore.route.name === 'publicRegister') { return PublicAPI.getPoll(sessionStore.route.params.token) } if (sessionStore.route.name === 'vote') { diff --git a/src/stores/session.ts b/src/stores/session.ts index 2e7a640f78..e112b8b5f8 100644 --- a/src/stores/session.ts +++ b/src/stores/session.ts @@ -53,42 +53,13 @@ export const useSessionStore = defineStore('session', { timezoneName: 'local', }, appSettings: { - allAccessGroups: [], - allowCombo: true, - allowPublicShares: true, - allowAllAccess: true, - allowPollCreation: true, - allowPollDownload: true, - autoArchive: false, - autoArchiveOffset: 30, - autoDelete: false, - autoDeleteOffset: 30, - defaultPrivacyUrl: '', - defaultImprintUrl: '', - disclaimer: '', - imprintUrl: '', - legalTermsInEmail: false, - privacyUrl: '', - showMailAddresses: false, - showLogin: true, - updateType: 'noPolling', - useActivity: false, - useCollaboration: true, - useSiteLegalTerms: true, - navigationPollsInList: true, - finalPrivacyUrl: '', finalImprintUrl: '', - comboGroups: [], - publicSharesGroups: [], - pollCreationGroups: [], - pollDownloadGroups: [], - showMailAddressesGroups: [], - unrestrictedOwner: false, - unrestrictedOwnerGroups: [], - groups: [], - status: { - loadingGroups: false, - }, + finalPrivacyUrl: '', + navigationPollsInList: false, + useLogin: false, + useActivity: false, + updateType: 'noPolling', + currentVersion: '', }, route: { currentRoute: '', @@ -261,7 +232,10 @@ export const useSessionStore = defineStore('session', { try { const response = await (() => { - if (this.route.name === 'publicVote') { + if ( + this.route.name === 'publicVote' + || this.route.name === 'publicRegister' + ) { return PublicAPI.getSession(this.publicToken) } return SessionAPI.getSession() diff --git a/src/stores/session.types.ts b/src/stores/session.types.ts index 3ca81985cb..2d31172016 100644 --- a/src/stores/session.types.ts +++ b/src/stores/session.types.ts @@ -5,7 +5,6 @@ import { RouteRecordNameGeneric } from 'vue-router' -import { AppSettingsStore } from './appSettings.types' import { ViewMode } from './preferences.types' import type { Share } from './shares.types' @@ -63,9 +62,19 @@ export type AppPermissions = { unrestrictedOwner: boolean } +type AppSettings = { + finalImprintUrl: string + finalPrivacyUrl: string + navigationPollsInList: boolean + useLogin: boolean + useActivity: boolean + updateType: string + currentVersion: string +} + export type Session = { appPermissions: AppPermissions - appSettings: AppSettingsStore + appSettings: AppSettings currentUser: User route: Route sessionSettings: SessionSettings diff --git a/src/views/PublicRegisterView.vue b/src/views/PublicRegisterView.vue new file mode 100644 index 0000000000..fff994a4e0 --- /dev/null +++ b/src/views/PublicRegisterView.vue @@ -0,0 +1,594 @@ + + + + + + + diff --git a/tests/Unit/Db/ShareMapperTest.php b/tests/Unit/Db/ShareMapperTest.php index 089089059a..4e459c5b6f 100644 --- a/tests/Unit/Db/ShareMapperTest.php +++ b/tests/Unit/Db/ShareMapperTest.php @@ -45,6 +45,46 @@ protected function setUp(): void { unset($poll); } + public function testGetURLWithNoPollId(): void { + $share = new Share(); + $this->assertEmpty($share->getURL(), 'URL must be empty when no poll ID is set'); + } + + public function testGetURLForInternalShare(): void { + foreach ($this->shares as $share) { + // Factory creates TYPE_USER shares which use the internal vote route + $url = $share->getURL(); + $this->assertNotEmpty($url, 'URL must not be empty for a share with a poll ID'); + $this->assertStringContainsString( + '/apps/polls/vote/' . $share->getPollId(), + $url, + 'Internal share URL must contain /apps/polls/vote/{pollId}', + ); + } + } + + public function testGetURLForPublicShare(): void { + foreach ($this->polls as $poll) { + $share = new Share(); + $share->setType(Share::TYPE_EMAIL); + $share->setPollId($poll->getId()); + $share->setToken(bin2hex(random_bytes(16))); + $share->setUserId('test@example.com'); + $share->setEmailAddress('test@example.com'); + + $share = $this->shareMapper->insert($share); + array_push($this->shares, $share); + + $url = $share->getURL(); + $this->assertNotEmpty($url, 'URL must not be empty for a public share'); + $this->assertStringContainsString( + '/apps/polls/s/' . $share->getToken(), + $url, + 'Public share URL must contain /apps/polls/s/{token}', + ); + } + } + public function testFindByPoll(): void { foreach ($this->polls as $poll) { $this->assertGreaterThan(0, count($this->shareMapper->findByPoll($poll->getId())));