Add post-login welcome and friendly how-it-works guide
Magent CI/CD / verify (push) Successful in 10m26s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped

This commit is contained in:
2026-09-07 16:08:37 +12:00
parent 0637860b95
commit 4034a8f72a
9 changed files with 174 additions and 179 deletions
+8
View File
@@ -1,4 +1,5 @@
from typing import Any, Dict from typing import Any, Dict
from urllib.parse import urlsplit
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
@@ -36,6 +37,13 @@ def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
} }
if include_changelog: if include_changelog:
info["changelog"] = (CHANGELOG or "").strip() info["changelog"] = (CHANGELOG or "").strip()
playback_url = (runtime.jellyfin_public_url or "").strip()
try:
parsed = urlsplit(playback_url)
valid = parsed.scheme in {"http", "https"} and bool(parsed.hostname) and not parsed.username and not parsed.password
except ValueError:
valid = False
info["mediaServerUrl"] = playback_url if valid else None
return info return info
+19
View File
@@ -0,0 +1,19 @@
import unittest
from unittest.mock import patch
from backend.app.config import Settings
from backend.app.routers.site import _build_site_info
class WelcomeSiteTests(unittest.TestCase):
def test_public_response_does_not_expose_playback_url(self):
with patch('backend.app.routers.site.get_runtime_settings', return_value=Settings().model_copy(update={'jellyfin_public_url': 'https://watch.example.com'})):
self.assertNotIn('mediaServerUrl', _build_site_info(False))
def test_authenticated_response_uses_public_playback_url(self):
with patch('backend.app.routers.site.get_runtime_settings', return_value=Settings().model_copy(update={'jellyfin_public_url': 'https://watch.example.com/web/'})):
self.assertEqual(_build_site_info(True)['mediaServerUrl'], 'https://watch.example.com/web/')
def test_missing_unsafe_or_credential_urls_have_no_watch_link(self):
for url in ['', 'javascript:alert(1)', '//internal', 'https://user:secret@example.com', 'https://[broken']:
with self.subTest(url=url), patch('backend.app.routers.site.get_runtime_settings', return_value=Settings().model_copy(update={'jellyfin_public_url': url})):
self.assertIsNone(_build_site_info(True)['mediaServerUrl'])
+52 -177
View File
@@ -1,181 +1,56 @@
'use client'
import PageHeading from '../ui/PageHeading' import PageHeading from '../ui/PageHeading'
import '../welcome.css'
export default function HowItWorksPage() { export default function HowItWorksPage() {
return ( return <main className="friendly-guide">
<main className="card how-page"> <PageHeading title="A little help getting started." description="Magent looks after your requests. GrizzlyFlix is where you watch them." />
<PageHeading title="How it works" description="Request something to watch, follow its progress, and get help when you need it." /> <nav aria-label="Quick links"><a href="/welcome">Welcome page</a><a href="/">My Requests</a><a href="/profile">My profile</a></nav>
<details open><summary>Request a movie or TV show</summary>
<section className="how-flow"> <ol>
<h2>What Magent is for</h2> <li><strong>Choose Movie or TV show.</strong><p>Open <a href="/new-requests">02 New Requests</a> and pick what youre looking for.</p></li>
<div className="how-grid"> <li><strong>Search and choose the right title.</strong><p>For TV, choose the seasons you want. If its already requested, open that request to see its progress.</p></li>
<article className="how-card"> <li><strong>Check your choices and send it.</strong><p>Choose from the quality options shown. These come from the librarys settings.</p></li>
<h3>Track requests</h3> <li><strong>Follow it in My Requests.</strong><p>Well show whats happening and any next step you can take. Some titles need approval or may not have a suitable download yet.</p></li>
<p> </ol>
Search by title, year, or request number to open the request page and see where an </details>
item is up to. <details><summary>Understand the six progress steps</summary>
</p> <ol>
</article> <li><strong>Requested:</strong> Your request has been received.</li>
<article className="how-card"> <li><strong>Approved:</strong> It has permission to go ahead.</li>
<h3>See live progress</h3> <li><strong>Library collection:</strong> The library is tracking whats collected and whats missing.</li>
<p> <li><strong>Release search:</strong> A suitable download is being looked for. Waiting here can mean there isnt a good match yet.</li>
Request status, timeline events, and download progress update live while you are <li><strong>Download:</strong> The files are being downloaded. TV requests can include several episodes or a season pack.</li>
viewing the page. <li><strong>Available to watch:</strong> GrizzlyFlix has added the content. Use the watch button to open it.</li>
</p> </ol>
</article> <p>A finished download still needs to be added to the media library. Wait for Available to watch before heading over.</p>
<article className="how-card"> </details>
<h3>Know when it is ready</h3> <details><summary>Something looks stuck</summary>
<p> <ol>
When the request is fully imported and available, Magent shows it as ready and links <li><strong>Open the request.</strong><p>Read its current status and next step.</p></li>
you through to Jellyfin. <li><strong>Choose Recheck request.</strong><p>Magent checks the connected services again to refresh where things are up to.</p></li>
</p> <li><strong>Follow the action offered.</strong><p>You may be able to restart a search or review suitable releases. Choose Best pick when offered if youre unsure.</p></li>
</article> </ol>
</div> <p>Remote activity explains the latest check. Open it to see the full list. A successful search doesnt always mean a download was found.</p>
</section> </details>
<details><summary>Report a problem and follow the fix</summary>
<section className="how-flow"> <ol>
<h2>The request pipeline</h2> <li><strong>Open <a href="/portal/issues">03 Issues</a>.</strong><p>Choose whats wrong: missing content, broken picture, wrong download, audio, subtitles, or playback.</p></li>
<ol className="how-steps"> <li><strong>Choose the affected content.</strong><p>Find the movie or show. For TV, select the affected seasons or episodes; you can choose more than one.</p></li>
<li> <li><strong>Read What will happen, then submit.</strong><p>It tells you whether the selected files will be replaced, missing content searched for, subtitles checked, or playback investigated.</p></li>
<strong>You request a movie or show</strong> through Seerr. <li><strong>Follow the issues progress.</strong><p>Open your reported issue to see the work recorded and where the fix is up to.</p></li>
</li> <li><strong>Tell us if it worked.</strong><p>When a supported repair is detected as ready to check, Magent can email you. Try the content, then choose Yes if its fixed or No if you still need help.</p></li>
<li> </ol>
<strong>Magent picks up the request</strong> and shows its current state. <p>Add your email in <a href="/profile">My profile</a> so updates can reach you. Reminder and automatic closure timings depend on the sites settings.</p>
</li> </details>
<li> <details><summary>Invite someone</summary>
<strong>The automation stack searches and downloads it</strong> if it can find a valid <ol>
release. <li><strong>Open <a href="/profile/invites">04 Invites</a>.</strong><p>If invites are enabled for your account, give your invite a name youll recognise.</p></li>
</li> <li><strong>Add a welcome note, or skip it.</strong><p>A custom invite code is optional too.</p></li>
<li> <li><strong>Choose how to share it.</strong><p>Copy the link yourself, or enter an email address to send it directly.</p></li>
<strong>The file is imported into the library</strong>. <li><strong>Manage it later.</strong><p>You can return to your invites to check them or disable a link. Your accounts invite limits apply automatically.</p></li>
</li> </ol>
<li> </details>
<strong>Jellyfin serves it</strong> once it is ready to watch. <details><summary>Update your account</summary><p>Open the account menu and choose <a href="/profile">My profile</a> to update your contact email, view your activity, or use the password options available for your account.</p><p>Looking for your downloads instead? <a href="/">01 My Requests</a> is your starting point.</p></details>
</li> <footer>Ready? <a href="/welcome">Choose where to go next </a></footer>
</ol> </main>
</section>
<section className="how-flow">
<h2>What the statuses usually mean</h2>
<div className="how-grid">
<article className="how-card">
<h3>Pending</h3>
<p>The request exists, but it is still waiting for approval or the next step.</p>
</article>
<article className="how-card">
<h3>Approved / Processing</h3>
<p>The request has been accepted and the automation tools are working on it.</p>
</article>
<article className="how-card">
<h3>Downloading</h3>
<p>Magent can show live progress while the content is still being downloaded.</p>
</article>
<article className="how-card">
<h3>Ready</h3>
<p>The item has been imported and should now be available in Jellyfin.</p>
</article>
<article className="how-card">
<h3>Partial / Waiting</h3>
<p>
Part of the workflow completed, but the request is still waiting on another service or
on content becoming available.
</p>
</article>
<article className="how-card">
<h3>Declined</h3>
<p>The request was rejected or cannot proceed in its current form.</p>
</article>
</div>
</section>
<section className="how-flow">
<h2>Live updates you can expect</h2>
<div className="how-step-grid">
<article className="how-step-card step-seerr">
<div className="step-badge">1</div>
<h3>Recent requests refresh automatically</h3>
<p className="step-note">
Your request list and landing-page activity update automatically while you are signed
in.
</p>
</article>
<article className="how-step-card step-qbit">
<div className="step-badge">2</div>
<h3>Request pages update in real time</h3>
<p className="step-note">
State changes, timeline steps, and downloader progress are pushed to the page live.
</p>
</article>
<article className="how-step-card step-jellyfin">
<div className="step-badge">3</div>
<h3>Ready state appears as soon as the import completes</h3>
<p className="step-note">
Once the content is actually available, Magent updates the request page without a hard
refresh.
</p>
</article>
</div>
</section>
<section className="how-flow">
<h2>User actions you may see</h2>
<div className="how-grid">
<article className="how-card">
<h3>Open request</h3>
<p>Jump into the full request page to inspect the current state and activity.</p>
</article>
<article className="how-card">
<h3>Open in Jellyfin</h3>
<p>Appears when the request is ready and Magent can link you through for playback.</p>
</article>
<article className="how-card">
<h3>Search + auto-download</h3>
<p>
Only appears for accounts that have been granted self-service download access by the
admin team.
</p>
</article>
<article className="how-card">
<h3>My invites</h3>
<p>
If your account is allowed to invite others, you can create and manage invite links
from your profile.
</p>
</article>
</div>
</section>
<section className="how-flow">
<h2>Invites and signup</h2>
<ol className="how-steps">
<li>
<strong>You receive an invite link</strong> by email or directly from the person who
invited you.
</li>
<li>
<strong>You sign up through Magent</strong> and your account is linked into the media
stack.
</li>
<li>
<strong>Your account defaults apply</strong> based on the invite or your assigned
profile.
</li>
<li>
<strong>You sign in and track requests</strong> from the landing page and your request
pages.
</li>
</ol>
</section>
<section className="how-callout">
<h2>If a request looks stuck</h2>
<p>
A waiting request usually means no usable release has been found yet, the download is
still in progress, or the import has not completed. Magent will keep updating as the
underlying services move forward.
</p>
</section>
</main>
)
} }
+1 -1
View File
@@ -71,7 +71,7 @@ export default function LoginPage() {
if (!data?.authenticated) { setError('Could not sign in. Please try again.'); return } if (!data?.authenticated) { setError('Could not sign in. Please try again.'); return }
setToken('cookie') setToken('cookie')
const next = new URLSearchParams(window.location.search).get('next') || '' const next = new URLSearchParams(window.location.search).get('next') || ''
window.location.assign(/^\/issues\/confirm\/\d+$/.test(next) ? next : '/') window.location.assign(/^\/issues\/confirm\/\d+$/.test(next) ? next : '/welcome')
} catch { } catch {
setError('Could not reach Magent. Check your connection and try again.') setError('Could not reach Magent. Check your connection and try again.')
} finally { setLoading(false) } } finally { setLoading(false) }
+1 -1
View File
@@ -10,7 +10,7 @@ import WorkspaceNavigation from './WorkspaceNavigation'
export default function ApplicationChrome() { export default function ApplicationChrome() {
const pathname = usePathname() const pathname = usePathname()
if (['/coming-soon', '/login', '/forgot-password', '/reset-password', '/signup'].includes(pathname)) return null if (['/welcome', '/coming-soon', '/login', '/forgot-password', '/reset-password', '/signup'].includes(pathname)) return null
return <> return <>
<header className="header"> <header className="header">
<div className="header-left"><a className="brand-link" href="/"><BrandingLogo className="brand-logo brand-logo--header" /><div className="brand-stack"><div className="brand">Magent</div><div className="tagline">GrizzlyFlix media operations</div></div></a></div> <div className="header-left"><a className="brand-link" href="/"><BrandingLogo className="brand-logo brand-logo--header" /><div className="brand-stack"><div className="brand">Magent</div><div className="tagline">GrizzlyFlix media operations</div></div></a></div>
+2
View File
@@ -93,6 +93,8 @@ export default function HeaderIdentity() {
{viewAsUser ? <span>Previewing user view</span> : null} {viewAsUser ? <span>Previewing user view</span> : null}
</div> </div>
<div className="signed-in-actions"> <div className="signed-in-actions">
<a href="/welcome" onClick={() => setOpen(false)}>Welcome page</a>
<a href="/how-it-works" onClick={() => setOpen(false)}>How it works</a>
<a href="/profile" onClick={() => setOpen(false)}> <a href="/profile" onClick={() => setOpen(false)}>
My profile My profile
</a> </a>
+21
View File
@@ -0,0 +1,21 @@
.welcome-page, .friendly-guide { width: min(100%, 1040px); margin: 40px auto; color: var(--ops-text); }
.welcome-page > header { text-align: center; margin-bottom: 32px; }
.welcome-kicker { color: var(--ops-cyan); font-size: 12px; letter-spacing: .12em; text-transform: uppercase; }
.welcome-page h1 { font-size: clamp(30px, 5vw, 48px); line-height: 1.15; margin: 16px 0; }
.welcome-page p, .friendly-guide p { color: var(--ops-muted); line-height: 1.65; }
.welcome-choices { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }
.welcome-choice { display: flex; flex-direction: column; align-items: flex-start; padding: 32px; gap: 16px; background: var(--ops-panel); border: 1px solid var(--ops-line); border-radius: 16px; text-decoration: none; color: inherit; }
.welcome-choice h2, .welcome-choice p { margin: 0; }
.welcome-choice strong { color: var(--ops-primary-2); margin-top: auto; padding-top: 16px; }
a.welcome-choice:hover { border-color: var(--ops-cyan); background: var(--ops-panel-2); }
.welcome-icon { color: var(--ops-cyan); font-size: 36px; line-height: 1; }
.welcome-page footer { text-align: center; margin-top: 28px; color: var(--ops-muted); }
.welcome-page a:focus-visible, .friendly-guide a:focus-visible, .friendly-guide summary:focus-visible { outline: 3px solid var(--ops-cyan); outline-offset: 5px; }
.friendly-guide > nav { display: flex; gap: 20px; flex-wrap: wrap; margin-bottom: 24px; }
.friendly-guide details { border: 1px solid var(--ops-line); border-radius: 12px; background: var(--ops-panel); margin: 12px 0; padding: 20px 24px; }
.friendly-guide summary { font-weight: 700; font-size: 19px; cursor: pointer; }
.friendly-guide ol { padding-left: 24px; }
.friendly-guide li { padding: 8px 0 8px 8px; line-height: 1.6; }
.friendly-guide li p { margin: 4px 0; }
.friendly-guide > footer { padding: 20px 0; }
@media (max-width: 640px) { .welcome-choices { grid-template-columns: 1fr; } .welcome-page, .friendly-guide { margin: 24px auto; } .welcome-choice { padding: 24px; } }
+36
View File
@@ -0,0 +1,36 @@
'use client'
import { useEffect, useState } from 'react'
import { authFetch, clearToken, getApiBase } from '../lib/auth'
import '../welcome.css'
export default function WelcomePage() {
const [ready, setReady] = useState(false)
const [url, setUrl] = useState<string | null>(null)
const [error, setError] = useState('')
useEffect(() => {
const controller = new AbortController()
void (async () => {
try {
const response = await authFetch(`${getApiBase()}/site/info`, { signal: controller.signal })
if (response.status === 401) { clearToken(); window.location.replace('/login'); return }
if (!response.ok) throw new Error('Unavailable')
const data = await response.json()
const candidate = data.mediaServerUrl ? new URL(data.mediaServerUrl) : null
if (candidate && ['https:', 'http:'].includes(candidate.protocol) && !candidate.username && !candidate.password) setUrl(candidate.href)
setReady(true)
} catch {
if (!controller.signal.aborted) setError('We couldnt load your welcome page. Please try again.')
}
})()
return () => controller.abort()
}, [])
return <main className="welcome-page">
<header><span className="welcome-kicker">GrizzlyFlix + Magent</span><h1>Make yourself at home.</h1><p>Something to watch, or something to sort out?</p></header>
{error ? <div role="alert"><p>{error}</p><button onClick={() => window.location.reload()}>Try again</button> <a href="/login">Back to sign in</a></div> : !ready ? <p role="status">Getting things ready</p> : <div className="welcome-choices">
{url ? <a className="welcome-choice" href={url}><span className="welcome-icon" aria-hidden="true"></span><h2>Go to GrizzlyFlix</h2><p>Find your next favourite. Watch movies and TV shows.</p><strong>Lets watch <span aria-hidden="true"></span></strong></a> : <section className="welcome-choice"><span className="welcome-icon" aria-hidden="true"></span><h2>Go to GrizzlyFlix</h2><p>The watch link hasnt been set up yet. Please ask an admin to add the public playback URL.</p></section>}
<a className="welcome-choice" href="/"><span className="welcome-icon" aria-hidden="true"></span><h2>Manage your account</h2><p>Track requests, report a problem, or update your profile.</p><strong>Open 01 My Requests <span aria-hidden="true"></span></strong></a>
</div>}
<footer>First time here? <a href="/how-it-works">Heres how it works</a>.</footer>
</main>
}
+34
View File
@@ -0,0 +1,34 @@
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT);
const assert = require('node:assert/strict');
(async () => {
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
let mode = 'ready';
await page.route('**/api/**', route => {
if (route.request().url().endsWith('/auth/me')) return route.fulfill({ json: { username: 'Tester', role: 'user' } });
if (route.request().url().endsWith('/site/info')) return route.fulfill({ status: mode === 'unauthorized' ? 401 : 200, json: { mediaServerUrl: mode === 'missing' ? null : 'https://watch.example.com/' } });
return route.fulfill({ json: {} });
});
for (const width of [1440, 390]) {
await page.setViewportSize({ width, height: 900 });
await page.goto('http://127.0.0.1:3103/welcome');
await page.getByRole('link', { name: /Go to GrizzlyFlix/ }).waitFor();
assert.equal(await page.getByRole('link', { name: /Go to GrizzlyFlix/ }).getAttribute('href'), 'https://watch.example.com/');
assert.equal(await page.getByRole('link', { name: /Manage your account/ }).getAttribute('href'), '/');
assert.equal(await page.locator('.header').count(), 0);
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
await page.goto('http://127.0.0.1:3103/how-it-works');
await page.getByText('Understand the six progress steps').click();
await page.getByText('Your request has been received.').waitFor();
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
}
mode = 'missing';
await page.goto('http://127.0.0.1:3103/welcome');
await page.getByText(/watch link hasnt been set up/).waitFor();
mode = 'unauthorized';
await page.goto('http://127.0.0.1:3103/welcome');
await page.waitForURL('**/login');
console.log('Welcome and guide checks passed: desktop, mobile, links, disclosure, missing URL, auth redirect.');
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exit(1); });