Skip to content

Commit de8cdb5

Browse files
committed
feat: add guest auth prompt component
Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
1 parent 54770d0 commit de8cdb5

8 files changed

Lines changed: 9690 additions & 5744 deletions

File tree

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
3+
- SPDX-License-Identifier: AGPL-3.0-or-later
4+
-->
5+
6+
<template>
7+
<NcDialog :buttons="dialogButtons"
8+
class="public-auth-prompt"
9+
data-cy-public-auth-prompt-dialog
10+
is-form
11+
:can-close="false"
12+
:name="title"
13+
@submit="onSubmit">
14+
<p v-if="subtitle" class="public-auth-prompt__subtitle">
15+
{{ subtitle }}
16+
</p>
17+
18+
<!-- Header -->
19+
<NcNoteCard class="public-auth-prompt__header"
20+
:text="notice"
21+
type="info" />
22+
23+
<!-- Form -->
24+
<NcTextField ref="input"
25+
class="public-auth-prompt__input"
26+
data-cy-public-auth-prompt-dialog-name
27+
:label="t('files_sharing', 'Name')"
28+
:placeholder="t('files_sharing', 'Enter your name')"
29+
:required="!cancellable"
30+
:value.sync="name"
31+
minlength="2"
32+
name="name" />
33+
</NcDialog>
34+
</template>
35+
36+
<script lang="ts">
37+
import { defineComponent } from 'vue'
38+
import { getBuilder } from '@nextcloud/browser-storage'
39+
import { t } from '@nextcloud/l10n'
40+
41+
import NcButton from '@nextcloud/vue/components/NcButton'
42+
import NcDialog from '@nextcloud/vue/components/NcDialog'
43+
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
44+
import NcTextField from '@nextcloud/vue/components/NcTextField'
45+
46+
import { setGuestNickname } from '../guest'
47+
48+
const storage = getBuilder('files_sharing').build()
49+
50+
type ButtonType = InstanceType<typeof NcButton>['$props']['type']
51+
52+
// TODO: move to @nextcloud/auth
53+
export default defineComponent({
54+
name: 'PublicAuthPrompt',
55+
56+
components: {
57+
NcDialog,
58+
NcNoteCard,
59+
NcTextField,
60+
},
61+
props: {
62+
/**
63+
* Preselected nickname
64+
* @default '' No name preselected by default
65+
*/
66+
nickname: {
67+
type: String,
68+
default: '',
69+
},
70+
71+
/**
72+
* Dialog title
73+
*/
74+
title: {
75+
type: String,
76+
default: t('files_sharing', 'Guest identification'),
77+
},
78+
79+
/**
80+
* Dialog subtitle
81+
* @default 'Enter your name to access the file'
82+
*/
83+
subtitle: {
84+
type: String,
85+
default: '',
86+
},
87+
88+
/**
89+
* Dialog notice
90+
* @default 'You are currently not identified.'
91+
*/
92+
notice: {
93+
type: String,
94+
default: t('files_sharing', 'You are currently not identified.'),
95+
},
96+
97+
/**
98+
* Dialog submit button label
99+
* @default 'Submit name'
100+
*/
101+
submitLabel: {
102+
type: String,
103+
default: t('files_sharing', 'Submit name'),
104+
},
105+
106+
/**
107+
* Whether the dialog is cancellable
108+
* @default false
109+
*/
110+
cancellable: {
111+
type: Boolean,
112+
default: false,
113+
},
114+
},
115+
116+
emits: ['close'],
117+
118+
setup() {
119+
return {
120+
t,
121+
}
122+
},
123+
124+
data() {
125+
return {
126+
name: '',
127+
}
128+
},
129+
130+
computed: {
131+
dialogButtons() {
132+
const cancelButton = {
133+
label: t('files_sharing', 'Cancel'),
134+
type: 'tertiary' as ButtonType,
135+
callback: () => this.$emit('close'),
136+
}
137+
138+
const submitButton = {
139+
label: this.submitLabel,
140+
type: 'primary' as ButtonType,
141+
nativeType: 'submit',
142+
}
143+
144+
// If the dialog is cancellable, add a cancel button
145+
if (this.cancellable) {
146+
return [cancelButton, submitButton]
147+
}
148+
149+
return [submitButton]
150+
},
151+
},
152+
153+
watch: {
154+
/** Reset name to pre-selected nickname (e.g. Talk / Collabora ) */
155+
nickname: {
156+
handler() {
157+
this.name = this.nickname
158+
},
159+
immediate: true,
160+
},
161+
},
162+
163+
methods: {
164+
onSubmit() {
165+
const nickname = this.name.trim()
166+
const input = this.$refs.input as HTMLInputElement
167+
168+
if (nickname === '') {
169+
// Show error if the nickname is empty
170+
input.setCustomValidity(t('files_sharing', 'You cannot leave the name empty.'))
171+
input.reportValidity()
172+
return
173+
}
174+
175+
if (nickname.length < 2) {
176+
// Show error if the nickname is too short
177+
input.setCustomValidity(t('files_sharing', 'Please enter a name with at least 2 characters.'))
178+
input.reportValidity()
179+
return
180+
}
181+
182+
try {
183+
// Set the nickname
184+
setGuestNickname(nickname)
185+
} catch (e) {
186+
input.setCustomValidity(t('files_sharing', 'Failed to set nickname.'))
187+
input.reportValidity()
188+
return
189+
}
190+
191+
// Set the dialog as shown
192+
storage.setItem('public-auth-prompt-shown', 'true')
193+
194+
// Close the dialog
195+
this.$emit('close', this.name)
196+
},
197+
},
198+
})
199+
</script>
200+
<style scoped lang="scss">
201+
.public-auth-prompt {
202+
&__subtitle {
203+
// Smaller than dialog title
204+
font-size: 1.25em;
205+
margin-block: 0 calc(3 * var(--default-grid-baseline));
206+
}
207+
208+
&__header {
209+
margin-block: 0 calc(3 * var(--default-grid-baseline));
210+
// No extra top margin for the first child
211+
&:first-child {
212+
margin-top: 0;
213+
}
214+
}
215+
216+
&__input {
217+
margin-block: calc(4 * var(--default-grid-baseline)) calc(2 * var(--default-grid-baseline));
218+
}
219+
}
220+
</style>

