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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('polls', 'Available options') }}
+
+ -
+
+
+ {{ optionLabel(option) }}
+
+
+
+ {{
+ n(
+ 'polls',
+ '+ %n more option',
+ '+ %n more options',
+ optionsStore.options.length - MAX_OPTIONS,
+ )
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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())));