Simplify navigation and modernize profile and sign-in
This commit is contained in:
@@ -0,0 +1,110 @@
|
|||||||
|
/* Top-navigation workspace and streamlined account screens. */
|
||||||
|
.page > main:not(.auth-screen):not(.auth-card):not(.login-page) { width: calc(100% - 64px); max-width: 1440px; margin: 32px auto 0; padding: 0; border: 0 !important; background: transparent !important; }
|
||||||
|
.page > .site-banner, .page > .user-view-banner { width: calc(100% - 64px); max-width: 1440px; margin: 16px auto 0; }
|
||||||
|
.admin-shell.admin-shell--top-nav { display: block; width: calc(100% - 64px); max-width: 1440px; margin: 24px auto 0; }
|
||||||
|
.admin-shell--top-nav > .admin-card { width: 100%; max-width: none; padding: 24px 0; }
|
||||||
|
.settings-top-navigation { display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 0 0 20px; border-bottom: 1px solid var(--ops-line-soft); }
|
||||||
|
.settings-top-navigation a { color: var(--ops-muted); font-size: 13px; text-decoration: none; }
|
||||||
|
.settings-top-navigation label { display: flex; align-items: center; gap: 12px; margin: 0; padding: 0; }
|
||||||
|
.settings-top-navigation label span { color: var(--ops-faint); font: 12px Inter, sans-serif; text-transform: none; }
|
||||||
|
.settings-top-navigation select { width: 260px; min-height: 42px; font: 13px Inter, sans-serif; padding: 10px 12px; border-radius: 8px; }
|
||||||
|
.admin-supplemental { margin-top: 28px; border-top: 1px solid var(--ops-line-soft); padding-top: 20px; }
|
||||||
|
.admin-supplemental > summary { cursor: pointer; color: var(--ops-muted); font-size: 13px; margin-bottom: 18px; }
|
||||||
|
.admin-supplemental .admin-rail-stack { display: block; max-width: 960px; }
|
||||||
|
.page > main.account-page { max-width: 920px !important; margin-top: 42px; }
|
||||||
|
.account-heading { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-bottom: 32px; }
|
||||||
|
.account-eyebrow { font: 10px "JetBrains Mono", monospace; color: var(--ops-faint); }
|
||||||
|
.account-heading h1 { font-size: clamp(30px, 4vw, 40px); line-height: 1.2; margin: 7px 0 0; color: var(--ops-text); }
|
||||||
|
.account-identity { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||||
|
.account-identity > div { display: grid; gap: 4px; min-width: 0; }
|
||||||
|
.account-identity strong { font-size: 14px; overflow-wrap: anywhere; }
|
||||||
|
.account-identity > div > span { font-size: 12px; color: var(--ops-faint); }
|
||||||
|
.account-avatar { display: grid; place-items: center; flex-shrink: 0; width: 44px; height: 44px; border: 1px solid #45434f; border-radius: 14px; background: #25242c; color: #dedaff; font: 600 20px "DM Sans", sans-serif; }
|
||||||
|
.account-tabs { display: flex; gap: 26px; border-bottom: 1px solid var(--ops-line-soft); margin-bottom: 24px; }
|
||||||
|
.account-tabs button { min-height: 46px; padding: 0 2px; border: 0; border-radius: 0 !important; border-bottom: 2px solid transparent; border-color: transparent !important; background: transparent !important; color: var(--ops-muted); font: 500 14px "DM Sans", sans-serif; box-shadow: none; text-transform: none; }
|
||||||
|
.account-tabs button[aria-selected=true] { color: #dedaff; border-bottom-color: #bcb3ff !important; }
|
||||||
|
.account-page [hidden] { display: none !important; }
|
||||||
|
.account-panel { border: 1px solid var(--ops-line); border-radius: 14px; background: var(--ops-panel); padding: 32px; animation: account-appear .18s ease-out; }
|
||||||
|
.account-section-intro { margin-bottom: 26px; }
|
||||||
|
.account-section-intro h2 { margin: 0 0 8px; font-size: 21px; }
|
||||||
|
.account-section-intro p { margin: 0; font-size: 13px; color: var(--ops-muted); line-height: 1.6; }
|
||||||
|
.account-form { display: grid; gap: 10px; max-width: 500px; }
|
||||||
|
.account-form fieldset { display: grid; gap: 10px; min-width: 0; padding: 0; margin: 0; border: 0; }
|
||||||
|
.account-form label { display: block; padding: 0; margin: 0; border: 0; background: none; color: var(--ops-text); font: 500 13px "DM Sans", sans-serif; text-transform: none; }
|
||||||
|
.account-form input { display: block; width: 100%; min-width: 0; min-height: 46px; padding: 11px 13px; margin: 0; border: 1px solid var(--ops-line); border-radius: 8px; font: 14px Inter, sans-serif; }
|
||||||
|
.account-form fieldset label:not(:first-child) { margin-top: 10px; }
|
||||||
|
.account-form .account-hint { margin: 0; color: var(--ops-faint); font-size: 12px; line-height: 1.6; }
|
||||||
|
.account-form-actions { display: flex; gap: 10px; margin-top: 14px; }
|
||||||
|
button.account-primary { min-height: 44px; padding: 11px 20px; border: 1px solid #c7bdff !important; border-radius: 8px; background: #c7bdff !important; color: #1c172c !important; font: 700 13px "DM Sans", sans-serif; text-transform: none; transition: background .15s, opacity .15s; }
|
||||||
|
button.account-primary:hover:not(:disabled) { background: #d8d1ff !important; }
|
||||||
|
button.account-primary:disabled { opacity: .4; cursor: not-allowed; }
|
||||||
|
button.account-secondary { min-height: 44px; padding: 11px 16px; border: 1px solid var(--ops-line); background: transparent !important; color: var(--ops-muted); font: 500 13px "DM Sans", sans-serif; text-transform: none; }
|
||||||
|
.account-notice { margin: 8px 0 0; padding: 12px 14px; border: 1px solid var(--ops-line); border-radius: 8px; font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||||
|
.account-notice.is-error { color: #ffb6b6; border-color: #763d44; background: #311e23; }
|
||||||
|
.account-notice.is-status { color: #aae0cb; border-color: #365c50; background: #1b2924; }
|
||||||
|
.account-connected { display: flex; align-items: center; gap: 9px; border-top: 1px solid var(--ops-line-soft); margin-top: 32px; padding-top: 20px; color: var(--ops-faint); font-size: 12px; }
|
||||||
|
.account-connection-dot { width: 6px; height: 6px; background: #83bda7; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.account-request-summary { display: flex; gap: 40px; align-items: center; padding-bottom: 24px; margin-bottom: 28px; border-bottom: 1px solid var(--ops-line-soft); }
|
||||||
|
.account-request-summary > div { display: grid; gap: 5px; }
|
||||||
|
.account-request-summary strong { font: 600 25px "DM Sans", sans-serif; }
|
||||||
|
.account-request-summary span { color: var(--ops-muted); font-size: 12px; }
|
||||||
|
.account-request-summary a { margin-left: auto; font-size: 13px; color: #d0c8ff; text-decoration: none; }
|
||||||
|
.account-list-heading { font-size: 14px; margin: 0 0 8px; }
|
||||||
|
.account-access-list { list-style: none; padding: 0; margin: 0; }
|
||||||
|
.account-access-list > li { padding: 18px 0; border-bottom: 1px solid var(--ops-line-soft); }
|
||||||
|
.account-access-list > li:last-child { border: 0; }
|
||||||
|
.account-access-summary { display: flex; justify-content: space-between; gap: 16px; }
|
||||||
|
.account-access-summary strong { font-size: 13px; font-weight: 500; }
|
||||||
|
.account-access-summary time { font-size: 12px; color: var(--ops-muted); text-align: right; }
|
||||||
|
.account-access-list details { margin-top: 8px; font-size: 12px; color: var(--ops-faint); }
|
||||||
|
.account-access-list summary { cursor: pointer; }
|
||||||
|
.account-access-list dl { display: grid; gap: 8px; margin-bottom: 0; }
|
||||||
|
.account-access-list dl > div { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.account-access-list dd { margin: 0; color: var(--ops-muted); overflow-wrap: anywhere; }
|
||||||
|
.account-empty { color: var(--ops-muted); font-size: 14px; line-height: 1.6; padding: 16px 0; }
|
||||||
|
.account-empty button { margin-top: 8px; }
|
||||||
|
.page:has(> .login-page) { padding: 0; background: #121214; }
|
||||||
|
.page > main.login-page { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100dvh; width: 100%; max-width: none; margin: 0; padding: 40px 20px; background: radial-gradient(ellipse at 50% 15%, #24202e 0, transparent 60%); }
|
||||||
|
.login-card { width: 100%; max-width: 430px; border: 1px solid #3a3841; border-radius: 20px; padding: 32px; background: #1c1b1f; box-shadow: 0 22px 90px #0003; }
|
||||||
|
.login-brand { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 36px; }
|
||||||
|
.login-brand > a { display: flex; align-items: center; gap: 10px; color: #eeeaf5; text-decoration: none; font: 600 23px "DM Sans", sans-serif; }
|
||||||
|
.magent-mark { width: 38px; height: 38px; }
|
||||||
|
.login-beta { padding: 4px 8px; border: 1px solid #44404d; border-radius: 6px; font: 10px "JetBrains Mono", monospace; color: #bdb5cf; text-transform: uppercase; }
|
||||||
|
.login-card header { margin-bottom: 24px; }
|
||||||
|
.login-card h1 { margin: 0 0 8px; font-size: 30px; line-height: 1.2; color: #f1edf6; }
|
||||||
|
.login-card header p { margin: 0; color: #aba5b7; font-size: 13px; }
|
||||||
|
.login-methods { display: flex; gap: 4px; padding: 4px; margin: 0 0 16px; background: #151417; border: 1px solid #3b3842; border-radius: 9px; }
|
||||||
|
.login-methods button { flex: 1; min-height: 36px; padding: 8px; border: 0; border-radius: 6px !important; background: transparent !important; color: #b3adbf; font: 500 13px "DM Sans", sans-serif; text-transform: none; }
|
||||||
|
.login-methods button[aria-pressed=true] { background: #36313f !important; color: #eee7ff; }
|
||||||
|
.login-method-help { font-size: 12px; line-height: 1.5; margin: 0 0 10px; color: #aba5b7; }
|
||||||
|
.login-form input { background: #151417 !important; border-color: #46414e !important; }
|
||||||
|
.login-password-label { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 12px; }
|
||||||
|
.login-password-label a { color: #c4b9e5; font-size: 12px; text-decoration: none; }
|
||||||
|
.login-password-field { position: relative; }
|
||||||
|
.login-password-field input { padding-right: 48px; }
|
||||||
|
.login-password-field .password-visibility { position: absolute; top: 1px; right: 1px; width: 44px; height: calc(100% - 2px); min-height: 42px; padding: 12px; border: 0; background: transparent !important; color: #aba5b7; box-shadow: none; }
|
||||||
|
.password-visibility svg { width: 19px; height: 19px; display: block; }
|
||||||
|
.login-form .login-submit { display: flex; justify-content: space-between; align-items: center; margin-top: 14px; min-height: 46px; }
|
||||||
|
.login-card footer { border-top: 1px solid #36323c; margin-top: 28px; padding-top: 22px; text-align: center; color: #aaa3b5; font-size: 12px; }
|
||||||
|
.login-card footer a { margin-left: 4px; color: #d4c7ff; text-decoration: none; font-weight: 500; }
|
||||||
|
.login-credit { color: #87818f; font-size: 11px; margin: 24px 0 0; }
|
||||||
|
.account-page :focus-visible, .login-page :focus-visible, .settings-top-navigation :focus-visible { outline: 2px solid #c7bdff; outline-offset: 3px; }
|
||||||
|
@keyframes account-appear { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .account-panel { animation: none; } }
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.page > main:not(.auth-screen):not(.auth-card):not(.login-page), .page > .site-banner, .page > .user-view-banner, .admin-shell.admin-shell--top-nav { width: calc(100% - 32px); }
|
||||||
|
.page > main.account-page { margin-top: 28px; }
|
||||||
|
.account-heading { align-items: flex-start; gap: 16px; margin-bottom: 24px; }
|
||||||
|
.account-identity strong { max-width: 130px; }
|
||||||
|
.account-avatar { display: none; }
|
||||||
|
.account-panel { padding: 22px 20px; }
|
||||||
|
.account-request-summary { gap: 24px; flex-wrap: wrap; }
|
||||||
|
.account-request-summary a { width: 100%; margin: 0; }
|
||||||
|
.account-access-summary { flex-direction: column; gap: 6px; }
|
||||||
|
.account-access-summary time { text-align: left; }
|
||||||
|
.settings-top-navigation { gap: 14px; flex-wrap: wrap; }
|
||||||
|
.settings-top-navigation label { flex: 1; min-width: 220px; }
|
||||||
|
.settings-top-navigation select { flex: 1; width: 100%; }
|
||||||
|
.login-card { padding: 26px 24px; }
|
||||||
|
.page > main.login-page { padding: 24px 16px; }
|
||||||
|
}
|
||||||
@@ -1750,11 +1750,6 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
<AdminShell
|
<AdminShell
|
||||||
title={SECTION_LABELS[section] ?? 'Settings'}
|
title={SECTION_LABELS[section] ?? 'Settings'}
|
||||||
subtitle={SECTION_DESCRIPTIONS[section] ?? 'Manage settings.'}
|
subtitle={SECTION_DESCRIPTIONS[section] ?? 'Manage settings.'}
|
||||||
actions={
|
|
||||||
<button type="button" onClick={() => router.push('/admin')}>
|
|
||||||
Back to settings
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{status && <div className="error-banner">{status}</div>}
|
{status && <div className="error-banner">{status}</div>}
|
||||||
{currentServiceStatus && (
|
{currentServiceStatus && (
|
||||||
|
|||||||
+3
-27
@@ -1,14 +1,10 @@
|
|||||||
import './globals.css'
|
import './globals.css'
|
||||||
import './ops-redesign.css'
|
import './ops-redesign.css'
|
||||||
import './admin/config.css'
|
import './admin/config.css'
|
||||||
|
import './account.css'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import BrandingFavicon from './ui/BrandingFavicon'
|
import BrandingFavicon from './ui/BrandingFavicon'
|
||||||
import BrandingLogo from './ui/BrandingLogo'
|
import ApplicationChrome from './ui/ApplicationChrome'
|
||||||
import HeaderActions from './ui/HeaderActions'
|
|
||||||
import HeaderIdentity from './ui/HeaderIdentity'
|
|
||||||
import SiteStatus from './ui/SiteStatus'
|
|
||||||
import UserViewBanner from './ui/UserViewBanner'
|
|
||||||
import WorkspaceNavigation from './ui/WorkspaceNavigation'
|
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Magent',
|
title: 'Magent',
|
||||||
@@ -21,27 +17,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||||||
<body>
|
<body>
|
||||||
<BrandingFavicon />
|
<BrandingFavicon />
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<header className="header">
|
<ApplicationChrome />
|
||||||
<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-right">
|
|
||||||
<span className="beta-chip" title="Beta environment">Beta</span>
|
|
||||||
<HeaderIdentity />
|
|
||||||
</div>
|
|
||||||
<div className="header-nav">
|
|
||||||
<HeaderActions />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<WorkspaceNavigation />
|
|
||||||
<UserViewBanner />
|
|
||||||
<SiteStatus />
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+81
-161
@@ -1,190 +1,110 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation'
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
import { useEffect, useState } from 'react'
|
import { getApiBase, setToken } from '../lib/auth'
|
||||||
import { getApiBase, setToken, clearToken } from '../lib/auth'
|
import MagentMark from '../ui/MagentMark'
|
||||||
import BrandingLogo from '../ui/BrandingLogo'
|
|
||||||
|
|
||||||
const DEFAULT_LOGIN_OPTIONS = {
|
type LoginMode = 'jellyfin' | 'local'
|
||||||
showJellyfinLogin: true,
|
type LoginOptions = { showJellyfinLogin: boolean; showLocalLogin: boolean; showForgotPassword: boolean; showSignupLink: boolean }
|
||||||
showLocalLogin: true,
|
const DEFAULT_OPTIONS: LoginOptions = { showJellyfinLogin: true, showLocalLogin: true, showForgotPassword: true, showSignupLink: true }
|
||||||
showForgotPassword: true,
|
|
||||||
showSignupLink: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter()
|
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [mode, setMode] = useState<LoginMode>('jellyfin')
|
||||||
|
const [options, setOptions] = useState<LoginOptions>(DEFAULT_OPTIONS)
|
||||||
|
const [optionsReady, setOptionsReady] = useState(false)
|
||||||
|
const [banner, setBanner] = useState<{ message: string; tone: string } | null>(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [loginOptions, setLoginOptions] = useState(DEFAULT_LOGIN_OPTIONS)
|
const canSignIn = options.showJellyfinLogin || options.showLocalLogin
|
||||||
const primaryMode: 'jellyfin' | 'local' | null = loginOptions.showJellyfinLogin
|
const selectedMode: LoginMode = mode === 'jellyfin' && options.showJellyfinLogin ? 'jellyfin' : options.showLocalLogin ? 'local' : 'jellyfin'
|
||||||
? 'jellyfin'
|
|
||||||
: loginOptions.showLocalLogin
|
|
||||||
? 'local'
|
|
||||||
: null
|
|
||||||
|
|
||||||
const submit = async (event: React.FormEvent, mode: 'local' | 'jellyfin') => {
|
useEffect(() => {
|
||||||
event.preventDefault()
|
const controller = new AbortController()
|
||||||
if (!primaryMode) {
|
const load = async () => {
|
||||||
setError('Login is currently disabled. Contact an administrator.')
|
try {
|
||||||
return
|
const response = await fetch(`${getApiBase()}/site/public`, { signal: controller.signal })
|
||||||
|
if (!response.ok) throw new Error('Options unavailable')
|
||||||
|
const data = await response.json()
|
||||||
|
if (controller.signal.aborted) return
|
||||||
|
setOptions({
|
||||||
|
showJellyfinLogin: data?.login?.showJellyfinLogin !== false,
|
||||||
|
showLocalLogin: data?.login?.showLocalLogin !== false,
|
||||||
|
showForgotPassword: data?.login?.showForgotPassword !== false,
|
||||||
|
showSignupLink: data?.login?.showSignupLink !== false,
|
||||||
|
})
|
||||||
|
if (data?.banner?.enabled && typeof data.banner.message === 'string' && data.banner.message.trim().toLowerCase() !== 'beta environment') {
|
||||||
|
setBanner({ message: data.banner.message, tone: data.banner.tone || 'info' })
|
||||||
}
|
}
|
||||||
setError(null)
|
} catch {
|
||||||
|
// Keep the normal sign-in methods available during a settings outage.
|
||||||
|
} finally {
|
||||||
|
if (!controller.signal.aborted) setOptionsReady(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load()
|
||||||
|
return () => controller.abort()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (loading || !canSignIn || !optionsReady) return
|
||||||
|
setError('')
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
clearToken()
|
const response = await fetch(`${getApiBase()}${selectedMode === 'jellyfin' ? '/auth/jellyfin/login' : '/auth/login'}`, {
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const endpoint = mode === 'jellyfin' ? '/auth/jellyfin/login' : '/auth/login'
|
|
||||||
const body = new URLSearchParams({ username, password })
|
|
||||||
const response = await fetch(`${baseUrl}${endpoint}`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body,
|
body: new URLSearchParams({ username: username.trim(), password }),
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Login failed')
|
setError(response.status === 429 ? 'Too many attempts. Please wait a moment and try again.'
|
||||||
|
: response.status >= 500 ? 'Sign-in is temporarily unavailable. Please try again shortly.'
|
||||||
|
: response.status === 403 ? 'This account cannot sign in. Please contact an administrator.'
|
||||||
|
: 'Check your username and password, then try again.')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
if (data?.authenticated) {
|
if (!data?.authenticated) { setError('Could not sign in. Please try again.'); return }
|
||||||
setToken('cookie')
|
setToken('cookie')
|
||||||
if (typeof window !== 'undefined') {
|
window.location.assign('/')
|
||||||
window.location.href = '/'
|
} catch {
|
||||||
return
|
setError('Could not reach Magent. Check your connection and try again.')
|
||||||
|
} finally { setLoading(false) }
|
||||||
}
|
}
|
||||||
router.push('/')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw new Error('Login failed')
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setError('Invalid username or password.')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true
|
|
||||||
const loadLoginOptions = async () => {
|
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await fetch(`${baseUrl}/site/public`)
|
|
||||||
if (!response.ok) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const data = await response.json()
|
|
||||||
const login = data?.login ?? {}
|
|
||||||
if (!active) return
|
|
||||||
setLoginOptions({
|
|
||||||
showJellyfinLogin: login.showJellyfinLogin !== false,
|
|
||||||
showLocalLogin: login.showLocalLogin !== false,
|
|
||||||
showForgotPassword: login.showForgotPassword !== false,
|
|
||||||
showSignupLink: login.showSignupLink !== false,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void loadLoginOptions()
|
|
||||||
return () => {
|
|
||||||
active = false
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loginHelpText = (() => {
|
|
||||||
if (loginOptions.showJellyfinLogin && loginOptions.showLocalLogin) {
|
|
||||||
return 'Use your Jellyfin account, or sign in with a local Magent admin account.'
|
|
||||||
}
|
|
||||||
if (loginOptions.showJellyfinLogin) {
|
|
||||||
return 'Use your Jellyfin account to sign in.'
|
|
||||||
}
|
|
||||||
if (loginOptions.showLocalLogin) {
|
|
||||||
return 'Use your local Magent admin account to sign in.'
|
|
||||||
}
|
|
||||||
return 'No sign-in methods are currently available. Contact an administrator.'
|
|
||||||
})()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="auth-screen">
|
<main className="login-page">
|
||||||
<section className="auth-hero">
|
<section className="login-card" aria-labelledby="login-title">
|
||||||
<div className="auth-mark">
|
<div className="login-brand"><a href="/login" aria-label="Magent sign in"><MagentMark /><span>Magent</span></a><span className="login-beta">Beta</span></div>
|
||||||
<BrandingLogo className="brand-logo brand-logo--login" />
|
<header><h1 id="login-title">Welcome back.</h1><p>Sign in to your media workspace.</p></header>
|
||||||
</div>
|
{banner && <p className={`account-notice ${['error', 'maintenance'].includes(banner.tone) ? 'is-error' : 'is-status'}`} role="status">{banner.message}</p>}
|
||||||
<div className="auth-title-block">
|
{optionsReady && options.showJellyfinLogin && options.showLocalLogin && <div className="login-methods" role="group" aria-label="Sign-in account">
|
||||||
<span className="section-kicker">Secure access</span>
|
<button type="button" aria-pressed={selectedMode === 'jellyfin'} disabled={loading} onClick={() => { setMode('jellyfin'); setError('') }}>Grizzlyflix</button>
|
||||||
<h1>Magent operational gateway</h1>
|
<button type="button" aria-pressed={selectedMode === 'local'} disabled={loading} onClick={() => { setMode('local'); setError('') }}>Magent</button>
|
||||||
<p>{loginHelpText}</p>
|
</div>}
|
||||||
</div>
|
{!optionsReady ? <p className="account-hint" role="status">Loading sign-in…</p> : !canSignIn ? <p className="account-notice is-error" role="alert">Sign-in is currently unavailable. Please contact an administrator.</p> : (
|
||||||
</section>
|
<form className="account-form login-form" onSubmit={submit}>
|
||||||
<form
|
<p className="login-method-help">{selectedMode === 'jellyfin' ? 'Use your Grizzlyflix / Jellyfin account.' : 'Use your Magent account.'}</p>
|
||||||
onSubmit={(event) => {
|
<label htmlFor="login-username">Username</label>
|
||||||
if (!primaryMode) {
|
<input id="login-username" name="username" value={username} onChange={(event) => setUsername(event.target.value)} autoComplete="username" autoCapitalize="none" spellCheck={false} required disabled={loading} />
|
||||||
event.preventDefault()
|
<div className="login-password-label"><label htmlFor="login-password">Password</label>{options.showForgotPassword && <a href="/forgot-password">Forgot password?</a>}</div>
|
||||||
setError('Login is currently disabled. Contact an administrator.')
|
<div className="login-password-field">
|
||||||
return
|
<input id="login-password" name="password" type={showPassword ? 'text' : 'password'} value={password} onChange={(event) => setPassword(event.target.value)} autoComplete="current-password" required disabled={loading} />
|
||||||
}
|
<button type="button" className="password-visibility" aria-label={showPassword ? 'Hide password' : 'Show password'} aria-pressed={showPassword} onClick={() => setShowPassword(!showPassword)}>
|
||||||
void submit(event, primaryMode)
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" aria-hidden="true"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" /><circle cx="12" cy="12" r="3" />{showPassword && <path d="m3 3 18 18" />}</svg>
|
||||||
}}
|
|
||||||
className="auth-form auth-panel"
|
|
||||||
>
|
|
||||||
<label>
|
|
||||||
<span>Username</span>
|
|
||||||
<input
|
|
||||||
value={username}
|
|
||||||
onChange={(event) => setUsername(event.target.value)}
|
|
||||||
autoComplete="username"
|
|
||||||
placeholder="Enter your username"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Password</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
|
||||||
autoComplete="current-password"
|
|
||||||
placeholder="Enter your password"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{error && <div className="error-banner">{error}</div>}
|
|
||||||
<div className="auth-actions">
|
|
||||||
{loginOptions.showJellyfinLogin ? (
|
|
||||||
<button type="submit" disabled={loading}>
|
|
||||||
{loading ? 'Signing in...' : 'Login with Jellyfin account'}
|
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{loginOptions.showLocalLogin ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ghost-button"
|
|
||||||
disabled={loading}
|
|
||||||
onClick={(event) => submit(event, 'local')}
|
|
||||||
>
|
|
||||||
Sign in with Magent account
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
{loginOptions.showForgotPassword ? (
|
|
||||||
<a className="ghost-button" href="/forgot-password">
|
|
||||||
Forgot password?
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
{loginOptions.showSignupLink ? (
|
|
||||||
<a className="ghost-button" href="/signup">
|
|
||||||
Have an invite? Create your account (Jellyfin + Magent)
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
{!loginOptions.showJellyfinLogin && !loginOptions.showLocalLogin ? (
|
|
||||||
<div className="error-banner">Login is currently disabled. Contact an administrator.</div>
|
|
||||||
) : null}
|
|
||||||
<div className="auth-footnote">
|
|
||||||
<span className="live-dot" aria-hidden="true" />
|
|
||||||
Beta environment
|
|
||||||
</div>
|
</div>
|
||||||
|
{error && <p className="account-notice is-error" role="alert">{error}</p>}
|
||||||
|
<button type="submit" className="account-primary login-submit" disabled={loading}>{loading ? 'Signing in…' : 'Sign in'}<span aria-hidden="true">→</span></button>
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
|
{optionsReady && options.showSignupLink && <footer>Have an invite? <a href="/signup">Create an account <span aria-hidden="true">↗</span></a></footer>}
|
||||||
|
</section>
|
||||||
|
<p className="login-credit">Grizzlyflix · Request. Watch. Enjoy.</p>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+169
-486
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||||
|
|
||||||
type ProfileInfo = {
|
type ProfileInfo = {
|
||||||
@@ -9,542 +9,225 @@ type ProfileInfo = {
|
|||||||
email?: string | null
|
email?: string | null
|
||||||
role: string
|
role: string
|
||||||
auth_provider: string
|
auth_provider: string
|
||||||
invite_management_enabled?: boolean
|
|
||||||
password_change_supported?: boolean
|
password_change_supported?: boolean
|
||||||
password_provider?: 'local' | 'jellyfin' | null
|
password_provider?: 'local' | 'jellyfin' | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProfileStats = {
|
|
||||||
total: number
|
|
||||||
ready: number
|
|
||||||
pending: number
|
|
||||||
in_progress: number
|
|
||||||
declined: number
|
|
||||||
working: number
|
|
||||||
partial: number
|
|
||||||
approved: number
|
|
||||||
last_request_at?: string | null
|
|
||||||
share: number
|
|
||||||
global_total: number
|
|
||||||
most_active_user?: { username: string; total: number } | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type ActivityEntry = {
|
type ActivityEntry = {
|
||||||
ip: string
|
ip: string
|
||||||
user_agent: string
|
user_agent: string
|
||||||
first_seen_at: string
|
first_seen_at: string
|
||||||
last_seen_at: string
|
last_seen_at: string
|
||||||
hit_count: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProfileActivity = {
|
|
||||||
last_ip?: string | null
|
|
||||||
last_user_agent?: string | null
|
|
||||||
last_seen_at?: string | null
|
|
||||||
device_count: number
|
|
||||||
recent: ActivityEntry[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProfileResponse = {
|
type ProfileResponse = {
|
||||||
user: ProfileInfo
|
user: ProfileInfo
|
||||||
stats: ProfileStats
|
stats?: { total: number; ready: number; in_progress: number }
|
||||||
activity: ProfileActivity
|
activity?: { recent: ActivityEntry[] }
|
||||||
}
|
}
|
||||||
|
type Notice = { tone: 'status' | 'error'; message: string } | null
|
||||||
type ProfileTab = 'overview' | 'activity' | 'security'
|
type ProfileTab = 'overview' | 'security' | 'activity'
|
||||||
|
const TABS: { key: ProfileTab; label: string }[] = [
|
||||||
const normalizeProfileTab = (value?: string | null): ProfileTab => {
|
{ key: 'overview', label: 'Account' },
|
||||||
if (value === 'activity' || value === 'security') {
|
{ key: 'security', label: 'Security' },
|
||||||
return value
|
{ key: 'activity', label: 'Activity' },
|
||||||
}
|
]
|
||||||
return 'overview'
|
const normalizeTab = (value: string | null): ProfileTab => value === 'security' || value === 'activity' ? value : 'overview'
|
||||||
}
|
const formatDate = (value?: string) => {
|
||||||
|
if (!value) return 'Not recorded'
|
||||||
const formatDate = (value?: string | null) => {
|
|
||||||
if (!value) return 'Never'
|
|
||||||
const date = new Date(value)
|
const date = new Date(value)
|
||||||
if (Number.isNaN(date.valueOf())) return value
|
return Number.isNaN(date.valueOf()) ? 'Not recorded' : date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
|
||||||
return date.toLocaleString()
|
|
||||||
}
|
}
|
||||||
|
const deviceName = (agent: string) => {
|
||||||
const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
|
const value = (agent || '').toLowerCase()
|
||||||
|
const browser = value.includes('edg/') ? 'Edge' : value.includes('firefox/') || value.includes('fxios/') ? 'Firefox'
|
||||||
const parseBrowser = (agent?: string | null) => {
|
: value.includes('chrome/') || value.includes('crios/') ? 'Chrome' : value.includes('safari/') ? 'Safari' : 'Browser'
|
||||||
if (!agent) return 'Unknown'
|
const device = /iphone|ipad/.test(value) ? 'iOS' : value.includes('android') ? 'Android'
|
||||||
const value = agent.toLowerCase()
|
: value.includes('windows') ? 'Windows' : value.includes('macintosh') ? 'Mac' : value.includes('linux') ? 'Linux' : ''
|
||||||
if (value.includes('edg/')) return 'Edge'
|
return device ? `${browser} on ${device}` : browser
|
||||||
if (value.includes('chrome/') && !value.includes('edg/')) return 'Chrome'
|
}
|
||||||
if (value.includes('firefox/')) return 'Firefox'
|
const responseMessage = async (response: Response, fallback: string) => {
|
||||||
if (value.includes('safari/') && !value.includes('chrome/')) return 'Safari'
|
const data = await response.json().catch(() => null)
|
||||||
return 'Unknown'
|
return typeof data?.detail === 'string' && data.detail.trim() ? data.detail : fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [profile, setProfile] = useState<ProfileInfo | null>(null)
|
const [data, setData] = useState<ProfileResponse | null>(null)
|
||||||
const [stats, setStats] = useState<ProfileStats | null>(null)
|
const [loading, setLoading] = useState(true)
|
||||||
const [activity, setActivity] = useState<ProfileActivity | null>(null)
|
const [loadError, setLoadError] = useState('')
|
||||||
|
const [activeTab, setActiveTab] = useState<ProfileTab>('overview')
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [emailSaving, setEmailSaving] = useState(false)
|
const [emailSaving, setEmailSaving] = useState(false)
|
||||||
const [emailStatus, setEmailStatus] = useState<{ tone: 'status' | 'error'; message: string } | null>(null)
|
const [emailNotice, setEmailNotice] = useState<Notice>(null)
|
||||||
const [currentPassword, setCurrentPassword] = useState('')
|
const [currentPassword, setCurrentPassword] = useState('')
|
||||||
const [newPassword, setNewPassword] = useState('')
|
const [newPassword, setNewPassword] = useState('')
|
||||||
const [confirmPassword, setConfirmPassword] = useState('')
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
const [status, setStatus] = useState<{ tone: 'status' | 'error'; message: string } | null>(null)
|
const [passwordSaving, setPasswordSaving] = useState(false)
|
||||||
const [activeTab, setActiveTab] = useState<ProfileTab>('overview')
|
const [passwordNotice, setPasswordNotice] = useState<Notice>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [showAllActivity, setShowAllActivity] = useState(false)
|
||||||
|
|
||||||
const inviteLink = useMemo(() => '/profile/invites', [])
|
const loadProfile = useCallback(async () => {
|
||||||
|
if (!getToken()) { router.replace('/login'); return }
|
||||||
useEffect(() => {
|
setLoading(true)
|
||||||
if (typeof window === 'undefined') return
|
setLoadError('')
|
||||||
const syncTabFromLocation = () => {
|
try {
|
||||||
const params = new URLSearchParams(window.location.search)
|
const response = await authFetch(`${getApiBase()}/auth/profile`)
|
||||||
setActiveTab(normalizeProfileTab(params.get('tab')))
|
if (response.status === 401) { clearToken(); router.replace('/login'); return }
|
||||||
|
if (!response.ok) throw new Error('Could not load your profile. Please try again.')
|
||||||
|
const profile = await response.json() as ProfileResponse
|
||||||
|
setData(profile)
|
||||||
|
setEmail(profile.user.email ?? '')
|
||||||
|
} catch {
|
||||||
|
setLoadError('Could not load your profile. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
}
|
}
|
||||||
syncTabFromLocation()
|
}, [router])
|
||||||
window.addEventListener('popstate', syncTabFromLocation)
|
|
||||||
return () => window.removeEventListener('popstate', syncTabFromLocation)
|
useEffect(() => { void loadProfile() }, [loadProfile])
|
||||||
|
useEffect(() => {
|
||||||
|
const syncTab = () => setActiveTab(normalizeTab(new URLSearchParams(window.location.search).get('tab')))
|
||||||
|
syncTab()
|
||||||
|
window.addEventListener('popstate', syncTab)
|
||||||
|
return () => window.removeEventListener('popstate', syncTab)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const selectTab = (tab: ProfileTab) => {
|
const selectTab = (tab: ProfileTab) => {
|
||||||
setActiveTab(tab)
|
setActiveTab(tab)
|
||||||
router.replace(tab === 'overview' ? '/profile' : `/profile?tab=${tab}`)
|
router.replace(tab === 'overview' ? '/profile' : `/profile?tab=${tab}`, { scroll: false })
|
||||||
}
|
}
|
||||||
|
const tabKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
||||||
useEffect(() => {
|
let next = index
|
||||||
if (!getToken()) {
|
if (event.key === 'ArrowRight') next = (index + 1) % TABS.length
|
||||||
router.push('/login')
|
else if (event.key === 'ArrowLeft') next = (index + TABS.length - 1) % TABS.length
|
||||||
return
|
else if (event.key === 'Home') next = 0
|
||||||
}
|
else if (event.key === 'End') next = TABS.length - 1
|
||||||
const load = async () => {
|
else return
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const profileResponse = await authFetch(`${baseUrl}/auth/profile`)
|
|
||||||
if (!profileResponse.ok) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const data = (await profileResponse.json()) as ProfileResponse
|
|
||||||
const user = data?.user ?? {}
|
|
||||||
setProfile({
|
|
||||||
username: user?.username ?? 'Unknown',
|
|
||||||
email: user?.email ?? null,
|
|
||||||
role: user?.role ?? 'user',
|
|
||||||
auth_provider: user?.auth_provider ?? 'local',
|
|
||||||
invite_management_enabled: Boolean(user?.invite_management_enabled ?? false),
|
|
||||||
password_change_supported: Boolean(user?.password_change_supported ?? false),
|
|
||||||
password_provider:
|
|
||||||
user?.password_provider === 'jellyfin' || user?.password_provider === 'local'
|
|
||||||
? user.password_provider
|
|
||||||
: null,
|
|
||||||
})
|
|
||||||
setEmail(user?.email ?? '')
|
|
||||||
setStats(data?.stats ?? null)
|
|
||||||
setActivity(data?.activity ?? null)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setStatus({ tone: 'error', message: 'Could not load your profile.' })
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void load()
|
|
||||||
}, [router])
|
|
||||||
|
|
||||||
const submit = async (event: React.FormEvent) => {
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setStatus(null)
|
selectTab(TABS[next].key)
|
||||||
if (!currentPassword || !newPassword) {
|
document.getElementById(`profile-tab-${TABS[next].key}`)?.focus()
|
||||||
setStatus({ tone: 'error', message: 'Enter your current password and a new password.' })
|
}
|
||||||
|
|
||||||
|
const saveEmail = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (emailSaving) return
|
||||||
|
setEmailSaving(true)
|
||||||
|
setEmailNotice(null)
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`${getApiBase()}/auth/profile/email`, {
|
||||||
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: email.trim() || null }),
|
||||||
|
})
|
||||||
|
if (response.status === 401) { clearToken(); router.replace('/login'); return }
|
||||||
|
if (!response.ok) throw new Error(await responseMessage(response, 'Could not save your email. Please try again.'))
|
||||||
|
const result = await response.json()
|
||||||
|
const saved = typeof result.email === 'string' ? result.email : ''
|
||||||
|
setData((current) => current ? { ...current, user: { ...current.user, email: saved || null } } : current)
|
||||||
|
setEmail(saved)
|
||||||
|
setEmailNotice({ tone: 'status', message: saved ? 'Email saved.' : 'Email removed.' })
|
||||||
|
} catch (error) {
|
||||||
|
setEmailNotice({ tone: 'error', message: error instanceof Error ? error.message : 'Could not save your email.' })
|
||||||
|
} finally { setEmailSaving(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const savePassword = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (passwordSaving) return
|
||||||
|
setPasswordNotice(null)
|
||||||
|
if (newPassword.trim().length < 8) {
|
||||||
|
setPasswordNotice({ tone: 'error', message: 'Use at least 8 characters for your new password.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (newPassword !== confirmPassword) {
|
if (newPassword !== confirmPassword) {
|
||||||
setStatus({ tone: 'error', message: 'New password and confirmation do not match.' })
|
setPasswordNotice({ tone: 'error', message: 'The new passwords do not match.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setPasswordSaving(true)
|
||||||
try {
|
try {
|
||||||
const baseUrl = getApiBase()
|
const response = await authFetch(`${getApiBase()}/auth/password`, {
|
||||||
const response = await authFetch(`${baseUrl}/auth/password`, {
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
method: 'POST',
|
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
current_password: currentPassword,
|
|
||||||
new_password: newPassword,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) throw new Error(await responseMessage(response, 'Could not change your password. Please try again.'))
|
||||||
let detail = 'Update failed'
|
const result = await response.json()
|
||||||
try {
|
setCurrentPassword(''); setNewPassword(''); setConfirmPassword('')
|
||||||
const payload = await response.json()
|
setPasswordNotice({ tone: 'status', message: result.provider === 'jellyfin'
|
||||||
if (typeof payload?.detail === 'string' && payload.detail.trim()) {
|
? 'Password updated for Grizzlyflix and Magent. Seerr uses the same password.'
|
||||||
detail = payload.detail
|
: 'Password updated.' })
|
||||||
}
|
} catch (error) {
|
||||||
} catch {
|
setPasswordNotice({ tone: 'error', message: error instanceof Error ? error.message : 'Could not change your password.' })
|
||||||
const text = await response.text().catch(() => '')
|
} finally { setPasswordSaving(false) }
|
||||||
if (text?.trim()) detail = text
|
|
||||||
}
|
|
||||||
throw new Error(detail)
|
|
||||||
}
|
|
||||||
const data = await response.json().catch(() => ({}))
|
|
||||||
setCurrentPassword('')
|
|
||||||
setNewPassword('')
|
|
||||||
setConfirmPassword('')
|
|
||||||
setStatus({
|
|
||||||
tone: 'status',
|
|
||||||
message:
|
|
||||||
data?.provider === 'jellyfin'
|
|
||||||
? 'Password updated across Jellyfin and Magent. Seerr continues to use the same Jellyfin password.'
|
|
||||||
: 'Password updated.',
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
if (err instanceof Error && err.message) {
|
|
||||||
setStatus({ tone: 'error', message: `Could not update password. ${err.message}` })
|
|
||||||
} else {
|
|
||||||
setStatus({ tone: 'error', message: 'Could not update password. Check your current password.' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveEmail = async (event: React.FormEvent) => {
|
const user = data?.user
|
||||||
event.preventDefault()
|
const passwordProvider = user?.password_provider ?? (user?.auth_provider === 'jellyfin' ? 'jellyfin' : 'local')
|
||||||
const nextEmail = email.trim()
|
const canChangePassword = user?.password_change_supported ?? ['local', 'jellyfin'].includes(user?.auth_provider ?? '')
|
||||||
setEmailStatus(null)
|
const emailChanged = email.trim() !== (user?.email ?? '')
|
||||||
if (nextEmail && !isValidEmail(nextEmail)) {
|
const recent = data?.activity?.recent ?? []
|
||||||
setEmailStatus({ tone: 'error', message: 'Enter a valid email address.' })
|
const notice = (value: Notice) => value && <p className={`account-notice is-${value.tone}`} role={value.tone === 'error' ? 'alert' : 'status'}>{value.message}</p>
|
||||||
return
|
|
||||||
}
|
|
||||||
setEmailSaving(true)
|
|
||||||
try {
|
|
||||||
const response = await authFetch(`${getApiBase()}/auth/profile/email`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ email: nextEmail || null }),
|
|
||||||
})
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let detail = 'Could not save your email address.'
|
|
||||||
try {
|
|
||||||
const payload = await response.json()
|
|
||||||
if (typeof payload?.detail === 'string' && payload.detail.trim()) detail = payload.detail
|
|
||||||
} catch {
|
|
||||||
// Keep the plain fallback when the response is not JSON.
|
|
||||||
}
|
|
||||||
throw new Error(detail)
|
|
||||||
}
|
|
||||||
const data = await response.json()
|
|
||||||
const savedEmail = typeof data?.email === 'string' ? data.email : ''
|
|
||||||
setEmail(savedEmail)
|
|
||||||
setProfile((current) => current ? { ...current, email: savedEmail || null } : current)
|
|
||||||
setEmailStatus({
|
|
||||||
tone: 'status',
|
|
||||||
message: savedEmail
|
|
||||||
? 'Contact email saved. Magent can now use it for account and issue updates.'
|
|
||||||
: 'Contact email removed from your account.',
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setEmailStatus({
|
|
||||||
tone: 'error',
|
|
||||||
message: err instanceof Error ? err.message : 'Could not save your email address.',
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setEmailSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const authProvider = profile?.auth_provider ?? 'local'
|
|
||||||
const passwordProvider = profile?.password_provider ?? (authProvider === 'jellyfin' ? 'jellyfin' : 'local')
|
|
||||||
const canManageInvites = profile?.role === 'admin' || Boolean(profile?.invite_management_enabled)
|
|
||||||
const canChangePassword = Boolean(profile?.password_change_supported ?? (authProvider === 'local' || authProvider === 'jellyfin'))
|
|
||||||
const securityHelpText =
|
|
||||||
passwordProvider === 'jellyfin'
|
|
||||||
? 'Reset your password here once. Magent updates Jellyfin directly, Seerr continues to use Jellyfin authentication, and Magent keeps the same password in sync.'
|
|
||||||
: passwordProvider === 'local'
|
|
||||||
? 'Change your Magent account password.'
|
|
||||||
: 'Password changes are not available for this sign-in provider.'
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <main className="card">Loading profile...</main>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="card">
|
<main className="account-page">
|
||||||
<div className="user-directory-panel-header profile-page-header">
|
<header className="account-heading">
|
||||||
<div>
|
<div><span className="account-eyebrow">YOUR ACCOUNT</span><h1>My profile</h1></div>
|
||||||
<h1>My profile</h1>
|
{user && <div className="account-identity"><span className="account-avatar" aria-hidden="true">{user.username.slice(0, 1).toUpperCase()}</span><div><strong>{user.username}</strong><span>{user.role === 'admin' ? 'Administrator' : 'Member'}</span></div></div>}
|
||||||
<p className="lede">Review your account, activity, and security settings.</p>
|
</header>
|
||||||
</div>
|
|
||||||
{canManageInvites || canChangePassword ? (
|
{loading ? <p className="account-empty" role="status">Loading your profile…</p> : loadError ? (
|
||||||
<div className="admin-inline-actions">
|
<div className="account-empty"><p role="alert">{loadError}</p><button type="button" className="account-secondary" onClick={() => void loadProfile()}>Try again</button></div>
|
||||||
{canManageInvites ? (
|
) : user && <>
|
||||||
<button type="button" className="ghost-button" onClick={() => router.push(inviteLink)}>
|
<div className="account-tabs" role="tablist" aria-label="Profile sections">
|
||||||
Open invite page
|
{TABS.map((tab, index) => <button key={tab.key} id={`profile-tab-${tab.key}`} type="button" role="tab"
|
||||||
</button>
|
aria-selected={activeTab === tab.key} aria-controls={`profile-panel-${tab.key}`} tabIndex={activeTab === tab.key ? 0 : -1}
|
||||||
) : null}
|
onKeyDown={(event) => tabKeyDown(event, index)} onClick={() => selectTab(tab.key)}>{tab.label}</button>)}
|
||||||
{canChangePassword ? (
|
|
||||||
<button type="button" onClick={() => selectTab('security')}>
|
|
||||||
{passwordProvider === 'jellyfin' ? 'Reset Jellyfin password' : 'Change password'}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{profile && (
|
<section className="account-panel" id="profile-panel-overview" role="tabpanel" aria-labelledby="profile-tab-overview" hidden={activeTab !== 'overview'}>
|
||||||
<div className="status-banner">
|
<div className="account-section-intro"><h2>Contact email</h2><p>For password recovery and updates on your reported issues.</p></div>
|
||||||
Signed in as <strong>{profile.username}</strong> ({profile.role}). Login type:{' '}
|
<form className="account-form" onSubmit={saveEmail}>
|
||||||
{profile.auth_provider}.
|
<label htmlFor="profile-email">Email address</label>
|
||||||
</div>
|
<input id="profile-email" name="email" type="email" autoComplete="email" placeholder="you@example.com" value={email} disabled={emailSaving}
|
||||||
)}
|
onChange={(event) => { setEmail(event.target.value); setEmailNotice(null) }} />
|
||||||
|
{!user.email && <p className="account-hint">Add an email so we can let you know when a fix is ready.</p>}
|
||||||
<div className="profile-tabbar">
|
{user.email && !email.trim() && <p className="account-hint">Saving without an email stops account and issue emails.</p>}
|
||||||
<div className="admin-segmented" role="tablist" aria-label="Profile sections">
|
{notice(emailNotice)}
|
||||||
<button
|
<div className="account-form-actions">
|
||||||
type="button"
|
<button type="submit" className="account-primary" disabled={emailSaving || !emailChanged}>{emailSaving ? 'Saving…' : 'Save email'}</button>
|
||||||
role="tab"
|
{emailChanged && <button type="button" className="account-secondary" disabled={emailSaving} onClick={() => { setEmail(user.email ?? ''); setEmailNotice(null) }}>Discard</button>}
|
||||||
aria-selected={activeTab === 'overview'}
|
|
||||||
className={activeTab === 'overview' ? 'is-active' : ''}
|
|
||||||
onClick={() => selectTab('overview')}
|
|
||||||
>
|
|
||||||
Overview
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={activeTab === 'activity'}
|
|
||||||
className={activeTab === 'activity' ? 'is-active' : ''}
|
|
||||||
onClick={() => selectTab('activity')}
|
|
||||||
>
|
|
||||||
Activity
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={activeTab === 'security'}
|
|
||||||
className={activeTab === 'security' ? 'is-active' : ''}
|
|
||||||
onClick={() => selectTab('security')}
|
|
||||||
>
|
|
||||||
Password
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeTab === 'overview' && (
|
|
||||||
<section className="profile-section profile-tab-panel">
|
|
||||||
<div className="profile-quick-link-card profile-contact-card">
|
|
||||||
<div>
|
|
||||||
<h2>Contact email</h2>
|
|
||||||
<p className="lede">
|
|
||||||
Used for password recovery, invite messages, and updates about issues you report.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<form className="profile-contact-form" onSubmit={saveEmail}>
|
|
||||||
<label>
|
|
||||||
<span>Email address</span>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(event) => setEmail(event.target.value)}
|
|
||||||
placeholder="you@example.com"
|
|
||||||
autoComplete="email"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{emailStatus ? (
|
|
||||||
<div className={emailStatus.tone === 'error' ? 'error-banner' : 'status-banner'}>
|
|
||||||
{emailStatus.message}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="admin-inline-actions">
|
|
||||||
<button type="submit" disabled={emailSaving || Boolean(email.trim() && !isValidEmail(email))}>
|
|
||||||
{emailSaving ? 'Saving…' : 'Save email'}
|
|
||||||
</button>
|
|
||||||
{profile?.email ? (
|
|
||||||
<button type="button" className="ghost-button" onClick={() => setEmail('')}>
|
|
||||||
Clear field
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
<div className="account-connected"><span className="account-connection-dot" aria-hidden="true" /><span>{user.auth_provider === 'jellyfin' ? 'Connected with your Grizzlyflix account' : user.auth_provider === 'local' ? 'Signed in with a Magent account' : 'Signed in with your media account'}</span></div>
|
||||||
{canManageInvites ? (
|
|
||||||
<div className="profile-quick-link-card">
|
|
||||||
<div>
|
|
||||||
<h2>Invite tools</h2>
|
|
||||||
<p className="lede">
|
|
||||||
Create invite links, send them by email, and track who you have invited from a dedicated page.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="admin-inline-actions">
|
|
||||||
<button type="button" onClick={() => router.push(inviteLink)}>
|
|
||||||
Go to invites
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{canChangePassword ? (
|
|
||||||
<div className="profile-quick-link-card">
|
|
||||||
<div>
|
|
||||||
<h2>{passwordProvider === 'jellyfin' ? 'Jellyfin password' : 'Password'}</h2>
|
|
||||||
<p className="lede">
|
|
||||||
{passwordProvider === 'jellyfin'
|
|
||||||
? 'Update your shared Jellyfin, Seerr, and Magent password without leaving Magent.'
|
|
||||||
: 'Update your Magent account password.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="admin-inline-actions">
|
|
||||||
<button type="button" onClick={() => selectTab('security')}>
|
|
||||||
{passwordProvider === 'jellyfin' ? 'Reset Jellyfin password' : 'Change password'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<h2>Account stats</h2>
|
|
||||||
<div className="stat-grid">
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-label">Requests submitted</div>
|
|
||||||
<div className="stat-value">{stats?.total ?? 0}</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-label">Ready to watch</div>
|
|
||||||
<div className="stat-value">{stats?.ready ?? 0}</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-label">In progress</div>
|
|
||||||
<div className="stat-value">{stats?.in_progress ?? 0}</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-label">Pending approval</div>
|
|
||||||
<div className="stat-value">{stats?.pending ?? 0}</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-label">Declined</div>
|
|
||||||
<div className="stat-value">{stats?.declined ?? 0}</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-label">Working</div>
|
|
||||||
<div className="stat-value">{stats?.working ?? 0}</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-label">Partial</div>
|
|
||||||
<div className="stat-value">{stats?.partial ?? 0}</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-label">Approved</div>
|
|
||||||
<div className="stat-value">{stats?.approved ?? 0}</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-label">Last request</div>
|
|
||||||
<div className="stat-value stat-value--small">
|
|
||||||
{formatDate(stats?.last_request_at)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-label">Share of all requests</div>
|
|
||||||
<div className="stat-value">
|
|
||||||
{stats?.global_total ? `${Math.round((stats.share || 0) * 1000) / 10}%` : '0%'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-label">Total requests (global)</div>
|
|
||||||
<div className="stat-value">{stats?.global_total ?? 0}</div>
|
|
||||||
</div>
|
|
||||||
{profile?.role === 'admin' ? (
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-label">Most active user</div>
|
|
||||||
<div className="stat-value stat-value--small">
|
|
||||||
{stats?.most_active_user
|
|
||||||
? `${stats.most_active_user.username} (${stats.most_active_user.total})`
|
|
||||||
: 'N/A'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'activity' && (
|
<section className="account-panel" id="profile-panel-security" role="tabpanel" aria-labelledby="profile-tab-security" hidden={activeTab !== 'security'}>
|
||||||
<section className="profile-section profile-tab-panel">
|
<div className="account-section-intro"><h2>Change password</h2><p>{passwordProvider === 'jellyfin' ? 'One password for Grizzlyflix, Seerr and Magent.' : 'Keep your Magent account secure.'}</p></div>
|
||||||
<h2>Connection history</h2>
|
{canChangePassword ? <form className="account-form" onSubmit={savePassword}>
|
||||||
<div className="status-banner">
|
<fieldset disabled={passwordSaving}>
|
||||||
Last seen {formatDate(activity?.last_seen_at)} from {activity?.last_ip ?? 'Unknown'}.
|
<label htmlFor="profile-current-password">Current password</label>
|
||||||
</div>
|
<input id="profile-current-password" type="password" autoComplete="current-password" value={currentPassword} onChange={(event) => setCurrentPassword(event.target.value)} required />
|
||||||
<div className="connection-list">
|
<label htmlFor="profile-new-password">New password</label>
|
||||||
{(activity?.recent ?? []).map((entry, index) => (
|
<input id="profile-new-password" type="password" autoComplete="new-password" aria-describedby="password-length" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} minLength={8} required />
|
||||||
<div key={`${entry.ip}-${entry.last_seen_at}-${index}`} className="connection-item">
|
<p id="password-length" className="account-hint">At least 8 characters.</p>
|
||||||
<div>
|
<label htmlFor="profile-confirm-password">Confirm new password</label>
|
||||||
<div className="connection-label">{parseBrowser(entry.user_agent)}</div>
|
<input id="profile-confirm-password" type="password" autoComplete="new-password" value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} minLength={8} required />
|
||||||
<div className="meta">IP: {entry.ip}</div>
|
</fieldset>
|
||||||
<div className="meta">First seen: {formatDate(entry.first_seen_at)}</div>
|
{notice(passwordNotice)}
|
||||||
<div className="meta">Last seen: {formatDate(entry.last_seen_at)}</div>
|
<div className="account-form-actions"><button type="submit" className="account-primary" disabled={passwordSaving}>{passwordSaving ? 'Updating…' : 'Update password'}</button></div>
|
||||||
</div>
|
</form> : <p className="account-empty">Password changes are managed by your sign-in provider. Contact an administrator for help.</p>}
|
||||||
<div className="connection-count">{entry.hit_count} visits</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{activity && activity.recent.length === 0 ? (
|
|
||||||
<div className="status-banner">No connection history yet.</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'security' && (
|
<section className="account-panel" id="profile-panel-activity" role="tabpanel" aria-labelledby="profile-tab-activity" hidden={activeTab !== 'activity'}>
|
||||||
<section className="profile-section profile-tab-panel">
|
<div className="account-section-intro"><h2>Your activity</h2><p>Your requests and recent account access.</p></div>
|
||||||
<h2>{passwordProvider === 'jellyfin' ? 'Jellyfin password reset' : 'Password'}</h2>
|
{data?.stats && <div className="account-request-summary"><div><strong>{data.stats.total}</strong><span>Requests</span></div><div><strong>{data.stats.ready}</strong><span>Ready to watch</span></div><a href="/">View my requests <span aria-hidden="true">↗</span></a></div>}
|
||||||
<div className="status-banner">{securityHelpText}</div>
|
<h3 className="account-list-heading">Recent account access</h3>
|
||||||
{canChangePassword ? (
|
{recent.length ? <ul className="account-access-list">
|
||||||
<form onSubmit={submit} className="auth-form profile-security-form">
|
{(showAllActivity ? recent : recent.slice(0, 5)).map((entry, index) => <li key={`${entry.ip}-${entry.last_seen_at}-${index}`}>
|
||||||
<label>
|
<div className="account-access-summary"><strong>{deviceName(entry.user_agent)}</strong><time dateTime={entry.last_seen_at}>{formatDate(entry.last_seen_at)}</time></div>
|
||||||
{passwordProvider === 'jellyfin' ? 'Current Jellyfin password' : 'Current password'}
|
<details><summary>Connection details</summary><dl><div><dt>IP address</dt><dd>{entry.ip || 'Not recorded'}</dd></div><div><dt>First seen</dt><dd>{formatDate(entry.first_seen_at)}</dd></div></dl></details>
|
||||||
<input
|
</li>)}
|
||||||
type="password"
|
</ul> : <p className="account-empty">No recent activity yet.</p>}
|
||||||
value={currentPassword}
|
{recent.length > 5 && <button className="account-secondary" type="button" onClick={() => setShowAllActivity(!showAllActivity)}>{showAllActivity ? 'Show less' : 'Show all activity'}</button>}
|
||||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
|
||||||
autoComplete="current-password"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
{passwordProvider === 'jellyfin' ? 'New Jellyfin password' : 'New password'}
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={newPassword}
|
|
||||||
onChange={(event) => setNewPassword(event.target.value)}
|
|
||||||
autoComplete="new-password"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Confirm new password
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={confirmPassword}
|
|
||||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
|
||||||
autoComplete="new-password"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{status ? (
|
|
||||||
<div className={status.tone === 'error' ? 'error-banner' : 'status-banner'}>
|
|
||||||
{status.message}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="auth-actions">
|
|
||||||
<button type="submit">
|
|
||||||
{passwordProvider === 'jellyfin' ? 'Reset Jellyfin password' : 'Update password'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<div className="status-banner">
|
|
||||||
Password changes are not available for {authProvider} sign-in accounts from Magent.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
</>}
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import AdminSidebar from './AdminSidebar'
|
import SettingsNavigation from './SettingsNavigation'
|
||||||
|
|
||||||
type AdminShellProps = {
|
type AdminShellProps = {
|
||||||
title: string
|
title: string
|
||||||
@@ -12,13 +12,9 @@ type AdminShellProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminShell({ title, subtitle, actions, rail, children }: AdminShellProps) {
|
export default function AdminShell({ title, subtitle, actions, rail, children }: AdminShellProps) {
|
||||||
const hasRail = Boolean(rail)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`admin-shell ${hasRail ? 'admin-shell--with-rail' : 'admin-shell--no-rail'}`}>
|
<div className="admin-shell admin-shell--top-nav">
|
||||||
<aside className="admin-shell-nav">
|
<SettingsNavigation />
|
||||||
<AdminSidebar />
|
|
||||||
</aside>
|
|
||||||
<main className="card admin-card">
|
<main className="card admin-card">
|
||||||
<div className="admin-header">
|
<div className="admin-header">
|
||||||
<div>
|
<div>
|
||||||
@@ -29,8 +25,8 @@ export default function AdminShell({ title, subtitle, actions, rail, children }:
|
|||||||
{actions}
|
{actions}
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
|
{rail && <details className="admin-supplemental"><summary>Additional information</summary>{rail}</details>}
|
||||||
</main>
|
</main>
|
||||||
{hasRail ? <aside className="admin-shell-rail">{rail}</aside> : null}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { usePathname, useRouter } from 'next/navigation'
|
|
||||||
import { CONFIG_GROUPS } from '../admin/configNavigation'
|
|
||||||
|
|
||||||
export default function AdminSidebar() {
|
|
||||||
const pathname = usePathname()
|
|
||||||
const router = useRouter()
|
|
||||||
const links = CONFIG_GROUPS.flatMap((group) => group.items)
|
|
||||||
const current = links.find((item) => item.href === pathname)
|
|
||||||
const renderLinks = (items: typeof links) => (
|
|
||||||
<div className="admin-nav-links">
|
|
||||||
{items.map((item) => <a key={item.href} href={item.href} aria-current={pathname === item.href ? 'page' : undefined} className={pathname === item.href ? 'is-active' : ''}>{item.label}</a>)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<nav className="admin-sidebar" aria-label="Settings navigation">
|
|
||||||
<label className="config-mobile-picker">
|
|
||||||
<span>Settings</span>
|
|
||||||
<select aria-label="Settings section" value={current?.href ?? '/admin'} onChange={(event) => router.push(event.target.value)}>
|
|
||||||
<option value="/admin">Settings overview</option>
|
|
||||||
{CONFIG_GROUPS.map((group) => <optgroup label={group.title} key={group.title}>{group.items.map((item) => <option key={item.href} value={item.href}>{item.label}</option>)}</optgroup>)}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<div className="config-desktop-navigation">
|
|
||||||
<a href="/admin" className="config-sidebar-home" aria-current={pathname === '/admin' ? 'page' : undefined}>Settings <span aria-hidden="true">↗</span></a>
|
|
||||||
{CONFIG_GROUPS.map((group) => group.advanced ? (
|
|
||||||
<details className="admin-nav-group config-nav-advanced" key={pathname + group.title} open={group.items.some((item) => item.href === pathname) || undefined}>
|
|
||||||
<summary>{group.title}</summary>{renderLinks(group.items)}
|
|
||||||
</details>
|
|
||||||
) : (
|
|
||||||
<div className="admin-nav-group" key={group.title}><span className="admin-nav-title">{group.title}</span>{renderLinks(group.items)}</div>
|
|
||||||
))}
|
|
||||||
<a href="/" className="config-sidebar-back">← My requests</a>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
|
import BrandingLogo from './BrandingLogo'
|
||||||
|
import HeaderActions from './HeaderActions'
|
||||||
|
import HeaderIdentity from './HeaderIdentity'
|
||||||
|
import SiteStatus from './SiteStatus'
|
||||||
|
import UserViewBanner from './UserViewBanner'
|
||||||
|
import WorkspaceNavigation from './WorkspaceNavigation'
|
||||||
|
|
||||||
|
export default function ApplicationChrome() {
|
||||||
|
const pathname = usePathname()
|
||||||
|
if (pathname === '/login') return null
|
||||||
|
return <>
|
||||||
|
<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-right"><span className="beta-chip" title="Beta environment">Beta</span><HeaderIdentity /></div>
|
||||||
|
<div className="header-nav"><HeaderActions /></div>
|
||||||
|
</header>
|
||||||
|
<WorkspaceNavigation />
|
||||||
|
<UserViewBanner />
|
||||||
|
<SiteStatus />
|
||||||
|
</>
|
||||||
|
}
|
||||||
@@ -63,7 +63,7 @@ export default function HeaderActions() {
|
|||||||
{
|
{
|
||||||
href: '/admin',
|
href: '/admin',
|
||||||
label: 'Config',
|
label: 'Config',
|
||||||
match: (path: string) => path.startsWith('/admin'),
|
match: (path: string) => path.startsWith('/admin') || path.startsWith('/users'),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function MagentMark() {
|
||||||
|
return <svg className="magent-mark" viewBox="0 0 40 40" fill="none" aria-hidden="true"><rect x=".5" y=".5" width="39" height="39" rx="11" fill="#242329" stroke="#45434f" /><path d="M10 29V11h4l6 9 6-9h4v18h-4V18l-6 8-6-8v11h-4Z" fill="#dedaff" /></svg>
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { usePathname, useRouter } from 'next/navigation'
|
||||||
|
import { CONFIG_GROUPS } from '../admin/configNavigation'
|
||||||
|
|
||||||
|
export default function SettingsNavigation() {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const router = useRouter()
|
||||||
|
const current = CONFIG_GROUPS.flatMap((group) => group.items).find((item) => item.href === pathname)
|
||||||
|
if (pathname === '/admin') return null
|
||||||
|
return <nav className="settings-top-navigation" aria-label="Settings navigation">
|
||||||
|
<a href="/admin">← All settings</a>
|
||||||
|
<label><span>Jump to</span><select aria-label="Settings section" value={current?.href ?? '/admin'} onChange={(event) => router.push(event.target.value)}>
|
||||||
|
<option value="/admin">Settings overview</option>
|
||||||
|
{CONFIG_GROUPS.map((group) => <optgroup label={group.title} key={group.title}>{group.items.map((item) => <option key={item.href} value={item.href}>{item.label}</option>)}</optgroup>)}
|
||||||
|
</select></label>
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { authFetch, getApiBase, getToken } from '../lib/auth'
|
import { authFetch, getApiBase, getToken } from '../lib/auth'
|
||||||
import BrandingLogo from './BrandingLogo'
|
|
||||||
|
|
||||||
type NavigationItem = {
|
type NavigationItem = {
|
||||||
href: string
|
href: string
|
||||||
@@ -19,7 +18,7 @@ const NAVIGATION: NavigationItem[] = [
|
|||||||
{ href: '/new-requests', label: 'New Request', shortLabel: 'New', icon: 'media', match: (path) => path === '/new-requests' },
|
{ href: '/new-requests', label: 'New Request', shortLabel: 'New', icon: 'media', match: (path) => path === '/new-requests' },
|
||||||
{ href: '/portal/issues', label: 'Issues', shortLabel: 'Issues', icon: 'issues', match: (path) => path.startsWith('/portal/issues') },
|
{ href: '/portal/issues', label: 'Issues', shortLabel: 'Issues', icon: 'issues', match: (path) => path.startsWith('/portal/issues') },
|
||||||
{ href: '/profile/invites', label: 'Invites', shortLabel: 'Invites', icon: 'invites', match: (path) => path.startsWith('/profile/invites') },
|
{ href: '/profile/invites', label: 'Invites', shortLabel: 'Invites', icon: 'invites', match: (path) => path.startsWith('/profile/invites') },
|
||||||
{ href: '/admin', label: 'Configuration', shortLabel: 'Config', icon: 'settings', adminOnly: true, match: (path) => path.startsWith('/admin') },
|
{ href: '/admin', label: 'Configuration', shortLabel: 'Config', icon: 'settings', adminOnly: true, match: (path) => path.startsWith('/admin') || path.startsWith('/users') },
|
||||||
]
|
]
|
||||||
|
|
||||||
const HIDDEN_ROUTES = ['/login', '/signup', '/forgot-password', '/reset-password', '/how-it-works']
|
const HIDDEN_ROUTES = ['/login', '/signup', '/forgot-password', '/reset-password', '/how-it-works']
|
||||||
@@ -67,24 +66,6 @@ export default function WorkspaceNavigation() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!pathname.startsWith('/admin') && <aside className="workspace-sidebar" aria-label="Workspace navigation">
|
|
||||||
<div className="workspace-sidebar-identity">
|
|
||||||
<BrandingLogo className="workspace-sidebar-logo" />
|
|
||||||
<div><strong>{role === 'admin' ? 'Magent Admin' : 'Magent'}</strong><span>Media operations workspace</span></div>
|
|
||||||
</div>
|
|
||||||
{showRequestsNav && <a className="workspace-new-request" href="/new-requests"><span>+</span> New request</a>}
|
|
||||||
<nav>
|
|
||||||
{items.map((item) => (
|
|
||||||
<a key={item.href} href={item.href} className={item.match(pathname) ? 'is-active' : undefined}>
|
|
||||||
<NavigationIcon name={item.icon} /><span>{item.label}</span>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
<div className="workspace-sidebar-footer">
|
|
||||||
<a href="/feedback">Support</a>
|
|
||||||
<a href={role === 'admin' ? '/admin' : '/profile'}>Settings</a>
|
|
||||||
</div>
|
|
||||||
</aside>}
|
|
||||||
<nav className="workspace-mobile-nav" aria-label="Mobile navigation">
|
<nav className="workspace-mobile-nav" aria-label="Mobile navigation">
|
||||||
{items.slice(0, 5).map((item) => (
|
{items.slice(0, 5).map((item) => (
|
||||||
<a key={item.href} href={item.href} className={item.match(pathname) ? 'is-active' : undefined}>
|
<a key={item.href} href={item.href} className={item.match(pathname) ? 'is-active' : undefined}>
|
||||||
|
|||||||
@@ -47,18 +47,30 @@ echo "Running remote beta smoke checks"
|
|||||||
ssh ${ssh_opts} "${remote}" "
|
ssh ${ssh_opts} "${remote}" "
|
||||||
set -e
|
set -e
|
||||||
python3 - <<'PY'
|
python3 - <<'PY'
|
||||||
from urllib import request
|
import time
|
||||||
|
from urllib import error, request
|
||||||
|
|
||||||
checks = [
|
checks = [
|
||||||
('http://127.0.0.1:8100/health', 200),
|
('http://127.0.0.1:8100/health', 200),
|
||||||
('http://${beta_frontend_bind}:3100/login', 200),
|
('http://${beta_frontend_bind}:3100/login', 200),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Compose returns before the app is ready. Allow the new processes to start
|
||||||
|
# instead of reporting a failed deployment on the first connection reset.
|
||||||
|
deadline = time.monotonic() + 90
|
||||||
for url, expected in checks:
|
for url, expected in checks:
|
||||||
with request.urlopen(url, timeout=20) as response:
|
while True:
|
||||||
|
try:
|
||||||
|
with request.urlopen(url, timeout=5) as response:
|
||||||
if response.status != expected:
|
if response.status != expected:
|
||||||
raise SystemExit(f'{url} returned {response.status}, expected {expected}')
|
raise OSError(f'HTTP {response.status}, expected {expected}')
|
||||||
print(url, response.status)
|
print(url, response.status)
|
||||||
|
break
|
||||||
|
except (error.URLError, OSError, TimeoutError) as exc:
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
raise SystemExit(f'Beta did not become ready: {url}: {exc}') from exc
|
||||||
|
print(f'Waiting for beta to start: {url}', flush=True)
|
||||||
|
time.sleep(2)
|
||||||
PY
|
PY
|
||||||
"
|
"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// UI contract checks. Every API request is mocked: no accounts, passwords,
|
||||||
|
// emails or live media are modified. Requires Node and Playwright.
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright')
|
||||||
|
const base = process.env.REVIEW_BASE || 'http://127.0.0.1:3101'
|
||||||
|
const output = process.env.REVIEW_DIR
|
||||||
|
const site = { login: { showJellyfinLogin: true, showLocalLogin: true, showForgotPassword: true, showSignupLink: true }, navigation: { showRequests: true }, banner: { enabled: true, message: 'Beta environment', tone: 'warning' } }
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
const browser = await chromium.launch({ headless: true })
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
|
||||||
|
let options = structuredClone(site)
|
||||||
|
let loginStatus = 401
|
||||||
|
let profileStatus = 200
|
||||||
|
let emailStatus = 200
|
||||||
|
let passwordStatus = 200
|
||||||
|
let provider = 'jellyfin'
|
||||||
|
let supported = true
|
||||||
|
let savedEmail = 'member@example.com'
|
||||||
|
const calls = []
|
||||||
|
const errors = []
|
||||||
|
await context.route('**/api/**', async (route) => {
|
||||||
|
const request = route.request()
|
||||||
|
const path = new URL(request.url()).pathname
|
||||||
|
const method = request.method()
|
||||||
|
const reply = (json, status = 200) => route.fulfill({ status, json })
|
||||||
|
if (path === '/api/site/public' || path === '/api/site/info') return reply(options)
|
||||||
|
if (path === '/api/auth/me') return reply({ username: 'Grizzlyflix member', role: 'user' })
|
||||||
|
if (path === '/api/auth/profile') return reply(profileStatus === 200 ? {
|
||||||
|
user: { username: 'Grizzlyflix member', role: 'user', email: savedEmail, auth_provider: provider, password_provider: provider, password_change_supported: supported },
|
||||||
|
stats: { total: 12, ready: 9, in_progress: 3 },
|
||||||
|
activity: { recent: Array.from({ length: 7 }, (_, i) => ({ ip: '192.0.2.' + (i + 1), user_agent: 'Mozilla/5.0 (Windows NT 10.0) Chrome/140.0', first_seen_at: '2026-09-01T00:00:00Z', last_seen_at: '2026-09-06T01:00:00Z' })) },
|
||||||
|
} : { detail: 'Service unavailable' }, profileStatus)
|
||||||
|
if (method !== 'GET') calls.push({ path, method, body: request.postData() })
|
||||||
|
if (path === '/api/auth/profile/email') {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||||
|
if (emailStatus !== 200) return reply({ detail: 'This email is already in use.' }, emailStatus)
|
||||||
|
savedEmail = request.postDataJSON().email
|
||||||
|
return reply({ email: savedEmail })
|
||||||
|
}
|
||||||
|
if (path === '/api/auth/password') {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||||
|
return reply(passwordStatus === 200 ? { provider } : { detail: 'Current password is incorrect' }, passwordStatus)
|
||||||
|
}
|
||||||
|
if (path.endsWith('/login')) {
|
||||||
|
if (loginStatus === 0) return route.abort()
|
||||||
|
return reply({ authenticated: loginStatus === 200 }, loginStatus)
|
||||||
|
}
|
||||||
|
if (path.includes('/events/stream')) return route.fulfill({ status: 200, contentType: 'text/event-stream', body: ': fixture\n\n' })
|
||||||
|
if (path.includes('/branding/')) return route.fulfill({ status: 404 })
|
||||||
|
return reply({ requests: [], items: [], services: [], results: [] })
|
||||||
|
})
|
||||||
|
const page = await context.newPage()
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message))
|
||||||
|
const screenshot = async (name) => { if (output) await page.screenshot({ path: output + '/' + name + '.png', fullPage: true, animations: 'disabled' }) }
|
||||||
|
const openLogin = async () => { await page.goto(base + '/login'); await page.getByRole('heading', { name: 'Welcome back.' }).waitFor(); await page.waitForTimeout(250) }
|
||||||
|
const login = async () => { await page.getByLabel('Username', { exact: true }).fill('member'); await page.getByLabel('Password', { exact: true }).fill('test-password'); await page.getByRole('button', { name: 'Sign in', exact: true }).click() }
|
||||||
|
await openLogin()
|
||||||
|
assert.equal(await page.locator('.header,.workspace-sidebar,.admin-sidebar').count(), 0)
|
||||||
|
await screenshot('account-login-desktop')
|
||||||
|
await login()
|
||||||
|
await page.getByRole('alert').filter({ hasText: 'Check your username' }).waitFor()
|
||||||
|
assert.equal(calls.at(-1).path, '/api/auth/jellyfin/login')
|
||||||
|
await page.getByRole('button', { name: 'Magent', exact: true }).click()
|
||||||
|
loginStatus = 429
|
||||||
|
await login()
|
||||||
|
await page.getByRole('alert').filter({ hasText: 'Too many attempts' }).waitFor()
|
||||||
|
assert.equal(calls.at(-1).path, '/api/auth/login')
|
||||||
|
loginStatus = 503
|
||||||
|
await login()
|
||||||
|
await page.getByRole('alert').filter({ hasText: 'temporarily unavailable' }).waitFor()
|
||||||
|
loginStatus = 0
|
||||||
|
await login()
|
||||||
|
await page.getByRole('alert').filter({ hasText: 'Check your connection' }).waitFor()
|
||||||
|
await page.getByRole('button', { name: 'Show password' }).click()
|
||||||
|
assert.equal(await page.getByLabel('Password', { exact: true }).getAttribute('type'), 'text')
|
||||||
|
await page.getByRole('button', { name: 'Hide password' }).click()
|
||||||
|
|
||||||
|
options.login.showJellyfinLogin = false
|
||||||
|
await openLogin()
|
||||||
|
assert.equal(await page.getByRole('group', { name: 'Sign-in account' }).count(), 0)
|
||||||
|
loginStatus = 401
|
||||||
|
await login()
|
||||||
|
await page.getByRole('alert').waitFor()
|
||||||
|
assert.equal(calls.at(-1).path, '/api/auth/login')
|
||||||
|
options.login.showLocalLogin = false
|
||||||
|
options.login.showForgotPassword = false
|
||||||
|
options.login.showSignupLink = false
|
||||||
|
options.banner.message = 'Maintenance tonight'
|
||||||
|
await openLogin()
|
||||||
|
assert.equal(await page.getByRole('button', { name: 'Sign in', exact: true }).count(), 0)
|
||||||
|
assert.equal(await page.getByRole('link', { name: 'Forgot password?' }).count(), 0)
|
||||||
|
assert.equal(await page.getByRole('link', { name: /Create an account/ }).count(), 0)
|
||||||
|
assert(await page.getByText('Maintenance tonight').isVisible())
|
||||||
|
options = structuredClone(site)
|
||||||
|
options.login.showLocalLogin = false
|
||||||
|
await openLogin()
|
||||||
|
loginStatus = 200
|
||||||
|
await login()
|
||||||
|
await page.waitForURL(base + '/')
|
||||||
|
assert((await context.cookies()).some((cookie) => cookie.name === 'magent_logged_in' && cookie.value === '1'))
|
||||||
|
console.log('PASS: both sign-in providers, disabled methods, error states, password visibility, redirect and notices')
|
||||||
|
|
||||||
|
await page.goto(base + '/profile')
|
||||||
|
const email = page.getByLabel('Email address', { exact: true })
|
||||||
|
await email.waitFor()
|
||||||
|
assert.equal(await page.locator('.workspace-sidebar,.admin-sidebar').count(), 0)
|
||||||
|
assert(await page.getByRole('button', { name: 'Save email' }).isDisabled())
|
||||||
|
await screenshot('account-profile-desktop')
|
||||||
|
await email.fill('changed@example.com')
|
||||||
|
await page.getByRole('button', { name: 'Discard' }).click()
|
||||||
|
assert.equal(await email.inputValue(), savedEmail)
|
||||||
|
await email.fill('not-an-email')
|
||||||
|
assert.equal(await email.evaluate((el) => el.checkValidity()), false)
|
||||||
|
await email.fill('changed@example.com')
|
||||||
|
await page.getByRole('button', { name: 'Save email' }).click()
|
||||||
|
await page.getByRole('status').filter({ hasText: 'Email saved.' }).waitFor()
|
||||||
|
assert.equal(JSON.parse(calls.at(-1).body).email, 'changed@example.com')
|
||||||
|
await email.fill('')
|
||||||
|
assert(await page.getByText('Saving without an email stops account and issue emails.').isVisible())
|
||||||
|
await page.getByRole('button', { name: 'Save email' }).click()
|
||||||
|
await page.getByRole('status').filter({ hasText: 'Email removed.' }).waitFor()
|
||||||
|
assert.equal(JSON.parse(calls.at(-1).body).email, null)
|
||||||
|
emailStatus = 409
|
||||||
|
await email.fill('duplicate@example.com')
|
||||||
|
await page.getByRole('button', { name: 'Save email' }).click()
|
||||||
|
await page.getByRole('alert').filter({ hasText: 'already in use' }).waitFor()
|
||||||
|
await page.getByRole('tab', { name: 'Account', exact: true }).focus()
|
||||||
|
await page.keyboard.press('ArrowRight')
|
||||||
|
assert.equal(await page.getByRole('tab', { name: 'Security', exact: true }).getAttribute('aria-selected'), 'true')
|
||||||
|
await screenshot('account-security-desktop')
|
||||||
|
await page.getByLabel('Current password', { exact: true }).fill('current-password')
|
||||||
|
await page.getByLabel('New password', { exact: true }).fill('new-password')
|
||||||
|
await page.getByLabel('Confirm new password', { exact: true }).fill('mismatch-password')
|
||||||
|
const previousCalls = calls.length
|
||||||
|
await page.getByRole('button', { name: 'Update password' }).click()
|
||||||
|
await page.getByRole('alert').filter({ hasText: 'do not match' }).waitFor()
|
||||||
|
assert.equal(calls.length, previousCalls)
|
||||||
|
await page.getByLabel('Confirm new password', { exact: true }).fill('new-password')
|
||||||
|
passwordStatus = 401
|
||||||
|
await page.getByRole('button', { name: 'Update password' }).click()
|
||||||
|
await page.getByRole('alert').filter({ hasText: 'Current password is incorrect' }).waitFor()
|
||||||
|
assert.equal(new URL(page.url()).pathname, '/profile')
|
||||||
|
passwordStatus = 200
|
||||||
|
await page.getByRole('button', { name: 'Update password' }).click()
|
||||||
|
await page.getByRole('status').filter({ hasText: 'Password updated for Grizzlyflix' }).waitFor()
|
||||||
|
assert.equal(await page.getByLabel('Current password', { exact: true }).inputValue(), '')
|
||||||
|
await page.getByRole('tab', { name: 'Activity', exact: true }).click()
|
||||||
|
assert.equal(await page.locator('.account-access-list > li').count(), 5)
|
||||||
|
assert.equal(await page.getByText('192.0.2.1', { exact: true }).isVisible(), false)
|
||||||
|
await page.getByRole('button', { name: 'Show all activity' }).click()
|
||||||
|
assert.equal(await page.locator('.account-access-list > li').count(), 7)
|
||||||
|
await screenshot('account-activity-desktop')
|
||||||
|
provider = 'local'
|
||||||
|
await page.goto(base + '/profile?tab=security')
|
||||||
|
await page.getByText('Keep your Magent account secure.').waitFor()
|
||||||
|
supported = false
|
||||||
|
await page.reload()
|
||||||
|
await page.getByText('Password changes are managed by your sign-in provider.', { exact: false }).waitFor()
|
||||||
|
assert.equal(await page.getByRole('button', { name: 'Update password' }).count(), 0)
|
||||||
|
profileStatus = 503
|
||||||
|
await page.reload()
|
||||||
|
await page.getByRole('button', { name: 'Try again' }).waitFor()
|
||||||
|
assert.equal(new URL(page.url()).pathname, '/profile')
|
||||||
|
profileStatus = 200
|
||||||
|
supported = true
|
||||||
|
await page.getByRole('button', { name: 'Try again' }).click()
|
||||||
|
await page.getByRole('tab', { name: 'Account', exact: true }).waitFor()
|
||||||
|
console.log('PASS: email save/removal/discard/validation, password validation/providers, keyboard tabs, activity and load recovery')
|
||||||
|
|
||||||
|
options = structuredClone(site)
|
||||||
|
for (const width of [320, 390, 768, 1024, 1440]) {
|
||||||
|
await page.setViewportSize({ width, height: 900 })
|
||||||
|
for (const path of ['/login', '/profile', '/profile?tab=security', '/profile?tab=activity']) {
|
||||||
|
await page.goto(base + path)
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth), false, path + ' overflows at ' + width)
|
||||||
|
if (width === 390 && (path === '/profile' || path === '/login')) await screenshot('account-mobile-' + path.slice(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.deepEqual(errors, [])
|
||||||
|
console.log('PASS: responsive layout at 320, 390, 768, 1024 and 1440px; no JavaScript errors')
|
||||||
|
await context.unrouteAll({ behavior: 'ignoreErrors' })
|
||||||
|
await browser.close()
|
||||||
|
})().catch((error) => { console.error(error.message); process.exit(1) })
|
||||||
@@ -8,7 +8,7 @@ const assert = require('node:assert/strict')
|
|||||||
const base = process.env.REVIEW_BASE || 'https://beta.grizzlyflix.co.nz'
|
const base = process.env.REVIEW_BASE || 'https://beta.grizzlyflix.co.nz'
|
||||||
const liveBase = process.env.REVIEW_LIVE_BASE || 'https://beta.grizzlyflix.co.nz'
|
const liveBase = process.env.REVIEW_LIVE_BASE || 'https://beta.grizzlyflix.co.nz'
|
||||||
const prefix = process.env.REVIEW_PREFIX || 'before'
|
const prefix = process.env.REVIEW_PREFIX || 'before'
|
||||||
const session = JSON.parse(fs.readFileSync(reviewDir + '/session.json', 'utf8'))
|
const session = JSON.parse(process.env.REVIEW_SESSION || fs.readFileSync(reviewDir + '/session.json', 'utf8'))
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] })
|
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] })
|
||||||
|
|||||||
Reference in New Issue
Block a user