Add dedicated profile invites page and fix mobile admin layout
This commit is contained in:
@@ -1 +1 @@
|
|||||||
0103262251
|
0203261511
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
BUILD_NUMBER = "0103262251"
|
BUILD_NUMBER = "0203261511"
|
||||||
CHANGELOG = '2026-01-22\\n- Initial commit\\n- Ignore build artifacts\\n- Update README\\n- Update README with Docker-first guide\\n\\n2026-01-23\\n- Fix cache titles via Seerr media lookup\\n- Split search actions and improve download options\\n- Fallback manual grab to qBittorrent\\n- Hide header actions when signed out\\n- Add feedback form and webhook\\n- Fix cache titles and move feedback link\\n- Show available status on landing when in Jellyfin\\n- Add default branding assets when missing\\n- Use bundled branding assets\\n- Remove password fields from users page\\n- Add Docker Hub compose override\\n- Fix backend Dockerfile paths for root context\\n- Copy public assets into frontend image\\n- Use backend branding assets for logo and favicon\\n\\n2026-01-24\\n- Route grabs through Sonarr/Radarr only\\n- Document fix buttons in how-it-works\\n- Clarify how-it-works steps and fixes\\n- Map Prowlarr releases to Arr indexers for manual grab\\n- Improve request handling and qBittorrent categories\\n\\n2026-01-25\\n- Add site banner, build number, and changelog\\n- Automate build number tagging and sync\\n- Improve mobile header layout\\n- Move account actions into avatar menu\\n- Add user stats and activity tracking\\n- Add Jellyfin login cache and admin-only stats\\n- Tidy request sync controls\\n- Seed branding logo from bundled assets\\n- Serve bundled branding assets by default\\n- Harden request cache titles and cache-only reads\\n- Build 2501262041\\n\\n2026-01-26\\n- Fix cache title hydration\\n- Fix sync progress bar animation\\n\\n2026-01-27\\n- Add cache control artwork stats\\n- Improve cache stats performance (build 271261145)\\n- Fix backend cache stats import (build 271261149)\\n- Clarify request sync settings (build 271261159)\\n- Bump build number to 271261202\\n- Fix request titles in snapshots (build 271261219)\\n- Fix snapshot title fallback (build 271261228)\\n- Add cache load spinner (build 271261238)\\n- Bump build number (process 2) 271261322\\n- Add service test buttons (build 271261335)\\n- Fallback to TMDB when artwork cache fails (build 271261524)\\n- Hydrate missing artwork from Seerr (build 271261539)\\n\\n2026-01-29\\n- release: 2901262036\\n- release: 2901262044\\n- release: 2901262102\\n- Hardcode build number in backend\\n- Bake build number and changelog\\n- Update full changelog\\n- Tidy full changelog\\n- Build 2901262240: cache users\n\n2026-01-30\n- Merge backend and frontend into one container'
|
CHANGELOG = '2026-01-22\\n- Initial commit\\n- Ignore build artifacts\\n- Update README\\n- Update README with Docker-first guide\\n\\n2026-01-23\\n- Fix cache titles via Seerr media lookup\\n- Split search actions and improve download options\\n- Fallback manual grab to qBittorrent\\n- Hide header actions when signed out\\n- Add feedback form and webhook\\n- Fix cache titles and move feedback link\\n- Show available status on landing when in Jellyfin\\n- Add default branding assets when missing\\n- Use bundled branding assets\\n- Remove password fields from users page\\n- Add Docker Hub compose override\\n- Fix backend Dockerfile paths for root context\\n- Copy public assets into frontend image\\n- Use backend branding assets for logo and favicon\\n\\n2026-01-24\\n- Route grabs through Sonarr/Radarr only\\n- Document fix buttons in how-it-works\\n- Clarify how-it-works steps and fixes\\n- Map Prowlarr releases to Arr indexers for manual grab\\n- Improve request handling and qBittorrent categories\\n\\n2026-01-25\\n- Add site banner, build number, and changelog\\n- Automate build number tagging and sync\\n- Improve mobile header layout\\n- Move account actions into avatar menu\\n- Add user stats and activity tracking\\n- Add Jellyfin login cache and admin-only stats\\n- Tidy request sync controls\\n- Seed branding logo from bundled assets\\n- Serve bundled branding assets by default\\n- Harden request cache titles and cache-only reads\\n- Build 2501262041\\n\\n2026-01-26\\n- Fix cache title hydration\\n- Fix sync progress bar animation\\n\\n2026-01-27\\n- Add cache control artwork stats\\n- Improve cache stats performance (build 271261145)\\n- Fix backend cache stats import (build 271261149)\\n- Clarify request sync settings (build 271261159)\\n- Bump build number to 271261202\\n- Fix request titles in snapshots (build 271261219)\\n- Fix snapshot title fallback (build 271261228)\\n- Add cache load spinner (build 271261238)\\n- Bump build number (process 2) 271261322\\n- Add service test buttons (build 271261335)\\n- Fallback to TMDB when artwork cache fails (build 271261524)\\n- Hydrate missing artwork from Seerr (build 271261539)\\n\\n2026-01-29\\n- release: 2901262036\\n- release: 2901262044\\n- release: 2901262102\\n- Hardcode build number in backend\\n- Bake build number and changelog\\n- Update full changelog\\n- Tidy full changelog\\n- Build 2901262240: cache users\n\n2026-01-30\n- Merge backend and frontend into one container'
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5221,6 +5221,26 @@ textarea {
|
|||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-quick-link-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: start;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
background: rgba(255, 255, 255, 0.018);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-quick-link-card h2 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-quick-link-card .lede {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-invites-section {
|
.profile-invites-section {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -5391,6 +5411,10 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
|
.profile-quick-link-card {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-invites-layout {
|
.profile-invites-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -6070,3 +6094,142 @@ textarea {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Final responsive admin shell stabilization */
|
||||||
|
.admin-shell,
|
||||||
|
.admin-shell-nav,
|
||||||
|
.admin-card,
|
||||||
|
.admin-shell-rail,
|
||||||
|
.admin-sidebar,
|
||||||
|
.admin-panel {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
.admin-shell {
|
||||||
|
grid-template-columns: minmax(220px, 250px) minmax(0, 1fr);
|
||||||
|
grid-template-areas:
|
||||||
|
"nav main"
|
||||||
|
"nav rail";
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell-nav {
|
||||||
|
grid-area: nav;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-card {
|
||||||
|
grid-area: main;
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell-rail {
|
||||||
|
grid-area: rail;
|
||||||
|
grid-column: auto;
|
||||||
|
position: static;
|
||||||
|
top: auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1080px) {
|
||||||
|
.page {
|
||||||
|
width: min(100%, calc(100vw - 12px));
|
||||||
|
max-width: none;
|
||||||
|
padding-inline: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card,
|
||||||
|
.admin-card {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-template-areas:
|
||||||
|
"nav"
|
||||||
|
"main"
|
||||||
|
"rail";
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell-nav,
|
||||||
|
.admin-card,
|
||||||
|
.admin-shell-rail {
|
||||||
|
grid-column: auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell-nav {
|
||||||
|
position: static;
|
||||||
|
top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar,
|
||||||
|
.admin-rail-stack,
|
||||||
|
.admin-rail-card,
|
||||||
|
.maintenance-layout,
|
||||||
|
.maintenance-tools-panel,
|
||||||
|
.cache-table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-grid,
|
||||||
|
.users-page-toolbar-grid,
|
||||||
|
.users-summary-grid,
|
||||||
|
.users-page-overview-grid,
|
||||||
|
.maintenance-action-grid,
|
||||||
|
.schedule-grid,
|
||||||
|
.diagnostics-inline-summary,
|
||||||
|
.diagnostics-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav,
|
||||||
|
.settings-links {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-group {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-links a {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section-actions,
|
||||||
|
.diagnostics-control-panel,
|
||||||
|
.diagnostics-control-actions,
|
||||||
|
.log-actions {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section-actions > *,
|
||||||
|
.diagnostics-control-actions > *,
|
||||||
|
.log-actions > * {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-meta,
|
||||||
|
.diagnostic-card-top,
|
||||||
|
.diagnostics-category-header,
|
||||||
|
.users-summary-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-row span {
|
||||||
|
white-space: normal;
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
624
frontend/app/profile/invites/page.tsx
Normal file
624
frontend/app/profile/invites/page.tsx
Normal file
@@ -0,0 +1,624 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
|
||||||
|
|
||||||
|
type ProfileInfo = {
|
||||||
|
username: string
|
||||||
|
role: string
|
||||||
|
auth_provider: string
|
||||||
|
invite_management_enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProfileResponse = {
|
||||||
|
user: ProfileInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
type OwnedInvite = {
|
||||||
|
id: number
|
||||||
|
code: string
|
||||||
|
label?: string | null
|
||||||
|
description?: string | null
|
||||||
|
recipient_email?: string | null
|
||||||
|
max_uses?: number | null
|
||||||
|
use_count: number
|
||||||
|
remaining_uses?: number | null
|
||||||
|
enabled: boolean
|
||||||
|
expires_at?: string | null
|
||||||
|
is_expired?: boolean
|
||||||
|
is_usable?: boolean
|
||||||
|
created_at?: string | null
|
||||||
|
updated_at?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type OwnedInvitesResponse = {
|
||||||
|
invites?: OwnedInvite[]
|
||||||
|
count?: number
|
||||||
|
invite_access?: {
|
||||||
|
enabled?: boolean
|
||||||
|
managed_by_master?: boolean
|
||||||
|
}
|
||||||
|
master_invite?: {
|
||||||
|
id: number
|
||||||
|
code: string
|
||||||
|
label?: string | null
|
||||||
|
description?: string | null
|
||||||
|
max_uses?: number | null
|
||||||
|
enabled?: boolean
|
||||||
|
expires_at?: string | null
|
||||||
|
is_usable?: boolean
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type OwnedInviteForm = {
|
||||||
|
code: string
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
recipient_email: string
|
||||||
|
max_uses: string
|
||||||
|
expires_at: string
|
||||||
|
enabled: boolean
|
||||||
|
send_email: boolean
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOwnedInviteForm = (): OwnedInviteForm => ({
|
||||||
|
code: '',
|
||||||
|
label: '',
|
||||||
|
description: '',
|
||||||
|
recipient_email: '',
|
||||||
|
max_uses: '',
|
||||||
|
expires_at: '',
|
||||||
|
enabled: true,
|
||||||
|
send_email: false,
|
||||||
|
message: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatDate = (value?: string | null) => {
|
||||||
|
if (!value) return 'Never'
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.valueOf())) return value
|
||||||
|
return date.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfileInvitesPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [profile, setProfile] = useState<ProfileInfo | null>(null)
|
||||||
|
const [inviteStatus, setInviteStatus] = useState<string | null>(null)
|
||||||
|
const [inviteError, setInviteError] = useState<string | null>(null)
|
||||||
|
const [invites, setInvites] = useState<OwnedInvite[]>([])
|
||||||
|
const [inviteSaving, setInviteSaving] = useState(false)
|
||||||
|
const [inviteEditingId, setInviteEditingId] = useState<number | null>(null)
|
||||||
|
const [inviteForm, setInviteForm] = useState<OwnedInviteForm>(defaultOwnedInviteForm())
|
||||||
|
const [inviteAccessEnabled, setInviteAccessEnabled] = useState(false)
|
||||||
|
const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false)
|
||||||
|
const [masterInviteTemplate, setMasterInviteTemplate] = useState<OwnedInvitesResponse['master_invite']>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const signupBaseUrl = useMemo(() => {
|
||||||
|
if (typeof window === 'undefined') return '/signup'
|
||||||
|
return `${window.location.origin}/signup`
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadPage = async () => {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const [profileResponse, invitesResponse] = await Promise.all([
|
||||||
|
authFetch(`${baseUrl}/auth/profile`),
|
||||||
|
authFetch(`${baseUrl}/auth/profile/invites`),
|
||||||
|
])
|
||||||
|
if (!profileResponse.ok || !invitesResponse.ok) {
|
||||||
|
if (profileResponse.status === 401 || invitesResponse.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw new Error('Could not load invite tools.')
|
||||||
|
}
|
||||||
|
const [profileData, inviteData] = (await Promise.all([
|
||||||
|
profileResponse.json(),
|
||||||
|
invitesResponse.json(),
|
||||||
|
])) as [ProfileResponse, OwnedInvitesResponse]
|
||||||
|
const user = profileData?.user ?? {}
|
||||||
|
setProfile({
|
||||||
|
username: user?.username ?? 'Unknown',
|
||||||
|
role: user?.role ?? 'user',
|
||||||
|
auth_provider: user?.auth_provider ?? 'local',
|
||||||
|
invite_management_enabled: Boolean(user?.invite_management_enabled ?? false),
|
||||||
|
})
|
||||||
|
setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : [])
|
||||||
|
setInviteAccessEnabled(Boolean(inviteData?.invite_access?.enabled ?? false))
|
||||||
|
setInviteManagedByMaster(Boolean(inviteData?.invite_access?.managed_by_master ?? false))
|
||||||
|
setMasterInviteTemplate(inviteData?.master_invite ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!getToken()) {
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
await loadPage()
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setInviteError(err instanceof Error ? err.message : 'Could not load invite tools.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load()
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
const resetInviteEditor = () => {
|
||||||
|
setInviteEditingId(null)
|
||||||
|
setInviteForm(defaultOwnedInviteForm())
|
||||||
|
}
|
||||||
|
|
||||||
|
const editInvite = (invite: OwnedInvite) => {
|
||||||
|
setInviteEditingId(invite.id)
|
||||||
|
setInviteError(null)
|
||||||
|
setInviteStatus(null)
|
||||||
|
setInviteForm({
|
||||||
|
code: invite.code ?? '',
|
||||||
|
label: invite.label ?? '',
|
||||||
|
description: invite.description ?? '',
|
||||||
|
recipient_email: invite.recipient_email ?? '',
|
||||||
|
max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '',
|
||||||
|
expires_at: invite.expires_at ?? '',
|
||||||
|
enabled: invite.enabled !== false,
|
||||||
|
send_email: false,
|
||||||
|
message: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const reloadInvites = async () => {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(`${baseUrl}/auth/profile/invites`)
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw new Error(`Invite refresh failed: ${response.status}`)
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as OwnedInvitesResponse
|
||||||
|
setInvites(Array.isArray(data?.invites) ? data.invites : [])
|
||||||
|
setInviteAccessEnabled(Boolean(data?.invite_access?.enabled ?? false))
|
||||||
|
setInviteManagedByMaster(Boolean(data?.invite_access?.managed_by_master ?? false))
|
||||||
|
setMasterInviteTemplate(data?.master_invite ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveInvite = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setInviteSaving(true)
|
||||||
|
setInviteError(null)
|
||||||
|
setInviteStatus(null)
|
||||||
|
try {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(
|
||||||
|
inviteEditingId == null
|
||||||
|
? `${baseUrl}/auth/profile/invites`
|
||||||
|
: `${baseUrl}/auth/profile/invites/${inviteEditingId}`,
|
||||||
|
{
|
||||||
|
method: inviteEditingId == null ? 'POST' : 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: inviteForm.code || null,
|
||||||
|
label: inviteForm.label || null,
|
||||||
|
description: inviteForm.description || null,
|
||||||
|
recipient_email: inviteForm.recipient_email || null,
|
||||||
|
max_uses: inviteForm.max_uses || null,
|
||||||
|
expires_at: inviteForm.expires_at || null,
|
||||||
|
enabled: inviteForm.enabled,
|
||||||
|
send_email: inviteForm.send_email,
|
||||||
|
message: inviteForm.message || null,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = await response.text()
|
||||||
|
throw new Error(text || 'Invite save failed')
|
||||||
|
}
|
||||||
|
const data = await response.json().catch(() => ({}))
|
||||||
|
if (data?.email?.status === 'ok') {
|
||||||
|
setInviteStatus(
|
||||||
|
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'} and email sent to ${data.email.recipient_email}.`
|
||||||
|
)
|
||||||
|
} else if (data?.email?.status === 'error') {
|
||||||
|
setInviteStatus(
|
||||||
|
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'}, but email failed: ${data.email.detail}`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setInviteStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
|
||||||
|
}
|
||||||
|
resetInviteEditor()
|
||||||
|
await reloadInvites()
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setInviteError(err instanceof Error ? err.message : 'Could not save invite.')
|
||||||
|
} finally {
|
||||||
|
setInviteSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteInvite = async (invite: OwnedInvite) => {
|
||||||
|
if (!window.confirm(`Delete invite "${invite.code}"?`)) return
|
||||||
|
setInviteError(null)
|
||||||
|
setInviteStatus(null)
|
||||||
|
try {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(`${baseUrl}/auth/profile/invites/${invite.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = await response.text()
|
||||||
|
throw new Error(text || 'Invite delete failed')
|
||||||
|
}
|
||||||
|
if (inviteEditingId === invite.id) {
|
||||||
|
resetInviteEditor()
|
||||||
|
}
|
||||||
|
setInviteStatus(`Deleted invite ${invite.code}.`)
|
||||||
|
await reloadInvites()
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setInviteError(err instanceof Error ? err.message : 'Could not delete invite.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyInviteLink = async (invite: OwnedInvite) => {
|
||||||
|
const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}`
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(url)
|
||||||
|
setInviteStatus(`Copied invite link for ${invite.code}.`)
|
||||||
|
} else {
|
||||||
|
window.prompt('Copy invite link', url)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
window.prompt('Copy invite link', url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canManageInvites = profile?.role === 'admin' || inviteAccessEnabled
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <main className="card">Loading invite tools...</main>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="card">
|
||||||
|
<div className="user-directory-panel-header profile-page-header">
|
||||||
|
<div>
|
||||||
|
<h1>My invites</h1>
|
||||||
|
<p className="lede">Create invite links, email them directly, and track who you have invited.</p>
|
||||||
|
</div>
|
||||||
|
<div className="admin-inline-actions">
|
||||||
|
<button type="button" className="ghost-button" onClick={() => router.push('/profile')}>
|
||||||
|
Back to profile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{profile ? (
|
||||||
|
<div className="status-banner">
|
||||||
|
Signed in as <strong>{profile.username}</strong> ({profile.role}).
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="profile-tabbar">
|
||||||
|
<div className="admin-segmented" role="tablist" aria-label="Profile sections">
|
||||||
|
<button type="button" role="tab" aria-selected={false} onClick={() => router.push('/profile')}>
|
||||||
|
Overview
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={false}
|
||||||
|
onClick={() => router.push('/profile?tab=activity')}
|
||||||
|
>
|
||||||
|
Activity
|
||||||
|
</button>
|
||||||
|
<button type="button" role="tab" aria-selected className="is-active">
|
||||||
|
My invites
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={false}
|
||||||
|
onClick={() => router.push('/profile?tab=security')}
|
||||||
|
>
|
||||||
|
Security
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{inviteError && <div className="error-banner">{inviteError}</div>}
|
||||||
|
{inviteStatus && <div className="status-banner">{inviteStatus}</div>}
|
||||||
|
|
||||||
|
{!canManageInvites ? (
|
||||||
|
<section className="profile-section profile-tab-panel">
|
||||||
|
<h2>Invite access is disabled</h2>
|
||||||
|
<p className="lede">
|
||||||
|
Your account is not currently allowed to create self-service invites. Ask an administrator to enable invite access for your profile.
|
||||||
|
</p>
|
||||||
|
<div className="admin-inline-actions">
|
||||||
|
<button type="button" onClick={() => router.push('/profile')}>
|
||||||
|
Return to profile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section className="profile-section profile-invites-section profile-tab-panel">
|
||||||
|
<div className="user-directory-panel-header">
|
||||||
|
<div>
|
||||||
|
<h2>Invite workspace</h2>
|
||||||
|
<p className="lede">
|
||||||
|
{inviteManagedByMaster
|
||||||
|
? 'Create and manage invite links you have issued. New invites use the admin master invite rule.'
|
||||||
|
: 'Create and manage invite links you have issued. New invites use your account defaults.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="profile-invites-layout">
|
||||||
|
<div className="profile-invite-form-card">
|
||||||
|
<h3>{inviteEditingId == null ? 'Create invite' : 'Edit invite'}</h3>
|
||||||
|
<p className="meta profile-invite-form-lede">
|
||||||
|
Save a recipient email, send the invite immediately, and keep the generated link ready to copy.
|
||||||
|
</p>
|
||||||
|
{inviteManagedByMaster && masterInviteTemplate ? (
|
||||||
|
<div className="status-banner profile-invite-master-banner">
|
||||||
|
Using master invite rule <code>{masterInviteTemplate.code}</code>
|
||||||
|
{masterInviteTemplate.label ? ` (${masterInviteTemplate.label})` : ''}. Limits and status are managed by admin.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<form onSubmit={saveInvite} className="admin-form compact-form invite-form-layout profile-form-layout">
|
||||||
|
<div className="invite-form-row">
|
||||||
|
<div className="invite-form-row-label">
|
||||||
|
<span>Identity</span>
|
||||||
|
<small>Optional code and label for easier tracking.</small>
|
||||||
|
</div>
|
||||||
|
<div className="invite-form-row-control invite-form-row-grid">
|
||||||
|
<label>
|
||||||
|
<span>Code (optional)</span>
|
||||||
|
<input
|
||||||
|
value={inviteForm.code}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({ ...current, code: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Leave blank to auto-generate"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Label</span>
|
||||||
|
<input
|
||||||
|
value={inviteForm.label}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({ ...current, label: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Family invite"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="invite-form-row">
|
||||||
|
<div className="invite-form-row-label">
|
||||||
|
<span>Description</span>
|
||||||
|
<small>Optional note shown on the signup page.</small>
|
||||||
|
</div>
|
||||||
|
<div className="invite-form-row-control">
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={inviteForm.description}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({
|
||||||
|
...current,
|
||||||
|
description: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="Optional note shown on the signup page"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="invite-form-row">
|
||||||
|
<div className="invite-form-row-label">
|
||||||
|
<span>Delivery</span>
|
||||||
|
<small>Save a recipient email and optionally send the invite immediately.</small>
|
||||||
|
</div>
|
||||||
|
<div className="invite-form-row-control invite-form-row-control--stacked">
|
||||||
|
<label>
|
||||||
|
<span>Recipient email</span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={inviteForm.recipient_email}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({
|
||||||
|
...current,
|
||||||
|
recipient_email: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="friend@example.com"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Delivery note</span>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={inviteForm.message}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({
|
||||||
|
...current,
|
||||||
|
message: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="Optional note to include in the email"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="inline-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={inviteForm.send_email}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({
|
||||||
|
...current,
|
||||||
|
send_email: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Send "You have been invited" email after saving
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="invite-form-row">
|
||||||
|
<div className="invite-form-row-label">
|
||||||
|
<span>Limits</span>
|
||||||
|
<small>Usage cap and optional expiry date/time.</small>
|
||||||
|
</div>
|
||||||
|
<div className="invite-form-row-control invite-form-row-grid">
|
||||||
|
<label>
|
||||||
|
<span>Max uses</span>
|
||||||
|
<input
|
||||||
|
value={inviteForm.max_uses}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({ ...current, max_uses: event.target.value }))
|
||||||
|
}
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="Blank = unlimited"
|
||||||
|
disabled={inviteManagedByMaster}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Invite expiry (ISO datetime)</span>
|
||||||
|
<input
|
||||||
|
value={inviteForm.expires_at}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({ ...current, expires_at: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="2026-03-01T12:00:00+00:00"
|
||||||
|
disabled={inviteManagedByMaster}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="invite-form-row">
|
||||||
|
<div className="invite-form-row-label">
|
||||||
|
<span>Status</span>
|
||||||
|
<small>Enable or disable this invite before sharing.</small>
|
||||||
|
</div>
|
||||||
|
<div className="invite-form-row-control invite-form-row-control--stacked">
|
||||||
|
<label className="inline-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={inviteForm.enabled}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({
|
||||||
|
...current,
|
||||||
|
enabled: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={inviteManagedByMaster}
|
||||||
|
/>
|
||||||
|
Invite is enabled
|
||||||
|
</label>
|
||||||
|
<div className="admin-inline-actions">
|
||||||
|
<button type="submit" disabled={inviteSaving}>
|
||||||
|
{inviteSaving
|
||||||
|
? 'Saving…'
|
||||||
|
: inviteEditingId == null
|
||||||
|
? 'Create invite'
|
||||||
|
: 'Save invite'}
|
||||||
|
</button>
|
||||||
|
{inviteEditingId != null && (
|
||||||
|
<button type="button" className="ghost-button" onClick={resetInviteEditor}>
|
||||||
|
Cancel edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div className="meta profile-invite-hint">
|
||||||
|
Invite URL format: <code>{signupBaseUrl}?code=INVITECODE</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="profile-invites-list">
|
||||||
|
{invites.length === 0 ? (
|
||||||
|
<div className="status-banner">You have not created any invites yet.</div>
|
||||||
|
) : (
|
||||||
|
<div className="admin-list">
|
||||||
|
{invites.map((invite) => (
|
||||||
|
<div key={invite.id} className="admin-list-item">
|
||||||
|
<div className="admin-list-item-main">
|
||||||
|
<div className="admin-list-item-title-row">
|
||||||
|
<code className="invite-code">{invite.code}</code>
|
||||||
|
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
|
||||||
|
{invite.is_usable ? 'Usable' : 'Unavailable'}
|
||||||
|
</span>
|
||||||
|
<span className="small-pill is-muted">
|
||||||
|
{invite.remaining_uses == null ? 'Unlimited' : `${invite.remaining_uses} left`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{invite.label && <p className="admin-list-item-text">{invite.label}</p>}
|
||||||
|
{invite.description && (
|
||||||
|
<p className="admin-list-item-text admin-list-item-text--muted">
|
||||||
|
{invite.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="admin-meta-row">
|
||||||
|
<span>Recipient: {invite.recipient_email || 'Not set'}</span>
|
||||||
|
<span>
|
||||||
|
Uses: {invite.use_count}
|
||||||
|
{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}
|
||||||
|
</span>
|
||||||
|
<span>Expires: {formatDate(invite.expires_at)}</span>
|
||||||
|
<span>Created: {formatDate(invite.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-inline-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={() => copyInviteLink(invite)}
|
||||||
|
>
|
||||||
|
Copy link
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={() => editInvite(invite)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => deleteInvite(invite)}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -48,68 +48,15 @@ type ProfileResponse = {
|
|||||||
activity: ProfileActivity
|
activity: ProfileActivity
|
||||||
}
|
}
|
||||||
|
|
||||||
type OwnedInvite = {
|
type ProfileTab = 'overview' | 'activity' | 'security'
|
||||||
id: number
|
|
||||||
code: string
|
|
||||||
label?: string | null
|
|
||||||
description?: string | null
|
|
||||||
recipient_email?: string | null
|
|
||||||
max_uses?: number | null
|
|
||||||
use_count: number
|
|
||||||
remaining_uses?: number | null
|
|
||||||
enabled: boolean
|
|
||||||
expires_at?: string | null
|
|
||||||
is_expired?: boolean
|
|
||||||
is_usable?: boolean
|
|
||||||
created_at?: string | null
|
|
||||||
updated_at?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type OwnedInvitesResponse = {
|
const normalizeProfileTab = (value?: string | null): ProfileTab => {
|
||||||
invites?: OwnedInvite[]
|
if (value === 'activity' || value === 'security') {
|
||||||
count?: number
|
return value
|
||||||
invite_access?: {
|
|
||||||
enabled?: boolean
|
|
||||||
managed_by_master?: boolean
|
|
||||||
}
|
}
|
||||||
master_invite?: {
|
return 'overview'
|
||||||
id: number
|
|
||||||
code: string
|
|
||||||
label?: string | null
|
|
||||||
description?: string | null
|
|
||||||
max_uses?: number | null
|
|
||||||
enabled?: boolean
|
|
||||||
expires_at?: string | null
|
|
||||||
is_usable?: boolean
|
|
||||||
} | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type OwnedInviteForm = {
|
|
||||||
code: string
|
|
||||||
label: string
|
|
||||||
description: string
|
|
||||||
recipient_email: string
|
|
||||||
max_uses: string
|
|
||||||
expires_at: string
|
|
||||||
enabled: boolean
|
|
||||||
send_email: boolean
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProfileTab = 'overview' | 'activity' | 'invites' | 'security'
|
|
||||||
|
|
||||||
const defaultOwnedInviteForm = (): OwnedInviteForm => ({
|
|
||||||
code: '',
|
|
||||||
label: '',
|
|
||||||
description: '',
|
|
||||||
recipient_email: '',
|
|
||||||
max_uses: '',
|
|
||||||
expires_at: '',
|
|
||||||
enabled: true,
|
|
||||||
send_email: false,
|
|
||||||
message: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const formatDate = (value?: string | null) => {
|
const formatDate = (value?: string | null) => {
|
||||||
if (!value) return 'Never'
|
if (!value) return 'Never'
|
||||||
const date = new Date(value)
|
const date = new Date(value)
|
||||||
@@ -135,23 +82,27 @@ export default function ProfilePage() {
|
|||||||
const [currentPassword, setCurrentPassword] = useState('')
|
const [currentPassword, setCurrentPassword] = useState('')
|
||||||
const [newPassword, setNewPassword] = useState('')
|
const [newPassword, setNewPassword] = useState('')
|
||||||
const [status, setStatus] = useState<string | null>(null)
|
const [status, setStatus] = useState<string | null>(null)
|
||||||
const [inviteStatus, setInviteStatus] = useState<string | null>(null)
|
|
||||||
const [inviteError, setInviteError] = useState<string | null>(null)
|
|
||||||
const [invites, setInvites] = useState<OwnedInvite[]>([])
|
|
||||||
const [inviteSaving, setInviteSaving] = useState(false)
|
|
||||||
const [inviteEditingId, setInviteEditingId] = useState<number | null>(null)
|
|
||||||
const [inviteForm, setInviteForm] = useState<OwnedInviteForm>(defaultOwnedInviteForm())
|
|
||||||
const [activeTab, setActiveTab] = useState<ProfileTab>('overview')
|
const [activeTab, setActiveTab] = useState<ProfileTab>('overview')
|
||||||
const [inviteAccessEnabled, setInviteAccessEnabled] = useState(false)
|
|
||||||
const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false)
|
|
||||||
const [masterInviteTemplate, setMasterInviteTemplate] = useState<OwnedInvitesResponse['master_invite']>(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
const signupBaseUrl = useMemo(() => {
|
const inviteLink = useMemo(() => '/profile/invites', [])
|
||||||
if (typeof window === 'undefined') return '/signup'
|
|
||||||
return `${window.location.origin}/signup`
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
const syncTabFromLocation = () => {
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
setActiveTab(normalizeProfileTab(params.get('tab')))
|
||||||
|
}
|
||||||
|
syncTabFromLocation()
|
||||||
|
window.addEventListener('popstate', syncTabFromLocation)
|
||||||
|
return () => window.removeEventListener('popstate', syncTabFromLocation)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const selectTab = (tab: ProfileTab) => {
|
||||||
|
setActiveTab(tab)
|
||||||
|
router.replace(tab === 'overview' ? '/profile' : `/profile?tab=${tab}`)
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!getToken()) {
|
if (!getToken()) {
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
@@ -160,19 +111,13 @@ export default function ProfilePage() {
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const baseUrl = getApiBase()
|
const baseUrl = getApiBase()
|
||||||
const [profileResponse, invitesResponse] = await Promise.all([
|
const profileResponse = await authFetch(`${baseUrl}/auth/profile`)
|
||||||
authFetch(`${baseUrl}/auth/profile`),
|
if (!profileResponse.ok) {
|
||||||
authFetch(`${baseUrl}/auth/profile/invites`),
|
|
||||||
])
|
|
||||||
if (!profileResponse.ok || !invitesResponse.ok) {
|
|
||||||
clearToken()
|
clearToken()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const [data, inviteData] = (await Promise.all([
|
const data = (await profileResponse.json()) as ProfileResponse
|
||||||
profileResponse.json(),
|
|
||||||
invitesResponse.json(),
|
|
||||||
])) as [ProfileResponse, OwnedInvitesResponse]
|
|
||||||
const user = data?.user ?? {}
|
const user = data?.user ?? {}
|
||||||
setProfile({
|
setProfile({
|
||||||
username: user?.username ?? 'Unknown',
|
username: user?.username ?? 'Unknown',
|
||||||
@@ -182,10 +127,6 @@ export default function ProfilePage() {
|
|||||||
})
|
})
|
||||||
setStats(data?.stats ?? null)
|
setStats(data?.stats ?? null)
|
||||||
setActivity(data?.activity ?? null)
|
setActivity(data?.activity ?? null)
|
||||||
setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : [])
|
|
||||||
setInviteAccessEnabled(Boolean(inviteData?.invite_access?.enabled ?? false))
|
|
||||||
setInviteManagedByMaster(Boolean(inviteData?.invite_access?.managed_by_master ?? false))
|
|
||||||
setMasterInviteTemplate(inviteData?.master_invite ?? null)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
setStatus('Could not load your profile.')
|
setStatus('Could not load your profile.')
|
||||||
@@ -244,150 +185,8 @@ export default function ProfilePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetInviteEditor = () => {
|
|
||||||
setInviteEditingId(null)
|
|
||||||
setInviteForm(defaultOwnedInviteForm())
|
|
||||||
}
|
|
||||||
|
|
||||||
const editInvite = (invite: OwnedInvite) => {
|
|
||||||
setInviteEditingId(invite.id)
|
|
||||||
setInviteError(null)
|
|
||||||
setInviteStatus(null)
|
|
||||||
setInviteForm({
|
|
||||||
code: invite.code ?? '',
|
|
||||||
label: invite.label ?? '',
|
|
||||||
description: invite.description ?? '',
|
|
||||||
recipient_email: invite.recipient_email ?? '',
|
|
||||||
max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '',
|
|
||||||
expires_at: invite.expires_at ?? '',
|
|
||||||
enabled: invite.enabled !== false,
|
|
||||||
send_email: false,
|
|
||||||
message: '',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const reloadInvites = async () => {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await authFetch(`${baseUrl}/auth/profile/invites`)
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw new Error(`Invite refresh failed: ${response.status}`)
|
|
||||||
}
|
|
||||||
const data = (await response.json()) as OwnedInvitesResponse
|
|
||||||
setInvites(Array.isArray(data?.invites) ? data.invites : [])
|
|
||||||
setInviteAccessEnabled(Boolean(data?.invite_access?.enabled ?? false))
|
|
||||||
setInviteManagedByMaster(Boolean(data?.invite_access?.managed_by_master ?? false))
|
|
||||||
setMasterInviteTemplate(data?.master_invite ?? null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveInvite = async (event: React.FormEvent) => {
|
|
||||||
event.preventDefault()
|
|
||||||
setInviteSaving(true)
|
|
||||||
setInviteError(null)
|
|
||||||
setInviteStatus(null)
|
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await authFetch(
|
|
||||||
inviteEditingId == null
|
|
||||||
? `${baseUrl}/auth/profile/invites`
|
|
||||||
: `${baseUrl}/auth/profile/invites/${inviteEditingId}`,
|
|
||||||
{
|
|
||||||
method: inviteEditingId == null ? 'POST' : 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
code: inviteForm.code || null,
|
|
||||||
label: inviteForm.label || null,
|
|
||||||
description: inviteForm.description || null,
|
|
||||||
recipient_email: inviteForm.recipient_email || null,
|
|
||||||
max_uses: inviteForm.max_uses || null,
|
|
||||||
expires_at: inviteForm.expires_at || null,
|
|
||||||
enabled: inviteForm.enabled,
|
|
||||||
send_email: inviteForm.send_email,
|
|
||||||
message: inviteForm.message || null,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const text = await response.text()
|
|
||||||
throw new Error(text || 'Invite save failed')
|
|
||||||
}
|
|
||||||
const data = await response.json().catch(() => ({}))
|
|
||||||
if (data?.email?.status === 'ok') {
|
|
||||||
setInviteStatus(
|
|
||||||
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'} and email sent to ${data.email.recipient_email}.`
|
|
||||||
)
|
|
||||||
} else if (data?.email?.status === 'error') {
|
|
||||||
setInviteStatus(
|
|
||||||
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'}, but email failed: ${data.email.detail}`
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
setInviteStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
|
|
||||||
}
|
|
||||||
resetInviteEditor()
|
|
||||||
await reloadInvites()
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setInviteError(err instanceof Error ? err.message : 'Could not save invite.')
|
|
||||||
} finally {
|
|
||||||
setInviteSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteInvite = async (invite: OwnedInvite) => {
|
|
||||||
if (!window.confirm(`Delete invite "${invite.code}"?`)) return
|
|
||||||
setInviteError(null)
|
|
||||||
setInviteStatus(null)
|
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await authFetch(`${baseUrl}/auth/profile/invites/${invite.id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
})
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const text = await response.text()
|
|
||||||
throw new Error(text || 'Invite delete failed')
|
|
||||||
}
|
|
||||||
if (inviteEditingId === invite.id) {
|
|
||||||
resetInviteEditor()
|
|
||||||
}
|
|
||||||
setInviteStatus(`Deleted invite ${invite.code}.`)
|
|
||||||
await reloadInvites()
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setInviteError(err instanceof Error ? err.message : 'Could not delete invite.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const copyInviteLink = async (invite: OwnedInvite) => {
|
|
||||||
const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}`
|
|
||||||
try {
|
|
||||||
if (navigator.clipboard?.writeText) {
|
|
||||||
await navigator.clipboard.writeText(url)
|
|
||||||
setInviteStatus(`Copied invite link for ${invite.code}.`)
|
|
||||||
} else {
|
|
||||||
window.prompt('Copy invite link', url)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
window.prompt('Copy invite link', url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const authProvider = profile?.auth_provider ?? 'local'
|
const authProvider = profile?.auth_provider ?? 'local'
|
||||||
const canManageInvites = profile?.role === 'admin' || inviteAccessEnabled
|
const canManageInvites = profile?.role === 'admin' || Boolean(profile?.invite_management_enabled)
|
||||||
const canChangePassword = authProvider === 'local' || authProvider === 'jellyfin'
|
const canChangePassword = authProvider === 'local' || authProvider === 'jellyfin'
|
||||||
const securityHelpText =
|
const securityHelpText =
|
||||||
authProvider === 'jellyfin'
|
authProvider === 'jellyfin'
|
||||||
@@ -396,25 +195,33 @@ export default function ProfilePage() {
|
|||||||
? 'Change your Magent account password.'
|
? 'Change your Magent account password.'
|
||||||
: 'Password changes are not available for this sign-in provider.'
|
: 'Password changes are not available for this sign-in provider.'
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeTab === 'invites' && !canManageInvites) {
|
|
||||||
setActiveTab('overview')
|
|
||||||
}
|
|
||||||
}, [activeTab, canManageInvites])
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <main className="card">Loading profile...</main>
|
return <main className="card">Loading profile...</main>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="card">
|
<main className="card">
|
||||||
|
<div className="user-directory-panel-header profile-page-header">
|
||||||
|
<div>
|
||||||
<h1>My profile</h1>
|
<h1>My profile</h1>
|
||||||
|
<p className="lede">Review your account, activity, and security settings.</p>
|
||||||
|
</div>
|
||||||
|
{canManageInvites ? (
|
||||||
|
<div className="admin-inline-actions">
|
||||||
|
<button type="button" className="ghost-button" onClick={() => router.push(inviteLink)}>
|
||||||
|
Open invite page
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
{profile && (
|
{profile && (
|
||||||
<div className="status-banner">
|
<div className="status-banner">
|
||||||
Signed in as <strong>{profile.username}</strong> ({profile.role}). Login type:{' '}
|
Signed in as <strong>{profile.username}</strong> ({profile.role}). Login type:{' '}
|
||||||
{profile.auth_provider}.
|
{profile.auth_provider}.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="profile-tabbar">
|
<div className="profile-tabbar">
|
||||||
<div className="admin-segmented" role="tablist" aria-label="Profile sections">
|
<div className="admin-segmented" role="tablist" aria-label="Profile sections">
|
||||||
<button
|
<button
|
||||||
@@ -422,7 +229,7 @@ export default function ProfilePage() {
|
|||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={activeTab === 'overview'}
|
aria-selected={activeTab === 'overview'}
|
||||||
className={activeTab === 'overview' ? 'is-active' : ''}
|
className={activeTab === 'overview' ? 'is-active' : ''}
|
||||||
onClick={() => setActiveTab('overview')}
|
onClick={() => selectTab('overview')}
|
||||||
>
|
>
|
||||||
Overview
|
Overview
|
||||||
</button>
|
</button>
|
||||||
@@ -431,18 +238,12 @@ export default function ProfilePage() {
|
|||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={activeTab === 'activity'}
|
aria-selected={activeTab === 'activity'}
|
||||||
className={activeTab === 'activity' ? 'is-active' : ''}
|
className={activeTab === 'activity' ? 'is-active' : ''}
|
||||||
onClick={() => setActiveTab('activity')}
|
onClick={() => selectTab('activity')}
|
||||||
>
|
>
|
||||||
Activity
|
Activity
|
||||||
</button>
|
</button>
|
||||||
{canManageInvites ? (
|
{canManageInvites ? (
|
||||||
<button
|
<button type="button" role="tab" aria-selected={false} onClick={() => router.push(inviteLink)}>
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={activeTab === 'invites'}
|
|
||||||
className={activeTab === 'invites' ? 'is-active' : ''}
|
|
||||||
onClick={() => setActiveTab('invites')}
|
|
||||||
>
|
|
||||||
My invites
|
My invites
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -451,7 +252,7 @@ export default function ProfilePage() {
|
|||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={activeTab === 'security'}
|
aria-selected={activeTab === 'security'}
|
||||||
className={activeTab === 'security' ? 'is-active' : ''}
|
className={activeTab === 'security' ? 'is-active' : ''}
|
||||||
onClick={() => setActiveTab('security')}
|
onClick={() => selectTab('security')}
|
||||||
>
|
>
|
||||||
Security
|
Security
|
||||||
</button>
|
</button>
|
||||||
@@ -460,6 +261,21 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
{activeTab === 'overview' && (
|
{activeTab === 'overview' && (
|
||||||
<section className="profile-section profile-tab-panel">
|
<section className="profile-section profile-tab-panel">
|
||||||
|
{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}
|
||||||
<h2>Account stats</h2>
|
<h2>Account stats</h2>
|
||||||
<div className="stat-grid">
|
<div className="stat-grid">
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
@@ -503,9 +319,7 @@ export default function ProfilePage() {
|
|||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="stat-label">Share of all requests</div>
|
<div className="stat-label">Share of all requests</div>
|
||||||
<div className="stat-value">
|
<div className="stat-value">
|
||||||
{stats?.global_total
|
{stats?.global_total ? `${Math.round((stats.share || 0) * 1000) / 10}%` : '0%'}
|
||||||
? `${Math.round((stats.share || 0) * 1000) / 10}%`
|
|
||||||
: '0%'}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
@@ -551,266 +365,6 @@ export default function ProfilePage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'invites' && (
|
|
||||||
<section className="profile-section profile-invites-section profile-tab-panel">
|
|
||||||
<div className="user-directory-panel-header">
|
|
||||||
<div>
|
|
||||||
<h2>My invites</h2>
|
|
||||||
<p className="lede">
|
|
||||||
{inviteManagedByMaster
|
|
||||||
? 'Create and manage invite links you’ve issued. New invites use the admin master invite rule.'
|
|
||||||
: 'Create and manage invite links you’ve issued. New invites use your account defaults.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{inviteError && <div className="error-banner">{inviteError}</div>}
|
|
||||||
{inviteStatus && <div className="status-banner">{inviteStatus}</div>}
|
|
||||||
<div className="profile-invites-layout">
|
|
||||||
<div className="profile-invite-form-card">
|
|
||||||
<h3>{inviteEditingId == null ? 'Create invite' : 'Edit invite'}</h3>
|
|
||||||
<p className="meta profile-invite-form-lede">
|
|
||||||
Share the generated signup link with the person you want to invite.
|
|
||||||
</p>
|
|
||||||
{inviteManagedByMaster && masterInviteTemplate ? (
|
|
||||||
<div className="status-banner profile-invite-master-banner">
|
|
||||||
Using master invite rule <code>{masterInviteTemplate.code}</code>
|
|
||||||
{masterInviteTemplate.label ? ` (${masterInviteTemplate.label})` : ''}. Limits/status are managed by admin.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<form onSubmit={saveInvite} className="admin-form compact-form invite-form-layout">
|
|
||||||
<div className="invite-form-row">
|
|
||||||
<div className="invite-form-row-label">
|
|
||||||
<span>Identity</span>
|
|
||||||
<small>Optional code and label for easier tracking.</small>
|
|
||||||
</div>
|
|
||||||
<div className="invite-form-row-control invite-form-row-grid">
|
|
||||||
<label>
|
|
||||||
<span>Code (optional)</span>
|
|
||||||
<input
|
|
||||||
value={inviteForm.code}
|
|
||||||
onChange={(event) =>
|
|
||||||
setInviteForm((current) => ({ ...current, code: event.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="Leave blank to auto-generate"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Label</span>
|
|
||||||
<input
|
|
||||||
value={inviteForm.label}
|
|
||||||
onChange={(event) =>
|
|
||||||
setInviteForm((current) => ({ ...current, label: event.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="Family invite"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="invite-form-row">
|
|
||||||
<div className="invite-form-row-label">
|
|
||||||
<span>Description</span>
|
|
||||||
<small>Optional note shown on the signup page.</small>
|
|
||||||
</div>
|
|
||||||
<div className="invite-form-row-control">
|
|
||||||
<textarea
|
|
||||||
rows={3}
|
|
||||||
value={inviteForm.description}
|
|
||||||
onChange={(event) =>
|
|
||||||
setInviteForm((current) => ({
|
|
||||||
...current,
|
|
||||||
description: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="Optional note shown on the signup page"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="invite-form-row">
|
|
||||||
<div className="invite-form-row-label">
|
|
||||||
<span>Delivery</span>
|
|
||||||
<small>Save a recipient email and optionally send the invite immediately.</small>
|
|
||||||
</div>
|
|
||||||
<div className="invite-form-row-control invite-form-row-control--stacked">
|
|
||||||
<label>
|
|
||||||
<span>Recipient email</span>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={inviteForm.recipient_email}
|
|
||||||
onChange={(event) =>
|
|
||||||
setInviteForm((current) => ({
|
|
||||||
...current,
|
|
||||||
recipient_email: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="friend@example.com"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Delivery note</span>
|
|
||||||
<textarea
|
|
||||||
rows={3}
|
|
||||||
value={inviteForm.message}
|
|
||||||
onChange={(event) =>
|
|
||||||
setInviteForm((current) => ({
|
|
||||||
...current,
|
|
||||||
message: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="Optional note to include in the email"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="inline-checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={inviteForm.send_email}
|
|
||||||
onChange={(event) =>
|
|
||||||
setInviteForm((current) => ({
|
|
||||||
...current,
|
|
||||||
send_email: event.target.checked,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
Send "You have been invited" email after saving
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="invite-form-row">
|
|
||||||
<div className="invite-form-row-label">
|
|
||||||
<span>Limits</span>
|
|
||||||
<small>Usage cap and optional expiry date/time.</small>
|
|
||||||
</div>
|
|
||||||
<div className="invite-form-row-control invite-form-row-grid">
|
|
||||||
<label>
|
|
||||||
<span>Max uses</span>
|
|
||||||
<input
|
|
||||||
value={inviteForm.max_uses}
|
|
||||||
onChange={(event) =>
|
|
||||||
setInviteForm((current) => ({ ...current, max_uses: event.target.value }))
|
|
||||||
}
|
|
||||||
inputMode="numeric"
|
|
||||||
placeholder="Blank = unlimited"
|
|
||||||
disabled={inviteManagedByMaster}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Invite expiry (ISO datetime)</span>
|
|
||||||
<input
|
|
||||||
value={inviteForm.expires_at}
|
|
||||||
onChange={(event) =>
|
|
||||||
setInviteForm((current) => ({ ...current, expires_at: event.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="2026-03-01T12:00:00+00:00"
|
|
||||||
disabled={inviteManagedByMaster}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="invite-form-row">
|
|
||||||
<div className="invite-form-row-label">
|
|
||||||
<span>Status</span>
|
|
||||||
<small>Enable or disable this invite before sharing.</small>
|
|
||||||
</div>
|
|
||||||
<div className="invite-form-row-control invite-form-row-control--stacked">
|
|
||||||
<label className="inline-checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={inviteForm.enabled}
|
|
||||||
onChange={(event) =>
|
|
||||||
setInviteForm((current) => ({
|
|
||||||
...current,
|
|
||||||
enabled: event.target.checked,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
disabled={inviteManagedByMaster}
|
|
||||||
/>
|
|
||||||
Invite is enabled
|
|
||||||
</label>
|
|
||||||
<div className="admin-inline-actions">
|
|
||||||
<button type="submit" disabled={inviteSaving}>
|
|
||||||
{inviteSaving
|
|
||||||
? 'Saving…'
|
|
||||||
: inviteEditingId == null
|
|
||||||
? 'Create invite'
|
|
||||||
: 'Save invite'}
|
|
||||||
</button>
|
|
||||||
{inviteEditingId != null && (
|
|
||||||
<button type="button" className="ghost-button" onClick={resetInviteEditor}>
|
|
||||||
Cancel edit
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<div className="meta profile-invite-hint">
|
|
||||||
Invite URL format: <code>{signupBaseUrl}?code=INVITECODE</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="profile-invites-list">
|
|
||||||
{invites.length === 0 ? (
|
|
||||||
<div className="status-banner">You haven’t created any invites yet.</div>
|
|
||||||
) : (
|
|
||||||
<div className="admin-list">
|
|
||||||
{invites.map((invite) => (
|
|
||||||
<div key={invite.id} className="admin-list-item">
|
|
||||||
<div className="admin-list-item-main">
|
|
||||||
<div className="admin-list-item-title-row">
|
|
||||||
<code className="invite-code">{invite.code}</code>
|
|
||||||
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
|
|
||||||
{invite.is_usable ? 'Usable' : 'Unavailable'}
|
|
||||||
</span>
|
|
||||||
<span className="small-pill is-muted">
|
|
||||||
{invite.remaining_uses == null ? 'Unlimited' : `${invite.remaining_uses} left`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{invite.label && <p className="admin-list-item-text">{invite.label}</p>}
|
|
||||||
{invite.description && (
|
|
||||||
<p className="admin-list-item-text admin-list-item-text--muted">
|
|
||||||
{invite.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="admin-meta-row">
|
|
||||||
<span>Recipient: {invite.recipient_email || 'Not set'}</span>
|
|
||||||
<span>
|
|
||||||
Uses: {invite.use_count}
|
|
||||||
{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}
|
|
||||||
</span>
|
|
||||||
<span>Expires: {formatDate(invite.expires_at)}</span>
|
|
||||||
<span>Created: {formatDate(invite.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="admin-inline-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ghost-button"
|
|
||||||
onClick={() => copyInviteLink(invite)}
|
|
||||||
>
|
|
||||||
Copy link
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ghost-button"
|
|
||||||
onClick={() => editInvite(invite)}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={() => deleteInvite(invite)}>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'security' && (
|
{activeTab === 'security' && (
|
||||||
<section className="profile-section profile-tab-panel">
|
<section className="profile-section profile-tab-panel">
|
||||||
<h2>Security</h2>
|
<h2>Security</h2>
|
||||||
|
|||||||
@@ -75,6 +75,9 @@ export default function HeaderIdentity() {
|
|||||||
<a href="/profile" onClick={() => setOpen(false)}>
|
<a href="/profile" onClick={() => setOpen(false)}>
|
||||||
My profile
|
My profile
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/profile/invites" onClick={() => setOpen(false)}>
|
||||||
|
My invites
|
||||||
|
</a>
|
||||||
<a href="/changelog" onClick={() => setOpen(false)}>
|
<a href="/changelog" onClick={() => setOpen(false)}>
|
||||||
Changelog
|
Changelog
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "magent-frontend",
|
"name": "magent-frontend",
|
||||||
"version": "0103262251",
|
"version": "0203261511",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "magent-frontend",
|
"name": "magent-frontend",
|
||||||
"version": "0103262251",
|
"version": "0203261511",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "magent-frontend",
|
"name": "magent-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0103262251",
|
"version": "0203261511",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
|
|||||||
Reference in New Issue
Block a user