Skip to content

Commit b9568cc

Browse files
committed
feat: add guest user
Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
1 parent 34caccb commit b9568cc

File tree

3 files changed

+240
-3
lines changed

3 files changed

+240
-3
lines changed

lib/guest.ts

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,77 @@
33
* SPDX-License-Identifier: GPL-3.0-or-later
44
*/
55
import { getBuilder } from '@nextcloud/browser-storage'
6+
import { TypedEventTarget } from 'typescript-event-target'
7+
import { NextcloudUser } from './user'
8+
import { emit } from '@nextcloud/event-bus'
69

710
const browserStorage = getBuilder('public').persist().build()
811

12+
/**
13+
* This event is emitted when the list of registered views is changed
14+
*/
15+
interface UpdateGuestDisplayName extends CustomEvent<never> {
16+
type: 'updateDisplayName'
17+
}
18+
19+
class GuestUser extends TypedEventTarget<{ updateDisplayName: UpdateGuestDisplayName }> implements NextcloudUser {
20+
21+
private _displayName: string | null
22+
readonly uid: string
23+
readonly isAdmin: boolean
24+
25+
constructor() {
26+
super()
27+
if (!browserStorage.getItem('guestUid')) {
28+
browserStorage.setItem('guestUid', self.crypto.randomUUID())
29+
}
30+
31+
this._displayName = browserStorage.getItem('guestNickname') || ''
32+
this.uid = browserStorage.getItem('guestUid') || self.crypto.randomUUID()
33+
this.isAdmin = false
34+
35+
}
36+
37+
get displayName(): string | null {
38+
return this._displayName
39+
}
40+
41+
set displayName(displayName: string) {
42+
this._displayName = displayName
43+
browserStorage.setItem('guestNickname', displayName)
44+
emit('user:info:changed', this)
45+
}
46+
47+
}
48+
49+
let currentUser: GuestUser | undefined
50+
51+
/**
52+
* Get the currently Guest user or null if not logged in
53+
*/
54+
export function getGuestUser(): GuestUser {
55+
if (!currentUser) {
56+
currentUser = new GuestUser()
57+
}
58+
59+
return currentUser
60+
}
61+
962
/**
1063
* Get the guest nickname for public pages
1164
*/
1265
export function getGuestNickname(): string | null {
13-
return browserStorage.getItem('guestNickname')
66+
return getGuestUser()?.displayName || null
1467
}
1568

1669
/**
1770
* Set the guest nickname for public pages
1871
* @param nickname The nickname to set
1972
*/
2073
export function setGuestNickname(nickname: string): void {
21-
browserStorage.setItem('guestNickname', nickname)
74+
if (!nickname || nickname.trim().length === 0) {
75+
throw new Error('Nickname cannot be empty')
76+
}
77+
78+
getGuestUser().displayName = nickname
2279
}

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 } from './guest'
1010
export { getRequestToken, onRequestTokenUpdate } from './requesttoken'
1111
export { getCurrentUser } from './user'

