diff --git a/frontend/app/account.css b/frontend/app/account.css new file mode 100644 index 0000000..652f6ed --- /dev/null +++ b/frontend/app/account.css @@ -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; } +} diff --git a/frontend/app/admin/SettingsPage.tsx b/frontend/app/admin/SettingsPage.tsx index 98f91b5..2ad0174 100644 --- a/frontend/app/admin/SettingsPage.tsx +++ b/frontend/app/admin/SettingsPage.tsx @@ -1750,11 +1750,6 @@ export default function SettingsPage({ section }: SettingsPageProps) { router.push('/admin')}> - Back to settings - - } > {status &&
{status}
} {currentServiceStatus && ( diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 465eff5..6d57f58 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,14 +1,10 @@ import './globals.css' import './ops-redesign.css' import './admin/config.css' +import './account.css' import type { ReactNode } from 'react' import BrandingFavicon from './ui/BrandingFavicon' -import BrandingLogo from './ui/BrandingLogo' -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' +import ApplicationChrome from './ui/ApplicationChrome' export const metadata = { title: 'Magent', @@ -21,27 +17,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
-
-
- - -
-
Magent
-
GrizzlyFlix media operations
-
-
-
-
- Beta - -
-
- -
-
- - - + {children}
diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index 071e5b4..d0740d5 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -1,190 +1,110 @@ 'use client' -import { useRouter } from 'next/navigation' -import { useEffect, useState } from 'react' -import { getApiBase, setToken, clearToken } from '../lib/auth' -import BrandingLogo from '../ui/BrandingLogo' +import { useEffect, useState, type FormEvent } from 'react' +import { getApiBase, setToken } from '../lib/auth' +import MagentMark from '../ui/MagentMark' -const DEFAULT_LOGIN_OPTIONS = { - showJellyfinLogin: true, - showLocalLogin: true, - showForgotPassword: true, - showSignupLink: true, -} +type LoginMode = 'jellyfin' | 'local' +type LoginOptions = { showJellyfinLogin: boolean; showLocalLogin: boolean; showForgotPassword: boolean; showSignupLink: boolean } +const DEFAULT_OPTIONS: LoginOptions = { showJellyfinLogin: true, showLocalLogin: true, showForgotPassword: true, showSignupLink: true } export default function LoginPage() { - const router = useRouter() const [username, setUsername] = useState('') const [password, setPassword] = useState('') - const [error, setError] = useState(null) + const [showPassword, setShowPassword] = useState(false) + const [mode, setMode] = useState('jellyfin') + const [options, setOptions] = useState(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 [loginOptions, setLoginOptions] = useState(DEFAULT_LOGIN_OPTIONS) - const primaryMode: 'jellyfin' | 'local' | null = loginOptions.showJellyfinLogin - ? 'jellyfin' - : loginOptions.showLocalLogin - ? 'local' - : null + const canSignIn = options.showJellyfinLogin || options.showLocalLogin + const selectedMode: LoginMode = mode === 'jellyfin' && options.showJellyfinLogin ? 'jellyfin' : options.showLocalLogin ? 'local' : 'jellyfin' - const submit = async (event: React.FormEvent, mode: 'local' | 'jellyfin') => { - event.preventDefault() - if (!primaryMode) { - setError('Login is currently disabled. Contact an administrator.') - return + useEffect(() => { + const controller = new AbortController() + const load = async () => { + try { + 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' }) + } + } catch { + // Keep the normal sign-in methods available during a settings outage. + } finally { + if (!controller.signal.aborted) setOptionsReady(true) + } } - setError(null) + void load() + return () => controller.abort() + }, []) + + const submit = async (event: FormEvent) => { + event.preventDefault() + if (loading || !canSignIn || !optionsReady) return + setError('') setLoading(true) try { - clearToken() - const baseUrl = getApiBase() - const endpoint = mode === 'jellyfin' ? '/auth/jellyfin/login' : '/auth/login' - const body = new URLSearchParams({ username, password }) - const response = await fetch(`${baseUrl}${endpoint}`, { + const response = await fetch(`${getApiBase()}${selectedMode === 'jellyfin' ? '/auth/jellyfin/login' : '/auth/login'}`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body, + body: new URLSearchParams({ username: username.trim(), password }), credentials: 'include', }) if (!response.ok) { - throw new Error('Login failed') - } - const data = await response.json() - if (data?.authenticated) { - setToken('cookie') - if (typeof window !== 'undefined') { - window.location.href = '/' - return - } - router.push('/') + 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 } - throw new Error('Login failed') - } catch (err) { - console.error(err) - setError('Invalid username or password.') - } finally { - setLoading(false) - } + const data = await response.json() + if (!data?.authenticated) { setError('Could not sign in. Please try again.'); return } + setToken('cookie') + window.location.assign('/') + } catch { + setError('Could not reach Magent. Check your connection and try again.') + } 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 ( -
-
-
- -
-
- Secure access -

Magent operational gateway

-

{loginHelpText}

-
+
+
+
MagentBeta
+

Welcome back.

Sign in to your media workspace.

+ {banner &&

{banner.message}

} + {optionsReady && options.showJellyfinLogin && options.showLocalLogin &&
+ + +
} + {!optionsReady ?

Loading sign-in…

: !canSignIn ?

Sign-in is currently unavailable. Please contact an administrator.

: ( +
+

{selectedMode === 'jellyfin' ? 'Use your Grizzlyflix / Jellyfin account.' : 'Use your Magent account.'}

+ + setUsername(event.target.value)} autoComplete="username" autoCapitalize="none" spellCheck={false} required disabled={loading} /> +
{options.showForgotPassword && Forgot password?}
+
+ setPassword(event.target.value)} autoComplete="current-password" required disabled={loading} /> + +
+ {error &&

{error}

} + +
+ )} + {optionsReady && options.showSignupLink && }
-
{ - if (!primaryMode) { - event.preventDefault() - setError('Login is currently disabled. Contact an administrator.') - return - } - void submit(event, primaryMode) - }} - className="auth-form auth-panel" - > - - - {error &&
{error}
} -
- {loginOptions.showJellyfinLogin ? ( - - ) : null} -
- {loginOptions.showLocalLogin ? ( - - ) : null} - {loginOptions.showForgotPassword ? ( - - Forgot password? - - ) : null} - {loginOptions.showSignupLink ? ( - - Have an invite? Create your account (Jellyfin + Magent) - - ) : null} - {!loginOptions.showJellyfinLogin && !loginOptions.showLocalLogin ? ( -
Login is currently disabled. Contact an administrator.
- ) : null} -
-
-
+

Grizzlyflix · Request. Watch. Enjoy.

) } diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx index efd1301..969166d 100644 --- a/frontend/app/profile/page.tsx +++ b/frontend/app/profile/page.tsx @@ -1,7 +1,7 @@ 'use client' +import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from 'react' import { useRouter } from 'next/navigation' -import { useEffect, useMemo, useState } from 'react' import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' type ProfileInfo = { @@ -9,542 +9,225 @@ type ProfileInfo = { email?: string | null role: string auth_provider: string - invite_management_enabled?: boolean password_change_supported?: boolean 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 = { ip: string user_agent: string first_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 = { user: ProfileInfo - stats: ProfileStats - activity: ProfileActivity + stats?: { total: number; ready: number; in_progress: number } + activity?: { recent: ActivityEntry[] } } - -type ProfileTab = 'overview' | 'activity' | 'security' - -const normalizeProfileTab = (value?: string | null): ProfileTab => { - if (value === 'activity' || value === 'security') { - return value - } - return 'overview' -} - -const formatDate = (value?: string | null) => { - if (!value) return 'Never' +type Notice = { tone: 'status' | 'error'; message: string } | null +type ProfileTab = 'overview' | 'security' | 'activity' +const TABS: { key: ProfileTab; label: string }[] = [ + { key: 'overview', label: 'Account' }, + { key: 'security', label: 'Security' }, + { key: 'activity', label: 'Activity' }, +] +const normalizeTab = (value: string | null): ProfileTab => value === 'security' || value === 'activity' ? value : 'overview' +const formatDate = (value?: string) => { + if (!value) return 'Not recorded' const date = new Date(value) - if (Number.isNaN(date.valueOf())) return value - return date.toLocaleString() + return Number.isNaN(date.valueOf()) ? 'Not recorded' : date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) } - -const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()) - -const parseBrowser = (agent?: string | null) => { - if (!agent) return 'Unknown' - const value = agent.toLowerCase() - if (value.includes('edg/')) return 'Edge' - if (value.includes('chrome/') && !value.includes('edg/')) return 'Chrome' - if (value.includes('firefox/')) return 'Firefox' - if (value.includes('safari/') && !value.includes('chrome/')) return 'Safari' - return 'Unknown' +const deviceName = (agent: string) => { + const value = (agent || '').toLowerCase() + const browser = value.includes('edg/') ? 'Edge' : value.includes('firefox/') || value.includes('fxios/') ? 'Firefox' + : value.includes('chrome/') || value.includes('crios/') ? 'Chrome' : value.includes('safari/') ? 'Safari' : 'Browser' + const device = /iphone|ipad/.test(value) ? 'iOS' : value.includes('android') ? 'Android' + : value.includes('windows') ? 'Windows' : value.includes('macintosh') ? 'Mac' : value.includes('linux') ? 'Linux' : '' + return device ? `${browser} on ${device}` : browser +} +const responseMessage = async (response: Response, fallback: string) => { + const data = await response.json().catch(() => null) + return typeof data?.detail === 'string' && data.detail.trim() ? data.detail : fallback } export default function ProfilePage() { const router = useRouter() - const [profile, setProfile] = useState(null) - const [stats, setStats] = useState(null) - const [activity, setActivity] = useState(null) + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [loadError, setLoadError] = useState('') + const [activeTab, setActiveTab] = useState('overview') const [email, setEmail] = useState('') const [emailSaving, setEmailSaving] = useState(false) - const [emailStatus, setEmailStatus] = useState<{ tone: 'status' | 'error'; message: string } | null>(null) + const [emailNotice, setEmailNotice] = useState(null) const [currentPassword, setCurrentPassword] = useState('') const [newPassword, setNewPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') - const [status, setStatus] = useState<{ tone: 'status' | 'error'; message: string } | null>(null) - const [activeTab, setActiveTab] = useState('overview') - const [loading, setLoading] = useState(true) + const [passwordSaving, setPasswordSaving] = useState(false) + const [passwordNotice, setPasswordNotice] = useState(null) + const [showAllActivity, setShowAllActivity] = useState(false) - const inviteLink = useMemo(() => '/profile/invites', []) - - useEffect(() => { - if (typeof window === 'undefined') return - const syncTabFromLocation = () => { - const params = new URLSearchParams(window.location.search) - setActiveTab(normalizeProfileTab(params.get('tab'))) + const loadProfile = useCallback(async () => { + if (!getToken()) { router.replace('/login'); return } + setLoading(true) + setLoadError('') + try { + const response = await authFetch(`${getApiBase()}/auth/profile`) + 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() - window.addEventListener('popstate', syncTabFromLocation) - return () => window.removeEventListener('popstate', syncTabFromLocation) + }, [router]) + + 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) => { setActiveTab(tab) - router.replace(tab === 'overview' ? '/profile' : `/profile?tab=${tab}`) + router.replace(tab === 'overview' ? '/profile' : `/profile?tab=${tab}`, { scroll: false }) + } + const tabKeyDown = (event: KeyboardEvent, index: number) => { + let next = index + if (event.key === 'ArrowRight') next = (index + 1) % TABS.length + else if (event.key === 'ArrowLeft') next = (index + TABS.length - 1) % TABS.length + else if (event.key === 'Home') next = 0 + else if (event.key === 'End') next = TABS.length - 1 + else return + event.preventDefault() + selectTab(TABS[next].key) + document.getElementById(`profile-tab-${TABS[next].key}`)?.focus() } - useEffect(() => { - if (!getToken()) { - router.push('/login') - return - } - const load = async () => { - 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) => { + const saveEmail = async (event: FormEvent) => { event.preventDefault() - setStatus(null) - if (!currentPassword || !newPassword) { - setStatus({ tone: 'error', message: 'Enter your current password and a new password.' }) + 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) => { + 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 } 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 } + setPasswordSaving(true) try { - const baseUrl = getApiBase() - const response = await authFetch(`${baseUrl}/auth/password`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - current_password: currentPassword, - new_password: newPassword, - }), + const response = await authFetch(`${getApiBase()}/auth/password`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }), }) - if (!response.ok) { - let detail = 'Update failed' - try { - const payload = await response.json() - if (typeof payload?.detail === 'string' && payload.detail.trim()) { - detail = payload.detail - } - } catch { - const text = await response.text().catch(() => '') - 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.' }) - } - } + if (!response.ok) throw new Error(await responseMessage(response, 'Could not change your password. Please try again.')) + const result = await response.json() + setCurrentPassword(''); setNewPassword(''); setConfirmPassword('') + setPasswordNotice({ tone: 'status', message: result.provider === 'jellyfin' + ? 'Password updated for Grizzlyflix and Magent. Seerr uses the same password.' + : 'Password updated.' }) + } catch (error) { + setPasswordNotice({ tone: 'error', message: error instanceof Error ? error.message : 'Could not change your password.' }) + } finally { setPasswordSaving(false) } } - const saveEmail = async (event: React.FormEvent) => { - event.preventDefault() - const nextEmail = email.trim() - setEmailStatus(null) - if (nextEmail && !isValidEmail(nextEmail)) { - setEmailStatus({ tone: 'error', message: 'Enter a valid email address.' }) - 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
Loading profile...
- } + const user = data?.user + const passwordProvider = user?.password_provider ?? (user?.auth_provider === 'jellyfin' ? 'jellyfin' : 'local') + const canChangePassword = user?.password_change_supported ?? ['local', 'jellyfin'].includes(user?.auth_provider ?? '') + const emailChanged = email.trim() !== (user?.email ?? '') + const recent = data?.activity?.recent ?? [] + const notice = (value: Notice) => value &&

{value.message}

return ( -
-
-
-

My profile

-

Review your account, activity, and security settings.

+
+
+
YOUR ACCOUNT

My profile

+ {user &&
{user.username}{user.role === 'admin' ? 'Administrator' : 'Member'}
} +
+ + {loading ?

Loading your profile…

: loadError ? ( +

{loadError}

+ ) : user && <> +
+ {TABS.map((tab, index) => )}
- {canManageInvites || canChangePassword ? ( -
- {canManageInvites ? ( - - ) : null} - {canChangePassword ? ( - - ) : null} -
- ) : null} -
- {profile && ( -
- Signed in as {profile.username} ({profile.role}). Login type:{' '} - {profile.auth_provider}. -
- )} - -
-
- - - -
-
- - {activeTab === 'overview' && ( -
-
-
-

Contact email

-

- Used for password recovery, invite messages, and updates about issues you report. -

+
- {canManageInvites ? ( -
-
-

Invite tools

-

- Create invite links, send them by email, and track who you have invited from a dedicated page. -

-
-
- -
-
- ) : null} - {canChangePassword ? ( -
-
-

{passwordProvider === 'jellyfin' ? 'Jellyfin password' : 'Password'}

-

- {passwordProvider === 'jellyfin' - ? 'Update your shared Jellyfin, Seerr, and Magent password without leaving Magent.' - : 'Update your Magent account password.'} -

-
-
- -
-
- ) : null} -

Account stats

-
-
-
Requests submitted
-
{stats?.total ?? 0}
-
-
-
Ready to watch
-
{stats?.ready ?? 0}
-
-
-
In progress
-
{stats?.in_progress ?? 0}
-
-
-
Pending approval
-
{stats?.pending ?? 0}
-
-
-
Declined
-
{stats?.declined ?? 0}
-
-
-
Working
-
{stats?.working ?? 0}
-
-
-
Partial
-
{stats?.partial ?? 0}
-
-
-
Approved
-
{stats?.approved ?? 0}
-
-
-
Last request
-
- {formatDate(stats?.last_request_at)} -
-
-
-
Share of all requests
-
- {stats?.global_total ? `${Math.round((stats.share || 0) * 1000) / 10}%` : '0%'} -
-
-
-
Total requests (global)
-
{stats?.global_total ?? 0}
-
- {profile?.role === 'admin' ? ( -
-
Most active user
-
- {stats?.most_active_user - ? `${stats.most_active_user.username} (${stats.most_active_user.total})` - : 'N/A'} -
-
- ) : null} -
+ +
- )} - {activeTab === 'activity' && ( -
-

Connection history

-
- Last seen {formatDate(activity?.last_seen_at)} from {activity?.last_ip ?? 'Unknown'}. -
-
- {(activity?.recent ?? []).map((entry, index) => ( -
-
-
{parseBrowser(entry.user_agent)}
-
IP: {entry.ip}
-
First seen: {formatDate(entry.first_seen_at)}
-
Last seen: {formatDate(entry.last_seen_at)}
-
-
{entry.hit_count} visits
-
- ))} - {activity && activity.recent.length === 0 ? ( -
No connection history yet.
- ) : null} -
+ - )} - {activeTab === 'security' && ( -
-

{passwordProvider === 'jellyfin' ? 'Jellyfin password reset' : 'Password'}

-
{securityHelpText}
- {canChangePassword ? ( -
- - - - {status ? ( -
- {status.message} -
- ) : null} -
- -
-
- ) : ( -
- Password changes are not available for {authProvider} sign-in accounts from Magent. -
- )} + - )} + }
) } diff --git a/frontend/app/ui/AdminShell.tsx b/frontend/app/ui/AdminShell.tsx index 370659a..0f88a0f 100644 --- a/frontend/app/ui/AdminShell.tsx +++ b/frontend/app/ui/AdminShell.tsx @@ -1,7 +1,7 @@ 'use client' import type { ReactNode } from 'react' -import AdminSidebar from './AdminSidebar' +import SettingsNavigation from './SettingsNavigation' type AdminShellProps = { title: string @@ -12,13 +12,9 @@ type AdminShellProps = { } export default function AdminShell({ title, subtitle, actions, rail, children }: AdminShellProps) { - const hasRail = Boolean(rail) - return ( -
- +
+
@@ -29,8 +25,8 @@ export default function AdminShell({ title, subtitle, actions, rail, children }: {actions}
{children} + {rail &&
Additional information{rail}
}
- {hasRail ? : null}
) } diff --git a/frontend/app/ui/AdminSidebar.tsx b/frontend/app/ui/AdminSidebar.tsx deleted file mode 100644 index 9bc1eec..0000000 --- a/frontend/app/ui/AdminSidebar.tsx +++ /dev/null @@ -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) => ( -
- {items.map((item) => {item.label})} -
- ) - return ( - - ) -} diff --git a/frontend/app/ui/ApplicationChrome.tsx b/frontend/app/ui/ApplicationChrome.tsx new file mode 100644 index 0000000..56662c7 --- /dev/null +++ b/frontend/app/ui/ApplicationChrome.tsx @@ -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 <> +
+ +
Beta
+
+
+ + + + +} diff --git a/frontend/app/ui/HeaderActions.tsx b/frontend/app/ui/HeaderActions.tsx index c612910..622057a 100644 --- a/frontend/app/ui/HeaderActions.tsx +++ b/frontend/app/ui/HeaderActions.tsx @@ -63,7 +63,7 @@ export default function HeaderActions() { { href: '/admin', label: 'Config', - match: (path: string) => path.startsWith('/admin'), + match: (path: string) => path.startsWith('/admin') || path.startsWith('/users'), }, ] : [ diff --git a/frontend/app/ui/MagentMark.tsx b/frontend/app/ui/MagentMark.tsx new file mode 100644 index 0000000..a33b79e --- /dev/null +++ b/frontend/app/ui/MagentMark.tsx @@ -0,0 +1,3 @@ +export default function MagentMark() { + return +} diff --git a/frontend/app/ui/SettingsNavigation.tsx b/frontend/app/ui/SettingsNavigation.tsx new file mode 100644 index 0000000..bed37e5 --- /dev/null +++ b/frontend/app/ui/SettingsNavigation.tsx @@ -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 +} diff --git a/frontend/app/ui/WorkspaceNavigation.tsx b/frontend/app/ui/WorkspaceNavigation.tsx index c5a169b..69e93fa 100644 --- a/frontend/app/ui/WorkspaceNavigation.tsx +++ b/frontend/app/ui/WorkspaceNavigation.tsx @@ -3,7 +3,6 @@ import { usePathname } from 'next/navigation' import { useEffect, useState } from 'react' import { authFetch, getApiBase, getToken } from '../lib/auth' -import BrandingLogo from './BrandingLogo' type NavigationItem = { 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: '/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: '/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'] @@ -67,24 +66,6 @@ export default function WorkspaceNavigation() { return ( <> - {!pathname.startsWith('/admin') && }