From 38581ca1c05f37b3f404571da34358660d1b667b Mon Sep 17 00:00:00 2001 From: Gauthier Date: Mon, 21 Sep 2026 10:11:05 +0200 Subject: [PATCH] feat: add a demo feature (#3017) --- cypress.config.ts | 2 +- cypress/e2e/discover.cy.ts | 4 +- next.config.ts | 1 + server/entity/MediaRequest.test.ts | 2 +- server/index.ts | 8 +++ server/routes/auth.test.ts | 2 +- server/routes/issue.test.ts | 18 +++--- server/routes/request.test.ts | 36 +++++------ server/routes/user/usersettings.test.ts | 4 +- server/utils/demoMode.ts | 86 +++++++++++++++++++++++++ server/utils/seedTestDb.ts | 12 ++-- src/components/Login/LocalLogin.tsx | 13 +++- src/i18n/locale/en.json | 1 + 13 files changed, 147 insertions(+), 42 deletions(-) create mode 100644 server/utils/demoMode.ts diff --git a/cypress.config.ts b/cypress.config.ts index 0fa88d2f9..5db5e200b 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ env: { ADMIN_EMAIL: 'admin@seerr.dev', ADMIN_PASSWORD: 'test1234', - USER_EMAIL: 'friend@seerr.dev', + USER_EMAIL: 'demo@seerr.dev', USER_PASSWORD: 'test1234', }, retries: { diff --git a/cypress/e2e/discover.cy.ts b/cypress/e2e/discover.cy.ts index 52a5e883a..66bc7713c 100644 --- a/cypress/e2e/discover.cy.ts +++ b/cypress/e2e/discover.cy.ts @@ -145,7 +145,7 @@ describe('Discover', () => { requestedBy: { permissions: 4194336, id: 18, - email: 'friend@seerr.dev', + email: 'demo@seerr.dev', plexUsername: null, username: '', recoveryLinkExpirationDate: null, @@ -159,7 +159,7 @@ describe('Discover', () => { createdAt: '2022-08-17T04:55:28.000Z', updatedAt: '2022-08-17T04:55:28.000Z', requestCount: 1, - displayName: 'friend@seerr.dev', + displayName: 'demo@seerr.dev', }, seasonCount: 0, }, diff --git a/next.config.ts b/next.config.ts index 25c023ac2..a3b5414f0 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,6 +3,7 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { env: { commitTag: process.env.COMMIT_TAG || 'local', + unsafeDoNotUseDemo: process.env.UNSAFE_DO_NOT_USE_DEMO, }, images: { remotePatterns: [ diff --git a/server/entity/MediaRequest.test.ts b/server/entity/MediaRequest.test.ts index 9ff5067f8..007cba51c 100644 --- a/server/entity/MediaRequest.test.ts +++ b/server/entity/MediaRequest.test.ts @@ -46,7 +46,7 @@ async function seedRequester(movieQuotaLimit: number): Promise { const userRepository = getRepository(User); const requester = await userRepository.findOneOrFail({ - where: { email: 'friend@seerr.dev' }, + where: { email: 'demo@seerr.dev' }, }); requester.movieQuotaLimit = movieQuotaLimit; diff --git a/server/index.ts b/server/index.ts index eb374099c..cce36c967 100644 --- a/server/index.ts +++ b/server/index.ts @@ -30,6 +30,7 @@ import createCustomProxyAgent, { setForceIpv4First, } from '@server/utils/customProxyAgent'; import { isPgsql } from '@server/utils/dbType'; +import { initDemoData } from '@server/utils/demoMode'; import { initializeDnsCache } from '@server/utils/dnsCache'; import restartFlag from '@server/utils/restartFlag'; import '@server/utils/userAgent'; @@ -253,6 +254,13 @@ app }; next(); }); + + // Init demo mode and catch some API routes + if (process.env.UNSAFE_DO_NOT_USE_DEMO === 'true') { + logger.info('Demo mode enabled, seeding database with demo data'); + await initDemoData(server); + } + server.use('/api/v1', routes); // Do not set cookies so CDNs can cache them diff --git a/server/routes/auth.test.ts b/server/routes/auth.test.ts index 07411d4e7..152277a91 100644 --- a/server/routes/auth.test.ts +++ b/server/routes/auth.test.ts @@ -692,7 +692,7 @@ describe('POST /auth/local', () => { it('allows the non-admin user to log in', async () => { const res = await request(app) .post('/auth/local') - .send({ email: 'friend@seerr.dev', password: 'test1234' }); + .send({ email: 'demo@seerr.dev', password: 'test1234' }); assert.strictEqual(res.status, 200); assert.ok('id' in res.body); diff --git a/server/routes/issue.test.ts b/server/routes/issue.test.ts index dcef47631..6948801d9 100644 --- a/server/routes/issue.test.ts +++ b/server/routes/issue.test.ts @@ -109,7 +109,7 @@ describe('POST /issue', () => { const userRepo = getRepository(User); const media = await seedMedia(); const friend = await userRepo.findOneOrFail({ - where: { email: 'friend@seerr.dev' }, + where: { email: 'demo@seerr.dev' }, }); const agent = await loginAs('admin@seerr.dev', 'test1234'); @@ -123,8 +123,8 @@ describe('POST /issue', () => { }); assert.strictEqual(res.status, 201); - assert.strictEqual(res.body.createdBy.email, 'friend@seerr.dev'); - assert.strictEqual(res.body.comments[0].user.email, 'friend@seerr.dev'); + assert.strictEqual(res.body.createdBy.email, 'demo@seerr.dev'); + assert.strictEqual(res.body.comments[0].user.email, 'demo@seerr.dev'); const persisted = await issueRepo.findOneOrFail({ where: { id: res.body.id }, @@ -153,13 +153,13 @@ describe('POST /issue', () => { const userRepo = getRepository(User); const media = await seedMedia(); const friend = await userRepo.findOneOrFail({ - where: { email: 'friend@seerr.dev' }, + where: { email: 'demo@seerr.dev' }, }); friend.permissions = Permission.CREATE_ISSUES; await userRepo.save(friend); - const agent = await loginAs('friend@seerr.dev', 'test1234'); + const agent = await loginAs('demo@seerr.dev', 'test1234'); const res = await agent.post('/issue').send({ issueType: IssueType.SUBTITLES, message: 'Subtitles are missing.', @@ -168,15 +168,15 @@ describe('POST /issue', () => { }); assert.strictEqual(res.status, 201); - assert.strictEqual(res.body.createdBy.email, 'friend@seerr.dev'); - assert.strictEqual(res.body.comments[0].user.email, 'friend@seerr.dev'); + assert.strictEqual(res.body.createdBy.email, 'demo@seerr.dev'); + assert.strictEqual(res.body.comments[0].user.email, 'demo@seerr.dev'); }); it('prevents non-managers from supplying another userId', async () => { const userRepo = getRepository(User); const media = await seedMedia(); const friend = await userRepo.findOneOrFail({ - where: { email: 'friend@seerr.dev' }, + where: { email: 'demo@seerr.dev' }, }); const admin = await userRepo.findOneOrFail({ where: { email: 'admin@seerr.dev' }, @@ -185,7 +185,7 @@ describe('POST /issue', () => { friend.permissions = Permission.CREATE_ISSUES; await userRepo.save(friend); - const agent = await loginAs('friend@seerr.dev', 'test1234'); + const agent = await loginAs('demo@seerr.dev', 'test1234'); const res = await agent.post('/issue').send({ issueType: IssueType.OTHER, message: 'Something else is wrong.', diff --git a/server/routes/request.test.ts b/server/routes/request.test.ts index 2c88e8420..e82a76d24 100644 --- a/server/routes/request.test.ts +++ b/server/routes/request.test.ts @@ -193,7 +193,7 @@ async function seedRequest(status = MediaRequestStatus.PENDING) { const requestRepo = getRepository(MediaRequest); const requestedBy = await userRepo.findOneOrFail({ - where: { email: 'friend@seerr.dev' }, + where: { email: 'demo@seerr.dev' }, }); const media = await mediaRepo.save( @@ -226,7 +226,7 @@ describe('DELETE /request/:requestId', () => { it('allows the owner to delete their own pending request', async () => { const mediaRequest = await seedRequest(); - const agent = await loginAs('friend@seerr.dev', 'test1234'); + const agent = await loginAs('demo@seerr.dev', 'test1234'); const res = await agent.delete(`/request/${mediaRequest.id}`); assert.strictEqual(res.status, 204); @@ -270,7 +270,7 @@ describe('DELETE /request/:requestId', () => { }) ); - const agent = await loginAs('friend@seerr.dev', 'test1234'); + const agent = await loginAs('demo@seerr.dev', 'test1234'); const res = await agent.delete(`/request/${mediaRequest.id}`); assert.strictEqual(res.status, 401); @@ -279,7 +279,7 @@ describe('DELETE /request/:requestId', () => { it('prevents the owner from deleting an approved request', async () => { const mediaRequest = await seedRequest(MediaRequestStatus.APPROVED); - const agent = await loginAs('friend@seerr.dev', 'test1234'); + const agent = await loginAs('demo@seerr.dev', 'test1234'); const res = await agent.delete(`/request/${mediaRequest.id}`); assert.strictEqual(res.status, 401); @@ -349,7 +349,7 @@ describe('PUT /request/:requestId (tv)', () => { where: { email: 'admin@seerr.dev' }, }); const otherUser = await userRepo.findOneOrFail({ - where: { email: 'friend@seerr.dev' }, + where: { email: 'demo@seerr.dev' }, }); const media = await mediaRepo.save( @@ -798,7 +798,7 @@ describe('POST /request (movie), override rules', () => { const userRepo = getRepository(User); const friend = await userRepo.findOneOrFail({ - where: { email: 'friend@seerr.dev' }, + where: { email: 'demo@seerr.dev' }, }); const overrideRuleRepo = getRepository(OverrideRule); @@ -810,7 +810,7 @@ describe('POST /request (movie), override rules', () => { }) ); - const agent = await loginAs('friend@seerr.dev', 'test1234'); + const agent = await loginAs('demo@seerr.dev', 'test1234'); const res = await agent.post('/request').send({ mediaType: MediaType.MOVIE, mediaId: 88001, @@ -826,7 +826,7 @@ describe('POST /request (movie), override rules', () => { const userRepo = getRepository(User); const friend = await userRepo.findOneOrFail({ - where: { email: 'friend@seerr.dev' }, + where: { email: 'demo@seerr.dev' }, }); const overrideRuleRepo = getRepository(OverrideRule); @@ -838,7 +838,7 @@ describe('POST /request (movie), override rules', () => { }) ); - const agent = await loginAs('friend@seerr.dev', 'test1234'); + const agent = await loginAs('demo@seerr.dev', 'test1234'); const res = await agent.post('/request').send({ mediaType: MediaType.MOVIE, mediaId: 88002, @@ -854,7 +854,7 @@ describe('POST /request (movie), override rules', () => { const userRepo = getRepository(User); const friend = await userRepo.findOneOrFail({ - where: { email: 'friend@seerr.dev' }, + where: { email: 'demo@seerr.dev' }, }); const overrideRuleRepo = getRepository(OverrideRule); @@ -866,7 +866,7 @@ describe('POST /request (movie), override rules', () => { }) ); - const agent = await loginAs('friend@seerr.dev', 'test1234'); + const agent = await loginAs('demo@seerr.dev', 'test1234'); const res = await agent.post('/request').send({ mediaType: MediaType.MOVIE, mediaId: 88005, @@ -884,7 +884,7 @@ describe('POST /request (tv), override rules', () => { const userRepo = getRepository(User); const friend = await userRepo.findOneOrFail({ - where: { email: 'friend@seerr.dev' }, + where: { email: 'demo@seerr.dev' }, }); const overrideRuleRepo = getRepository(OverrideRule); @@ -896,7 +896,7 @@ describe('POST /request (tv), override rules', () => { }) ); - const agent = await loginAs('friend@seerr.dev', 'test1234'); + const agent = await loginAs('demo@seerr.dev', 'test1234'); const res = await agent.post('/request').send({ mediaType: MediaType.TV, mediaId: 88003, @@ -913,7 +913,7 @@ describe('POST /request (tv), override rules', () => { const userRepo = getRepository(User); const friend = await userRepo.findOneOrFail({ - where: { email: 'friend@seerr.dev' }, + where: { email: 'demo@seerr.dev' }, }); const overrideRuleRepo = getRepository(OverrideRule); @@ -925,7 +925,7 @@ describe('POST /request (tv), override rules', () => { }) ); - const agent = await loginAs('friend@seerr.dev', 'test1234'); + const agent = await loginAs('demo@seerr.dev', 'test1234'); const res = await agent.post('/request').send({ mediaType: MediaType.TV, mediaId: 88004, @@ -942,7 +942,7 @@ describe('POST /request (tv), override rules', () => { const userRepo = getRepository(User); const friend = await userRepo.findOneOrFail({ - where: { email: 'friend@seerr.dev' }, + where: { email: 'demo@seerr.dev' }, }); const overrideRuleRepo = getRepository(OverrideRule); @@ -954,7 +954,7 @@ describe('POST /request (tv), override rules', () => { }) ); - const agent = await loginAs('friend@seerr.dev', 'test1234'); + const agent = await loginAs('demo@seerr.dev', 'test1234'); const res = await agent.post('/request').send({ mediaType: MediaType.TV, mediaId: 88006, @@ -1029,7 +1029,7 @@ describe('DELETE /request/:requestId, orphaned season status reset', () => { }); assert.strictEqual(updated.seasons[0].status, MediaStatus.UNKNOWN); - const friend = await loginAs('friend@seerr.dev', 'test1234'); + const friend = await loginAs('demo@seerr.dev', 'test1234'); const reRequest = await friend.post('/request').send({ mediaType: MediaType.TV, mediaId: 99101, diff --git a/server/routes/user/usersettings.test.ts b/server/routes/user/usersettings.test.ts index ba8335875..90d0f7fbb 100644 --- a/server/routes/user/usersettings.test.ts +++ b/server/routes/user/usersettings.test.ts @@ -99,7 +99,7 @@ describe('POST /user/:id/settings/linked-accounts/jellyfin/quickconnect', () => }); it('links the account when the media server is Jellyfin', async () => { - const { agent, userId } = await loginAs('friend@seerr.dev', 'test1234'); + const { agent, userId } = await loginAs('demo@seerr.dev', 'test1234'); const res = await agent .post(`/user/${userId}/settings/linked-accounts/jellyfin/quickconnect`) @@ -116,7 +116,7 @@ describe('POST /user/:id/settings/linked-accounts/jellyfin/quickconnect', () => }); it('returns 403 when the media server is Emby', async () => { - const { agent, userId } = await loginAs('friend@seerr.dev', 'test1234'); + const { agent, userId } = await loginAs('demo@seerr.dev', 'test1234'); getSettings().main.mediaServerType = MediaServerType.EMBY; const res = await agent diff --git a/server/utils/demoMode.ts b/server/utils/demoMode.ts new file mode 100644 index 000000000..a5a1808a8 --- /dev/null +++ b/server/utils/demoMode.ts @@ -0,0 +1,86 @@ +import dataSource from '@server/datasource'; +import DiscoverSlider from '@server/entity/DiscoverSlider'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { seedTestUsers } from '@server/utils/seedTestDb'; +import type { Express } from 'express'; +import fs from 'fs'; +import path from 'path'; + +async function resetDemoData(): Promise { + try { + // Drop all tables except users and sessions + const entities = dataSource.entityMetadatas; + for (const entity of entities) { + if (['session', 'user'].includes(entity.tableName.toLowerCase())) { + continue; + } + await dataSource.getRepository(entity.name).deleteAll(); + } + // Add the default users + await seedTestUsers(); + // Remove admin user from the database + await dataSource.getRepository('user').delete({ email: 'admin@seerr.dev' }); + // Re-seed discover sliders + await DiscoverSlider.bootstrapSliders(); + } catch (error) { + logger.error('Failed to reset demo data', { error: error.message }); + } +} + +export async function initDemoData(server: Express): Promise { + // Check that no other data than the demo user exists in the database before seeding demo data + const userCount = await dataSource.getRepository('user').count(); + if ( + userCount > 1 || + (userCount === 1 && + !(await dataSource + .getRepository('user') + .findOneBy({ email: 'demo@seerr.dev' }))) + ) { + logger.error('Database is not empty, cannot seed demo data. Aborting.'); + process.exit(1); + } + + // Retrieve settings from Cypress tests + try { + fs.copyFileSync( + path.join(__dirname, '../../cypress/config/settings.cypress.json'), + path.join(__dirname, '../../config/settings.json') + ); + } catch (error) { + logger.error('Failed to copy settings from Cypress tests', { error }); + process.exit(1); + } + const settings = getSettings(); + await settings.load(); + settings.main.mediaServerLogin = false; + await settings.save(); + // Seed the database with demo data + await resetDemoData(); + // Resets the database every hour + setInterval(resetDemoData, 60 * 60 * 1000); + + // Disable password sign-in for non-demo users + server.post('/api/v1/auth/local', async (req, res, next) => { + if (req.body?.email !== 'demo@seerr.dev') { + return res.status(403).json({ error: 'Password sign-in is disabled.' }); + } + next(); + }); + // Disable password change + server.post('/api/v1/user/:id/settings/password', async (_, res) => { + return res.status(204).send(); + }); + // Disable test user modification + server.post('/api/v1/user/:id/settings/main', async (_, res) => { + return res.status(200).json({}); + }); + // Disable unlinking media servers + server.post( + '/api/v1/user/:id/settings/linked-accounts/plex', + async (_, res) => { + return res.status(204).send(); + } + ); +} diff --git a/server/utils/seedTestDb.ts b/server/utils/seedTestDb.ts index b91d1abf4..534173870 100644 --- a/server/utils/seedTestDb.ts +++ b/server/utils/seedTestDb.ts @@ -31,7 +31,7 @@ function assertTestDatabase(operation: string, allowOutsideTest = false): void { * Seeds test users into the database. * Assumes the database schema is already set up. */ -async function seedTestUsers(): Promise { +export async function seedTestUsers(): Promise { const userRepository = getRepository(User); const admin = await userRepository.findOne({ @@ -58,17 +58,17 @@ async function seedTestUsers(): Promise { // Create the other user const otherUser = (await userRepository.findOne({ - where: { email: 'friend@seerr.dev' }, + where: { email: 'demo@seerr.dev' }, })) ?? new User(); otherUser.plexId = admin?.plexId ?? 1; otherUser.plexToken = '1234'; - otherUser.plexUsername = 'friend'; - otherUser.username = 'friend'; - otherUser.email = 'friend@seerr.dev'; + otherUser.plexUsername = 'demo'; + otherUser.username = 'demo'; + otherUser.email = 'demo@seerr.dev'; otherUser.userType = UserType.PLEX; otherUser.password = TEST_USER_PASSWORD_HASH; otherUser.permissions = 32; - otherUser.avatar = gravatarUrl('friend@seerr.dev', { + otherUser.avatar = gravatarUrl('demo@seerr.dev', { default: 'mm', size: 200, }); diff --git a/src/components/Login/LocalLogin.tsx b/src/components/Login/LocalLogin.tsx index 7fdd529a9..56dd227bd 100644 --- a/src/components/Login/LocalLogin.tsx +++ b/src/components/Login/LocalLogin.tsx @@ -1,3 +1,4 @@ +import Alert from '@app/components/Common/Alert'; import Button from '@app/components/Common/Button'; import SensitiveInput from '@app/components/Common/SensitiveInput'; import useSettings from '@app/hooks/useSettings'; @@ -27,6 +28,7 @@ const messages = defineMessages('components.Login', { signingin: 'Signing In…', signin: 'Sign In', forgotpassword: 'Forgot Password?', + demoModeInfo: 'Demo mode is enabled. Use the demo credentials to sign in.', }); interface LocalLoginProps { @@ -54,8 +56,9 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => { return ( { })} + {process.env.unsafeDoNotUseDemo === 'true' && ( + + {intl.formatMessage(messages.demoModeInfo)} + + )} +