Compare commits
2 Commits
ab27ebfadf
...
dev-2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 619708cae0 | |||
| af67c888c6 |
@@ -29,9 +29,46 @@ const SECTION_LABELS: Record<string, string> = {
|
|||||||
qbittorrent: 'qBittorrent',
|
qbittorrent: 'qBittorrent',
|
||||||
log: 'Activity log',
|
log: 'Activity log',
|
||||||
requests: 'Request syncing',
|
requests: 'Request syncing',
|
||||||
|
invites: 'Invites',
|
||||||
|
password: 'Password rules',
|
||||||
|
captcha: 'Captcha',
|
||||||
|
smtp: 'Email (SMTP)',
|
||||||
|
notify: 'Notifications',
|
||||||
|
expiry: 'Account expiry',
|
||||||
}
|
}
|
||||||
|
|
||||||
const BOOL_SETTINGS = new Set(['jellyfin_sync_to_arr'])
|
const BOOL_SETTINGS = new Set([
|
||||||
|
'jellyfin_sync_to_arr',
|
||||||
|
'invites_enabled',
|
||||||
|
'invites_require_captcha',
|
||||||
|
'signup_allow_referrals',
|
||||||
|
'password_require_upper',
|
||||||
|
'password_require_lower',
|
||||||
|
'password_require_number',
|
||||||
|
'password_require_symbol',
|
||||||
|
'password_reset_enabled',
|
||||||
|
'smtp_tls',
|
||||||
|
'smtp_starttls',
|
||||||
|
'notify_email_enabled',
|
||||||
|
'notify_discord_enabled',
|
||||||
|
'notify_telegram_enabled',
|
||||||
|
'notify_matrix_enabled',
|
||||||
|
'notify_pushover_enabled',
|
||||||
|
'notify_pushbullet_enabled',
|
||||||
|
'notify_gotify_enabled',
|
||||||
|
'notify_ntfy_enabled',
|
||||||
|
'jellyseerr_sync_users',
|
||||||
|
])
|
||||||
|
const NUMBER_SETTINGS = new Set([
|
||||||
|
'invite_default_profile_id',
|
||||||
|
'referral_default_uses',
|
||||||
|
'password_min_length',
|
||||||
|
'smtp_port',
|
||||||
|
'expiry_default_days',
|
||||||
|
'expiry_warning_days',
|
||||||
|
'expiry_check_interval_minutes',
|
||||||
|
'jellyseerr_sync_interval_minutes',
|
||||||
|
])
|
||||||
|
|
||||||
const SECTION_DESCRIPTIONS: Record<string, string> = {
|
const SECTION_DESCRIPTIONS: Record<string, string> = {
|
||||||
jellyseerr: 'Connect the request system where users submit content.',
|
jellyseerr: 'Connect the request system where users submit content.',
|
||||||
@@ -44,6 +81,12 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
|
|||||||
qbittorrent: 'Downloader connection settings.',
|
qbittorrent: 'Downloader connection settings.',
|
||||||
requests: 'Sync and refresh cadence for requests.',
|
requests: 'Sync and refresh cadence for requests.',
|
||||||
log: 'Activity log for troubleshooting.',
|
log: 'Activity log for troubleshooting.',
|
||||||
|
invites: 'Invite-only sign-ups and default rules.',
|
||||||
|
password: 'Set global password rules and local reset settings.',
|
||||||
|
captcha: 'Choose and configure captcha providers.',
|
||||||
|
smtp: 'Email delivery settings for password resets and notices.',
|
||||||
|
notify: 'Where system messages should be sent.',
|
||||||
|
expiry: 'Handle account expiry and automated actions.',
|
||||||
}
|
}
|
||||||
|
|
||||||
const SETTINGS_SECTION_MAP: Record<string, string | null> = {
|
const SETTINGS_SECTION_MAP: Record<string, string | null> = {
|
||||||
@@ -55,6 +98,12 @@ const SETTINGS_SECTION_MAP: Record<string, string | null> = {
|
|||||||
prowlarr: 'prowlarr',
|
prowlarr: 'prowlarr',
|
||||||
qbittorrent: 'qbittorrent',
|
qbittorrent: 'qbittorrent',
|
||||||
requests: 'requests',
|
requests: 'requests',
|
||||||
|
invites: 'invites',
|
||||||
|
password: 'password',
|
||||||
|
captcha: 'captcha',
|
||||||
|
smtp: 'smtp',
|
||||||
|
notifications: 'notify',
|
||||||
|
expiry: 'expiry',
|
||||||
cache: null,
|
cache: null,
|
||||||
logs: 'log',
|
logs: 'log',
|
||||||
maintenance: null,
|
maintenance: null,
|
||||||
@@ -78,6 +127,59 @@ const labelFromKey = (key: string) =>
|
|||||||
.replace('jellyfin public url', 'Jellyfin public URL')
|
.replace('jellyfin public url', 'Jellyfin public URL')
|
||||||
.replace('jellyfin sync to arr', 'Sync Jellyfin to Sonarr/Radarr')
|
.replace('jellyfin sync to arr', 'Sync Jellyfin to Sonarr/Radarr')
|
||||||
.replace('artwork cache mode', 'Artwork cache mode')
|
.replace('artwork cache mode', 'Artwork cache mode')
|
||||||
|
.replace('invites enabled', 'Allow invite sign-ups')
|
||||||
|
.replace('invites require captcha', 'Require captcha for invite sign-ups')
|
||||||
|
.replace('invite default profile id', 'Default invite profile')
|
||||||
|
.replace('signup allow referrals', 'Allow users to create referral invites')
|
||||||
|
.replace('referral default uses', 'Default referral invite uses')
|
||||||
|
.replace('password min length', 'Minimum password length')
|
||||||
|
.replace('password require upper', 'Require uppercase letters')
|
||||||
|
.replace('password require lower', 'Require lowercase letters')
|
||||||
|
.replace('password require number', 'Require numbers')
|
||||||
|
.replace('password require symbol', 'Require symbols')
|
||||||
|
.replace('password reset enabled', 'Allow password reset emails')
|
||||||
|
.replace('captcha provider', 'Captcha provider')
|
||||||
|
.replace('hcaptcha site key', 'hCaptcha site key')
|
||||||
|
.replace('hcaptcha secret key', 'hCaptcha secret key')
|
||||||
|
.replace('recaptcha site key', 'reCAPTCHA site key')
|
||||||
|
.replace('recaptcha secret key', 'reCAPTCHA secret key')
|
||||||
|
.replace('turnstile site key', 'Turnstile site key')
|
||||||
|
.replace('turnstile secret key', 'Turnstile secret key')
|
||||||
|
.replace('smtp host', 'SMTP host')
|
||||||
|
.replace('smtp port', 'SMTP port')
|
||||||
|
.replace('smtp user', 'SMTP username')
|
||||||
|
.replace('smtp password', 'SMTP password')
|
||||||
|
.replace('smtp from', 'SMTP from address')
|
||||||
|
.replace('smtp tls', 'Use TLS (SMTPS)')
|
||||||
|
.replace('smtp starttls', 'Use STARTTLS')
|
||||||
|
.replace('notify email enabled', 'Send emails')
|
||||||
|
.replace('notify discord enabled', 'Send Discord alerts')
|
||||||
|
.replace('notify telegram enabled', 'Send Telegram alerts')
|
||||||
|
.replace('notify matrix enabled', 'Send Matrix alerts')
|
||||||
|
.replace('notify pushover enabled', 'Send Pushover alerts')
|
||||||
|
.replace('notify pushbullet enabled', 'Send Pushbullet alerts')
|
||||||
|
.replace('notify gotify enabled', 'Send Gotify alerts')
|
||||||
|
.replace('notify ntfy enabled', 'Send ntfy alerts')
|
||||||
|
.replace('telegram bot token', 'Telegram bot token')
|
||||||
|
.replace('telegram chat id', 'Telegram chat ID')
|
||||||
|
.replace('matrix homeserver', 'Matrix homeserver')
|
||||||
|
.replace('matrix user', 'Matrix username')
|
||||||
|
.replace('matrix password', 'Matrix password')
|
||||||
|
.replace('matrix access token', 'Matrix access token')
|
||||||
|
.replace('matrix room id', 'Matrix room ID')
|
||||||
|
.replace('pushover token', 'Pushover app token')
|
||||||
|
.replace('pushover user key', 'Pushover user key')
|
||||||
|
.replace('pushbullet token', 'Pushbullet access token')
|
||||||
|
.replace('gotify url', 'Gotify URL')
|
||||||
|
.replace('gotify token', 'Gotify token')
|
||||||
|
.replace('ntfy url', 'ntfy URL')
|
||||||
|
.replace('ntfy topic', 'ntfy topic')
|
||||||
|
.replace('expiry default days', 'Default account expiry (days)')
|
||||||
|
.replace('expiry default action', 'Expiry action')
|
||||||
|
.replace('expiry warning days', 'Warn users this many days before expiry')
|
||||||
|
.replace('expiry check interval minutes', 'Expiry check interval (minutes)')
|
||||||
|
.replace('jellyseerr sync users', 'Sync Jellyseerr users into Magent')
|
||||||
|
.replace('jellyseerr sync interval minutes', 'Jellyseerr sync interval (minutes)')
|
||||||
|
|
||||||
type SettingsPageProps = {
|
type SettingsPageProps = {
|
||||||
section: string
|
section: string
|
||||||
@@ -106,6 +208,31 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
const [artworkPrefetch, setArtworkPrefetch] = useState<any | null>(null)
|
const [artworkPrefetch, setArtworkPrefetch] = useState<any | null>(null)
|
||||||
const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null)
|
const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null)
|
||||||
const [maintenanceBusy, setMaintenanceBusy] = useState(false)
|
const [maintenanceBusy, setMaintenanceBusy] = useState(false)
|
||||||
|
const [invites, setInvites] = useState<any[]>([])
|
||||||
|
const [inviteProfiles, setInviteProfiles] = useState<any[]>([])
|
||||||
|
const [inviteStatus, setInviteStatus] = useState<string | null>(null)
|
||||||
|
const [inviteForm, setInviteForm] = useState({
|
||||||
|
profile_id: '',
|
||||||
|
max_uses: '1',
|
||||||
|
require_captcha: false,
|
||||||
|
allow_referrals: false,
|
||||||
|
})
|
||||||
|
const [profileForm, setProfileForm] = useState({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
max_uses: '',
|
||||||
|
require_captcha: false,
|
||||||
|
allow_referrals: false,
|
||||||
|
referral_uses: '',
|
||||||
|
user_expiry_action: 'disable',
|
||||||
|
})
|
||||||
|
const [inviteExpiry, setInviteExpiry] = useState({ unit: 'days', value: '7' })
|
||||||
|
const [profileExpiry, setProfileExpiry] = useState({ unit: 'days', value: '' })
|
||||||
|
const [profileUserExpiry, setProfileUserExpiry] = useState({ unit: 'days', value: '' })
|
||||||
|
const [announcementSubject, setAnnouncementSubject] = useState('')
|
||||||
|
const [announcementBody, setAnnouncementBody] = useState('')
|
||||||
|
const [announcementChannels, setAnnouncementChannels] = useState<string[]>(['discord'])
|
||||||
|
const [announcementStatus, setAnnouncementStatus] = useState<string | null>(null)
|
||||||
|
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
const baseUrl = getApiBase()
|
const baseUrl = getApiBase()
|
||||||
@@ -198,6 +325,10 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
if (section === 'artwork') {
|
if (section === 'artwork') {
|
||||||
await loadArtworkPrefetchStatus()
|
await loadArtworkPrefetchStatus()
|
||||||
}
|
}
|
||||||
|
if (section === 'invites') {
|
||||||
|
await loadInviteProfiles()
|
||||||
|
await loadInvites()
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
setStatus('Could not load admin settings.')
|
setStatus('Could not load admin settings.')
|
||||||
@@ -251,6 +382,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
const showRequestsExtras = section === 'requests'
|
const showRequestsExtras = section === 'requests'
|
||||||
const showArtworkExtras = section === 'artwork'
|
const showArtworkExtras = section === 'artwork'
|
||||||
const showCacheExtras = section === 'cache'
|
const showCacheExtras = section === 'cache'
|
||||||
|
const showInviteExtras = section === 'invites'
|
||||||
|
const showNotificationExtras = section === 'notifications'
|
||||||
const shouldRenderSection = (sectionGroup: { key: string; items?: AdminSetting[] }) => {
|
const shouldRenderSection = (sectionGroup: { key: string; items?: AdminSetting[] }) => {
|
||||||
if (sectionGroup.items && sectionGroup.items.length > 0) return true
|
if (sectionGroup.items && sectionGroup.items.length > 0) return true
|
||||||
if (showArtworkExtras && sectionGroup.key === 'artwork') return true
|
if (showArtworkExtras && sectionGroup.key === 'artwork') return true
|
||||||
@@ -289,6 +422,59 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
requests_data_source: 'Pick where Magent should read requests from.',
|
requests_data_source: 'Pick where Magent should read requests from.',
|
||||||
log_level: 'How much detail is written to the activity log.',
|
log_level: 'How much detail is written to the activity log.',
|
||||||
log_file: 'Where the activity log is stored.',
|
log_file: 'Where the activity log is stored.',
|
||||||
|
invites_enabled: 'Allow new users to register with invite links.',
|
||||||
|
invites_require_captcha: 'Require a captcha on invite sign-up.',
|
||||||
|
invite_default_profile_id: 'Default invite profile applied when creating invites.',
|
||||||
|
signup_allow_referrals: 'Let users create referral invites for friends/family.',
|
||||||
|
referral_default_uses: 'Default number of uses for referral invites.',
|
||||||
|
password_min_length: 'Minimum length required for passwords.',
|
||||||
|
password_require_upper: 'Require uppercase letters in passwords.',
|
||||||
|
password_require_lower: 'Require lowercase letters in passwords.',
|
||||||
|
password_require_number: 'Require numbers in passwords.',
|
||||||
|
password_require_symbol: 'Require symbols in passwords.',
|
||||||
|
password_reset_enabled: 'Allow local users to request password reset emails.',
|
||||||
|
captcha_provider: 'Choose which captcha provider to use for sign-up.',
|
||||||
|
hcaptcha_site_key: 'Public hCaptcha site key.',
|
||||||
|
hcaptcha_secret_key: 'Secret hCaptcha key.',
|
||||||
|
recaptcha_site_key: 'Public reCAPTCHA site key.',
|
||||||
|
recaptcha_secret_key: 'Secret reCAPTCHA key.',
|
||||||
|
turnstile_site_key: 'Public Turnstile site key.',
|
||||||
|
turnstile_secret_key: 'Secret Turnstile key.',
|
||||||
|
smtp_host: 'SMTP server hostname.',
|
||||||
|
smtp_port: 'SMTP server port.',
|
||||||
|
smtp_user: 'SMTP username (optional).',
|
||||||
|
smtp_password: 'SMTP password (optional).',
|
||||||
|
smtp_from: 'Default "from" address for system emails.',
|
||||||
|
smtp_tls: 'Use TLS for SMTP (SMTPS).',
|
||||||
|
smtp_starttls: 'Use STARTTLS for SMTP.',
|
||||||
|
notify_email_enabled: 'Send notices by email.',
|
||||||
|
notify_discord_enabled: 'Send notices to Discord.',
|
||||||
|
notify_telegram_enabled: 'Send notices to Telegram.',
|
||||||
|
notify_matrix_enabled: 'Send notices to Matrix.',
|
||||||
|
notify_pushover_enabled: 'Send notices to Pushover.',
|
||||||
|
notify_pushbullet_enabled: 'Send notices to Pushbullet.',
|
||||||
|
notify_gotify_enabled: 'Send notices to Gotify.',
|
||||||
|
notify_ntfy_enabled: 'Send notices to ntfy.',
|
||||||
|
telegram_bot_token: 'Telegram bot token for sending notices.',
|
||||||
|
telegram_chat_id: 'Default Telegram chat ID.',
|
||||||
|
matrix_homeserver: 'Matrix server base URL.',
|
||||||
|
matrix_user: 'Matrix bot username.',
|
||||||
|
matrix_password: 'Matrix bot password.',
|
||||||
|
matrix_access_token: 'Matrix access token.',
|
||||||
|
matrix_room_id: 'Matrix room ID for announcements.',
|
||||||
|
pushover_token: 'Pushover application token.',
|
||||||
|
pushover_user_key: 'Pushover user key.',
|
||||||
|
pushbullet_token: 'Pushbullet access token.',
|
||||||
|
gotify_url: 'Gotify server URL.',
|
||||||
|
gotify_token: 'Gotify app token.',
|
||||||
|
ntfy_url: 'ntfy server URL.',
|
||||||
|
ntfy_topic: 'ntfy topic for notifications.',
|
||||||
|
expiry_default_days: 'Default number of days before accounts expire.',
|
||||||
|
expiry_default_action: 'Action to take when an account expires.',
|
||||||
|
expiry_warning_days: 'How many days before expiry to warn the user.',
|
||||||
|
expiry_check_interval_minutes: 'How often expiry checks run.',
|
||||||
|
jellyseerr_sync_users: 'Sync Jellyseerr users into Magent.',
|
||||||
|
jellyseerr_sync_interval_minutes: 'How often Jellyseerr user sync runs.',
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildSelectOptions = (
|
const buildSelectOptions = (
|
||||||
@@ -312,6 +498,62 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
return list
|
return list
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const durationOptions = {
|
||||||
|
minutes: Array.from({ length: 60 }, (_, i) => String(i + 1)),
|
||||||
|
hours: Array.from({ length: 24 }, (_, i) => String(i + 1)),
|
||||||
|
days: Array.from({ length: 365 }, (_, i) => String(i + 1)),
|
||||||
|
}
|
||||||
|
|
||||||
|
const toDays = (choice: { unit: string; value: string }) => {
|
||||||
|
if (!choice || choice.unit === 'unlimited') return null
|
||||||
|
const amount = Number(choice.value)
|
||||||
|
if (!amount || amount <= 0) return null
|
||||||
|
if (choice.unit === 'minutes') return amount / 1440
|
||||||
|
if (choice.unit === 'hours') return amount / 24
|
||||||
|
if (choice.unit === 'months') return amount * 30
|
||||||
|
return amount
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderDurationControl = (
|
||||||
|
label: string,
|
||||||
|
choice: { unit: string; value: string },
|
||||||
|
setChoice: (next: { unit: string; value: string }) => void
|
||||||
|
) => (
|
||||||
|
<label>
|
||||||
|
{label}
|
||||||
|
<div className="duration-row">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
placeholder="Amount"
|
||||||
|
value={choice.value}
|
||||||
|
onChange={(event) =>
|
||||||
|
setChoice({
|
||||||
|
unit: choice.unit,
|
||||||
|
value: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={choice.unit === 'unlimited'}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={choice.unit}
|
||||||
|
onChange={(event) =>
|
||||||
|
setChoice({
|
||||||
|
unit: event.target.value,
|
||||||
|
value: event.target.value === 'unlimited' ? '' : choice.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="minutes">Minutes</option>
|
||||||
|
<option value="hours">Hours</option>
|
||||||
|
<option value="days">Days</option>
|
||||||
|
<option value="months">Months</option>
|
||||||
|
<option value="unlimited">Unlimited</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
|
||||||
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
|
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setStatus(null)
|
setStatus(null)
|
||||||
@@ -422,6 +664,174 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadInviteProfiles = async () => {
|
||||||
|
try {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(`${baseUrl}/admin/invite-profiles`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Profiles unavailable')
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
setInviteProfiles(Array.isArray(data?.profiles) ? data.profiles : [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setInviteProfiles([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadInvites = async () => {
|
||||||
|
try {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(`${baseUrl}/admin/invites`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Invites unavailable')
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
setInvites(Array.isArray(data?.invites) ? data.invites : [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setInvites([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createInviteProfile = async () => {
|
||||||
|
setInviteStatus(null)
|
||||||
|
try {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(`${baseUrl}/admin/invite-profiles`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...profileForm,
|
||||||
|
max_uses: profileForm.max_uses ? Number(profileForm.max_uses) : null,
|
||||||
|
expires_in_days: toDays(profileExpiry),
|
||||||
|
referral_uses: profileForm.referral_uses ? Number(profileForm.referral_uses) : null,
|
||||||
|
user_expiry_days: toDays(profileUserExpiry),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text()
|
||||||
|
throw new Error(text || 'Profile creation failed')
|
||||||
|
}
|
||||||
|
setProfileForm({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
max_uses: '',
|
||||||
|
require_captcha: false,
|
||||||
|
allow_referrals: false,
|
||||||
|
referral_uses: '',
|
||||||
|
user_expiry_action: 'disable',
|
||||||
|
})
|
||||||
|
setProfileExpiry({ unit: 'days', value: '' })
|
||||||
|
setProfileUserExpiry({ unit: 'days', value: '' })
|
||||||
|
setInviteStatus('Invite profile created.')
|
||||||
|
await loadInviteProfiles()
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setInviteStatus('Could not create invite profile.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createInviteCode = async () => {
|
||||||
|
setInviteStatus(null)
|
||||||
|
try {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(`${baseUrl}/admin/invites`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
profile_id: inviteForm.profile_id ? Number(inviteForm.profile_id) : null,
|
||||||
|
max_uses: inviteForm.max_uses ? Number(inviteForm.max_uses) : null,
|
||||||
|
expires_in_days: toDays(inviteExpiry),
|
||||||
|
require_captcha: inviteForm.require_captcha,
|
||||||
|
allow_referrals: inviteForm.allow_referrals,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text()
|
||||||
|
throw new Error(text || 'Invite creation failed')
|
||||||
|
}
|
||||||
|
setInviteStatus('Invite created.')
|
||||||
|
await loadInvites()
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setInviteStatus('Could not create invite.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const disableInviteCode = async (code: string) => {
|
||||||
|
setInviteStatus(null)
|
||||||
|
try {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(`${baseUrl}/admin/invites/${encodeURIComponent(code)}/disable`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Disable failed')
|
||||||
|
}
|
||||||
|
setInviteStatus(`Invite ${code} disabled.`)
|
||||||
|
await loadInvites()
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setInviteStatus('Could not disable invite.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteInviteCode = async (code: string) => {
|
||||||
|
setInviteStatus(null)
|
||||||
|
try {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(
|
||||||
|
`${baseUrl}/admin/invites/${encodeURIComponent(code)}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Delete failed')
|
||||||
|
}
|
||||||
|
setInviteStatus(`Invite ${code} deleted.`)
|
||||||
|
await loadInvites()
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setInviteStatus('Could not delete invite.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleAnnouncementChannel = (channel: string) => {
|
||||||
|
setAnnouncementChannels((current) =>
|
||||||
|
current.includes(channel) ? current.filter((item) => item !== channel) : [...current, channel]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendAnnouncement = async () => {
|
||||||
|
setAnnouncementStatus(null)
|
||||||
|
if (!announcementSubject || !announcementBody) {
|
||||||
|
setAnnouncementStatus('Enter a subject and a message.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(`${baseUrl}/admin/announcements`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
subject: announcementSubject,
|
||||||
|
body: announcementBody,
|
||||||
|
channels: announcementChannels,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text()
|
||||||
|
throw new Error(text || 'Announcement failed')
|
||||||
|
}
|
||||||
|
setAnnouncementStatus('Announcement sent.')
|
||||||
|
setAnnouncementBody('')
|
||||||
|
setAnnouncementSubject('')
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setAnnouncementStatus('Could not send the announcement.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const prefetchArtwork = async () => {
|
const prefetchArtwork = async () => {
|
||||||
setArtworkPrefetchStatus(null)
|
setArtworkPrefetchStatus(null)
|
||||||
try {
|
try {
|
||||||
@@ -1085,6 +1495,83 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
</label>
|
</label>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (setting.key === 'captcha_provider') {
|
||||||
|
return (
|
||||||
|
<label key={setting.key} data-helper={helperText || undefined}>
|
||||||
|
<span className="label-row">
|
||||||
|
<span>{labelFromKey(setting.key)}</span>
|
||||||
|
<span className="meta">
|
||||||
|
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
name={setting.key}
|
||||||
|
value={value || 'none'}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFormValues((current) => ({
|
||||||
|
...current,
|
||||||
|
[setting.key]: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="none">No captcha</option>
|
||||||
|
<option value="hcaptcha">hCaptcha</option>
|
||||||
|
<option value="recaptcha">reCAPTCHA</option>
|
||||||
|
<option value="turnstile">Cloudflare Turnstile</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (setting.key === 'expiry_default_action') {
|
||||||
|
return (
|
||||||
|
<label key={setting.key} data-helper={helperText || undefined}>
|
||||||
|
<span className="label-row">
|
||||||
|
<span>{labelFromKey(setting.key)}</span>
|
||||||
|
<span className="meta">
|
||||||
|
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
name={setting.key}
|
||||||
|
value={value || 'disable'}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFormValues((current) => ({
|
||||||
|
...current,
|
||||||
|
[setting.key]: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="disable">Disable account</option>
|
||||||
|
<option value="delete">Delete account</option>
|
||||||
|
<option value="disable_then_delete">Disable, then delete</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (NUMBER_SETTINGS.has(setting.key)) {
|
||||||
|
return (
|
||||||
|
<label key={setting.key} data-helper={helperText || undefined}>
|
||||||
|
<span className="label-row">
|
||||||
|
<span>{labelFromKey(setting.key)}</span>
|
||||||
|
<span className="meta">
|
||||||
|
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
name={setting.key}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFormValues((current) => ({
|
||||||
|
...current,
|
||||||
|
[setting.key]: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<label key={setting.key} data-helper={helperText || undefined}>
|
<label key={setting.key} data-helper={helperText || undefined}>
|
||||||
<span className="label-row">
|
<span className="label-row">
|
||||||
@@ -1260,6 +1747,297 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
{showInviteExtras && (
|
||||||
|
<section className="admin-section" id="invites">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2>Invites</h2>
|
||||||
|
</div>
|
||||||
|
<div className="status-banner">
|
||||||
|
Create profiles for common rules, then issue invites in seconds.
|
||||||
|
</div>
|
||||||
|
{inviteStatus && <div className="status-banner">{inviteStatus}</div>}
|
||||||
|
<div className="invite-stack">
|
||||||
|
<div className="summary-card invite-card">
|
||||||
|
<h3>Create a profile</h3>
|
||||||
|
<p className="meta">Profiles save default rules for new invites.</p>
|
||||||
|
<div className="invite-grid">
|
||||||
|
<label>
|
||||||
|
Profile name
|
||||||
|
<input
|
||||||
|
value={profileForm.name}
|
||||||
|
onChange={(event) =>
|
||||||
|
setProfileForm((current) => ({ ...current, name: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Description
|
||||||
|
<input
|
||||||
|
value={profileForm.description}
|
||||||
|
onChange={(event) =>
|
||||||
|
setProfileForm((current) => ({ ...current, description: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Max uses
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={profileForm.max_uses}
|
||||||
|
onChange={(event) =>
|
||||||
|
setProfileForm((current) => ({ ...current, max_uses: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="field-help">Leave blank for unlimited.</span>
|
||||||
|
</label>
|
||||||
|
{renderDurationControl('Invite expires after', profileExpiry, setProfileExpiry)}
|
||||||
|
{renderDurationControl(
|
||||||
|
'User account expires after',
|
||||||
|
profileUserExpiry,
|
||||||
|
setProfileUserExpiry
|
||||||
|
)}
|
||||||
|
<label>
|
||||||
|
Expiry action
|
||||||
|
<select
|
||||||
|
value={profileForm.user_expiry_action}
|
||||||
|
onChange={(event) =>
|
||||||
|
setProfileForm((current) => ({
|
||||||
|
...current,
|
||||||
|
user_expiry_action: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="disable">Disable account</option>
|
||||||
|
<option value="delete">Delete account</option>
|
||||||
|
<option value="disable_then_delete">Disable, then delete</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Referral uses
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={profileForm.referral_uses}
|
||||||
|
onChange={(event) =>
|
||||||
|
setProfileForm((current) => ({ ...current, referral_uses: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="field-help">Leave blank for 1.</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="toggle-row">
|
||||||
|
<label className="toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={profileForm.require_captcha}
|
||||||
|
onChange={(event) =>
|
||||||
|
setProfileForm((current) => ({
|
||||||
|
...current,
|
||||||
|
require_captcha: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>Require captcha</span>
|
||||||
|
</label>
|
||||||
|
<span className="toggle-help">Adds a human check to sign-up.</span>
|
||||||
|
</div>
|
||||||
|
<div className="toggle-row">
|
||||||
|
<label className="toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={profileForm.allow_referrals}
|
||||||
|
onChange={(event) =>
|
||||||
|
setProfileForm((current) => ({
|
||||||
|
...current,
|
||||||
|
allow_referrals: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>Allow referrals</span>
|
||||||
|
</label>
|
||||||
|
<span className="toggle-help">Let users share their own invite.</span>
|
||||||
|
</div>
|
||||||
|
<div className="admin-actions">
|
||||||
|
<button type="button" onClick={createInviteProfile}>
|
||||||
|
Save profile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="summary-card invite-card">
|
||||||
|
<h3>Create an invite</h3>
|
||||||
|
<p className="meta">Pick a profile or customize a one-off invite.</p>
|
||||||
|
<div className="invite-grid">
|
||||||
|
<label>
|
||||||
|
Profile
|
||||||
|
<select
|
||||||
|
value={inviteForm.profile_id}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({ ...current, profile_id: event.target.value }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">No profile</option>
|
||||||
|
{inviteProfiles.map((profile) => (
|
||||||
|
<option key={profile.id} value={String(profile.id)}>
|
||||||
|
{profile.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Max uses
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={inviteForm.max_uses}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({ ...current, max_uses: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="field-help">Leave blank for unlimited.</span>
|
||||||
|
</label>
|
||||||
|
{renderDurationControl('Invite expires after', inviteExpiry, setInviteExpiry)}
|
||||||
|
</div>
|
||||||
|
<div className="toggle-row">
|
||||||
|
<label className="toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={inviteForm.require_captcha}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({
|
||||||
|
...current,
|
||||||
|
require_captcha: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>Require captcha</span>
|
||||||
|
</label>
|
||||||
|
<span className="toggle-help">Adds a human check to sign-up.</span>
|
||||||
|
</div>
|
||||||
|
<div className="toggle-row">
|
||||||
|
<label className="toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={inviteForm.allow_referrals}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({
|
||||||
|
...current,
|
||||||
|
allow_referrals: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>Allow referrals</span>
|
||||||
|
</label>
|
||||||
|
<span className="toggle-help">Lets this person create their own invite.</span>
|
||||||
|
</div>
|
||||||
|
<div className="admin-actions">
|
||||||
|
<button type="button" onClick={createInviteCode}>
|
||||||
|
Create invite
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ghost-button" onClick={loadInvites}>
|
||||||
|
Refresh invites
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cache-table">
|
||||||
|
<div className="cache-row cache-head">
|
||||||
|
<span>Code</span>
|
||||||
|
<span>Uses</span>
|
||||||
|
<span>Expires</span>
|
||||||
|
<span>Status</span>
|
||||||
|
<span>Actions</span>
|
||||||
|
</div>
|
||||||
|
{invites.length === 0 ? (
|
||||||
|
<div className="meta">No invites yet.</div>
|
||||||
|
) : (
|
||||||
|
invites.map((invite) => (
|
||||||
|
<div key={invite.code} className="cache-row">
|
||||||
|
<span>{invite.code}</span>
|
||||||
|
<span>
|
||||||
|
{invite.uses_count ?? 0}/{invite.max_uses ?? 'Unlimited'}
|
||||||
|
</span>
|
||||||
|
<span>{invite.expires_at || 'Never'}</span>
|
||||||
|
<span>{invite.disabled ? 'Disabled' : 'Active'}</span>
|
||||||
|
<span className="cache-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={() => disableInviteCode(invite.code)}
|
||||||
|
disabled={invite.disabled}
|
||||||
|
>
|
||||||
|
Disable
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger-button"
|
||||||
|
onClick={() => deleteInviteCode(invite.code)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{showNotificationExtras && (
|
||||||
|
<section className="admin-section" id="announcements">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2>Send an announcement</h2>
|
||||||
|
</div>
|
||||||
|
<div className="status-banner">
|
||||||
|
Send a message to all users through the channels you select.
|
||||||
|
</div>
|
||||||
|
{announcementStatus && <div className="status-banner">{announcementStatus}</div>}
|
||||||
|
<div className="admin-grid">
|
||||||
|
<label>
|
||||||
|
Subject
|
||||||
|
<input
|
||||||
|
value={announcementSubject}
|
||||||
|
onChange={(event) => setAnnouncementSubject(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Message
|
||||||
|
<textarea
|
||||||
|
rows={5}
|
||||||
|
value={announcementBody}
|
||||||
|
onChange={(event) => setAnnouncementBody(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="settings-checkbox-grid">
|
||||||
|
{[
|
||||||
|
{ id: 'email', label: 'Email' },
|
||||||
|
{ id: 'discord', label: 'Discord' },
|
||||||
|
{ id: 'telegram', label: 'Telegram' },
|
||||||
|
{ id: 'matrix', label: 'Matrix' },
|
||||||
|
{ id: 'pushover', label: 'Pushover' },
|
||||||
|
{ id: 'pushbullet', label: 'Pushbullet' },
|
||||||
|
{ id: 'gotify', label: 'Gotify' },
|
||||||
|
{ id: 'ntfy', label: 'ntfy' },
|
||||||
|
].map((channel) => (
|
||||||
|
<label key={channel.id} className="toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={announcementChannels.includes(channel.id)}
|
||||||
|
onChange={() => toggleAnnouncementChannel(channel.id)}
|
||||||
|
/>
|
||||||
|
<span>{channel.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-actions">
|
||||||
|
<button type="button" onClick={sendAnnouncement}>
|
||||||
|
Send announcement
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</AdminShell>
|
</AdminShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -659,6 +659,21 @@ button span {
|
|||||||
justify-items: end;
|
justify-items: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-bulk-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-bulk-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.toggle {
|
.toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -790,6 +805,11 @@ button span {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.captcha-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.ghost-button {
|
.ghost-button {
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
@@ -937,6 +957,103 @@ button span {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-checkbox-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(120px, 1fr) minmax(160px, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-row input,
|
||||||
|
.duration-row select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-card h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-card .admin-actions {
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-grid label {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-grid input,
|
||||||
|
.invite-grid select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-help {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-help {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-inline {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(140px, 1fr) minmax(180px, 260px);
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-stack .field-help {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-grid label {
|
.admin-grid label {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -1058,7 +1175,7 @@ button span {
|
|||||||
|
|
||||||
.cache-row {
|
.cache-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 90px minmax(0, 1.6fr) 120px 90px 180px;
|
grid-template-columns: minmax(140px, 1.4fr) 100px 140px 100px 180px;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -1066,6 +1183,7 @@ button span {
|
|||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cache-row span {
|
.cache-row span {
|
||||||
@@ -1082,6 +1200,13 @@ button span {
|
|||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cache-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.maintenance-grid {
|
.maintenance-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -1399,6 +1524,22 @@ button span {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.duration-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-inline {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user