lib/guest.ts

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,86 @@
22
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
33
* SPDX-License-Identifier: GPL-3.0-or-later
44
*/
5+
import type { ComponentProps } from 'vue-component-type-helpers'
6+
import { defineAsyncComponent } from 'vue'
57
import { getBuilder } from '@nextcloud/browser-storage'
8+
import { spawnDialog } from '@nextcloud/vue'
9+
import { TypedEventTarget } from 'typescript-event-target'
10+
11+
import PublicAuthPrompt from './components/PublicAuthPrompt.vue'
612

713
const browserStorage = getBuilder('public').persist().build()
814

15+
/**
16+
* This event is emitted when the list of registered views is changed
17+
*/
18+
interface UpdateGuestDisplayName extends CustomEvent<never> {
19+
type: 'updateDisplayName'
20+
}
21+
22+
class GuestUser extends TypedEventTarget<{ updateDisplayName: UpdateGuestDisplayName }> {
23+
24+
displayName: string | null
25+
readonly uid: string
26+
readonly isAdmin: boolean
27+
28+
constructor() {
29+
super()
30+
this.displayName = browserStorage.getItem('guestNickname') || ''
31+
this.uid = browserStorage.getItem('guestUid') || self.crypto.randomUUID()
32+
this.isAdmin = false
33+
}
34+
35+
setDisplayName(displayName: string): void {
36+
this.displayName = displayName
37+
browserStorage.setItem('guestNickname', displayName)
38+
this.dispatchTypedEvent('updateDisplayName', new CustomEvent('updateDisplayName') as UpdateGuestDisplayName)
39+
}
40+
41+
}
42+
43+
let currentUser: GuestUser | null | undefined
44+
45+
/**
46+
* Get the currently Guest user or null if not logged in
47+
*/
48+
export function getGuestUser(): GuestUser {
49+
if (!currentUser) {
50+
currentUser = new GuestUser()
51+
}
52+
53+
return currentUser
54+
}
55+
956
/**
1057
* Get the guest nickname for public pages
1158
*/
1259
export function getGuestNickname(): string | null {
13-
return browserStorage.getItem('guestNickname')
60+
return getGuestUser()?.displayName || null
1461
}
1562

1663
/**
1764
* Set the guest nickname for public pages
1865
* @param nickname The nickname to set
1966
*/
2067
export function setGuestNickname(nickname: string): void {
21-
browserStorage.setItem('guestNickname', nickname)
68+
if (!nickname || nickname.trim().length === 0) {
69+
throw new Error('Nickname cannot be empty')
70+
}
71+
72+
getGuestUser().setDisplayName(nickname)
73+
}
74+
75+
type PublicAuthPromptProps = ComponentProps<typeof PublicAuthPrompt>
76+
77+
/**
78+
* Show the public auth prompt dialog
79+
* This is used to ask the current user their nickname
80+
* as well as show some additional contextual information
81+
*/
82+
export function showGuestUserPrompt(props: PublicAuthPromptProps): void {
83+
spawnDialog(
84+
defineAsyncComponent(() => import('./components/PublicAuthPrompt.vue')),
85+
props,
86+
)
2287
}

lib/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@ export type { CsrfTokenObserver } from './requesttoken'
66
export type { NextcloudUser } from './user'
77

88
export { getCSPNonce } from './csp-nonce'
9-
export { getGuestNickname, setGuestNickname } from './guest'
9+
export { getGuestUser, getGuestNickname, setGuestNickname, showGuestUserPrompt } from './guest'
1010
export { getRequestToken, onRequestTokenUpdate } from './requesttoken'
1111
export { getCurrentUser } from './user'

0 commit comments

Comments
 (0)