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'])
+49 -174
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>
Search by title, year, or request number to open the request page and see where an
item is up to.
</p>
</article>
<article className="how-card">
<h3>See live progress</h3>
<p>
Request status, timeline events, and download progress update live while you are
viewing the page.
</p>
</article>
<article className="how-card">
<h3>Know when it is ready</h3>
<p>
When the request is fully imported and available, Magent shows it as ready and links
you through to Jellyfin.
</p>
</article>
</div>
</section>
<section className="how-flow">
<h2>The request pipeline</h2>
<ol className="how-steps">
<li>
<strong>You request a movie or show</strong> through Seerr.
</li>
<li>
<strong>Magent picks up the request</strong> and shows its current state.
</li>
<li>
<strong>The automation stack searches and downloads it</strong> if it can find a valid
release.
</li>
<li>
<strong>The file is imported into the library</strong>.
</li>
<li>
<strong>Jellyfin serves it</strong> once it is ready to watch.
</li>
</ol> </ol>
</section> </details>
<details><summary>Understand the six progress steps</summary>
<section className="how-flow"> <ol>
<h2>What the statuses usually mean</h2> <li><strong>Requested:</strong> Your request has been received.</li>
<div className="how-grid"> <li><strong>Approved:</strong> It has permission to go ahead.</li>
<article className="how-card"> <li><strong>Library collection:</strong> The library is tracking whats collected and whats missing.</li>
<h3>Pending</h3> <li><strong>Release search:</strong> A suitable download is being looked for. Waiting here can mean there isnt a good match yet.</li>
<p>The request exists, but it is still waiting for approval or the next step.</p> <li><strong>Download:</strong> The files are being downloaded. TV requests can include several episodes or a season pack.</li>
</article> <li><strong>Available to watch:</strong> GrizzlyFlix has added the content. Use the watch button to open it.</li>
<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> </ol>
</section> <p>A finished download still needs to be added to the media library. Wait for Available to watch before heading over.</p>
</details>
<section className="how-callout"> <details><summary>Something looks stuck</summary>
<h2>If a request looks stuck</h2> <ol>
<p> <li><strong>Open the request.</strong><p>Read its current status and next step.</p></li>
A waiting request usually means no usable release has been found yet, the download is <li><strong>Choose Recheck request.</strong><p>Magent checks the connected services again to refresh where things are up to.</p></li>
still in progress, or the import has not completed. Magent will keep updating as the <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>
underlying services move forward. </ol>
</p> <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>
<ol>
<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>
<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><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>
<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><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>
</ol>
<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>
</details>
<details><summary>Invite someone</summary>
<ol>
<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><strong>Add a welcome note, or skip it.</strong><p>A custom invite code is optional too.</p></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>
<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>
</ol>
</details>
<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>
<footer>Ready? <a href="/welcome">Choose where to go next </a></footer>
</main> </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); });