test/guest.test.ts

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: GPL-3.0-or-later
4+
*/
5+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
6+
import { getBuilder } from '@nextcloud/browser-storage'
7+
import { emit } from '@nextcloud/event-bus'
8+
9+
// Mock dependencies
10+
vi.mock('@nextcloud/browser-storage')
11+
vi.mock('@nextcloud/event-bus')
12+
13+
let tmpBrowserStorage = {}
14+
15+
// Mock browser storage
16+
const mockBrowserStorage = {
17+
getItem: vi.fn((key) => tmpBrowserStorage[key]),
18+
setItem: vi.fn((key, value) => { tmpBrowserStorage[key] = value }),
19+
removeItem: vi.fn((key) => { delete tmpBrowserStorage[key] }),
20+
}
21+
22+
// Mock crypto for UUID generation
23+
const originalCrypto = global.crypto
24+
const mockCrypto = {
25+
randomUUID: vi.fn(() => 'mock-uuid-' + Math.random().toString(36).slice(2, 10)),
26+
}
27+
28+
describe('Guest User Module', () => {
29+
beforeEach(() => {
30+
// Setup mocks
31+
vi.clearAllMocks()
32+
vi.resetModules()
33+
34+
// Clear temporary browser storage
35+
tmpBrowserStorage = {}
36+
37+
// Mock getBuilder to return our mockBrowserStorage
38+
vi.mocked(getBuilder).mockReturnValue({
39+
persist: () => ({
40+
// @ts-expect-error Mocking builder
41+
build: () => mockBrowserStorage,
42+
}),
43+
})
44+
45+
// Replace global crypto with mock
46+
Object.defineProperty(global, 'crypto', {
47+
value: mockCrypto,
48+
writable: true,
49+
})
50+
})
51+
52+
afterEach(() => {
53+
// Restore original crypto
54+
Object.defineProperty(global, 'crypto', {
55+
value: originalCrypto,
56+
writable: true,
57+
})
58+
})
59+
60+
describe('getGuestUser', () => {
61+
it('should create a new guest user with default values when no storage exists', async () => {
62+
const { getGuestUser } = await import('../lib')
63+
const guestUser = getGuestUser()
64+
65+
const uid = guestUser.uid
66+
67+
expect(guestUser.uid).toBeTruthy()
68+
expect(guestUser.displayName).toBe('')
69+
expect(guestUser.isAdmin).toBe(false)
70+
expect(mockBrowserStorage.getItem).toHaveBeenNthCalledWith(1, 'guestUid')
71+
expect(mockBrowserStorage.getItem).toHaveBeenNthCalledWith(2, 'guestNickname')
72+
expect(mockBrowserStorage.setItem).toHaveBeenCalledWith('guestUid', uid)
73+
74+
expect(mockBrowserStorage.getItem).toHaveBeenNthCalledWith(3, 'guestUid')
75+
76+
expect(guestUser.uid).toBe(uid)
77+
expect(guestUser.displayName).toBe('')
78+
expect(guestUser.isAdmin).toBe(false)
79+
80+
expect(mockCrypto.randomUUID).toHaveBeenCalledOnce()
81+
})
82+
83+
it('should return the existing guest user if already created', async () => {
84+
tmpBrowserStorage.guestNickname = 'Test User'
85+
tmpBrowserStorage.guestUid = 'existing-uid'
86+
87+
const { getGuestUser } = await import('../lib')
88+
89+
const guestUser = getGuestUser()
90+
expect(mockBrowserStorage.getItem).toHaveBeenNthCalledWith(1, 'guestUid')
91+
expect(mockBrowserStorage.getItem).toHaveBeenNthCalledWith(2, 'guestNickname')
92+
expect(mockBrowserStorage.setItem).not.toHaveBeenCalled()
93+
expect(mockBrowserStorage.getItem).toHaveBeenNthCalledWith(3, 'guestUid')
94+
95+
expect(guestUser.uid).toBe('existing-uid')
96+
expect(guestUser.displayName).toBe('Test User')
97+
expect(guestUser.isAdmin).toBe(false)
98+
99+
const newGuestUser = getGuestUser()
100+
expect(newGuestUser).toBe(guestUser)
101+
})
102+
})
103+
104+
describe('getGuestNickname', () => {
105+
it('should return null if no nickname is set', async () => {
106+
const { getGuestNickname } = await import('../lib')
107+
const nickname = getGuestNickname()
108+
109+
expect(nickname).toBeNull()
110+
})
111+
112+
it('should return the nickname if set', async () => {
113+
tmpBrowserStorage.guestNickname = 'Test User'
114+
115+
const { getGuestNickname } = await import('../lib')
116+
const nickname = getGuestNickname()
117+
118+
expect(nickname).toBe('Test User')
119+
})
120+
})
121+
122+
describe('setGuestNickname', () => {
123+
it('should throw an error if nickname is empty', async () => {
124+
const { setGuestNickname } = await import('../lib')
125+
expect(() => setGuestNickname('')).toThrow(
126+
'Nickname cannot be empty',
127+
)
128+
expect(() => setGuestNickname(' ')).toThrow(
129+
'Nickname cannot be empty',
130+
)
131+
})
132+
133+
it('should set the nickname and store it in browser storage', async () => {
134+
const nickname = 'New Test User'
135+
136+
const { getGuestUser, setGuestNickname } = await import('../lib')
137+
setGuestNickname(nickname)
138+
const guestUser = getGuestUser()
139+
140+
expect(guestUser.uid).toBeTruthy()
141+
expect(guestUser.displayName).toBe(nickname)
142+
expect(mockBrowserStorage.setItem).toHaveBeenCalledWith(
143+
'guestNickname',
144+
nickname,
145+
)
146+
147+
expect(tmpBrowserStorage.guestNickname).toBe(nickname)
148+
})
149+
150+
it('should emit a user info changed event when nickname is set', async () => {
151+
const nickname = 'Event Test User'
152+
153+
const { setGuestNickname } = await import('../lib')
154+
setGuestNickname(nickname)
155+
156+
expect(emit).toHaveBeenCalledWith(
157+
'user:info:changed',
158+
expect.anything(),
159+
)
160+
})
161+
})
162+
163+
describe('GuestUser class', () => {
164+
it('should update displayName when set through property', async () => {
165+
166+
const { getGuestUser } = await import('../lib')
167+
const guestUser = getGuestUser()
168+
const newName = 'Property Test User'
169+
170+
guestUser.displayName = newName
171+
172+
expect(guestUser.displayName).toBe(newName)
173+
expect(mockBrowserStorage.setItem).toHaveBeenCalledWith(
174+
'guestNickname',
175+
newName,
176+
)
177+
expect(emit).toHaveBeenCalledWith('user:info:changed', guestUser)
178+
})
179+
})
180+
})

0 commit comments

Comments
 (0)