Add team roles and content management updates
This commit is contained in:
@@ -9,17 +9,22 @@ import type {
|
||||
AdminSeasonDetailResponse,
|
||||
AdminSeasonListItem,
|
||||
AdminSiteSettingsResponse,
|
||||
AdminTeamResponse,
|
||||
ApproveNominationPayload,
|
||||
BulkResolveRiskFlagsPayload,
|
||||
CreateTeamMemberPayload,
|
||||
CreateSeasonPayload,
|
||||
RejectNominationPayload,
|
||||
ResolveRiskFlagPayload,
|
||||
SetAwardResultPayload,
|
||||
TeamMemberPasswordResponse,
|
||||
UpdateClipStatusPayload,
|
||||
UpdateOperationalSettingsPayload,
|
||||
UpdateRiskRulesPayload,
|
||||
UpdateSeasonPayload,
|
||||
UpdateSiteSettingsPayload,
|
||||
UpdateTeamMemberPayload,
|
||||
UpdateTeamRolesPayload,
|
||||
UpsertCandidatePayload,
|
||||
UpsertCategoryPayload,
|
||||
} from '../../types/awards'
|
||||
@@ -83,6 +88,20 @@ export const adminApi = {
|
||||
getAdminSiteSettings: () => requestJson<AdminSiteSettingsResponse>('/api/admin/site-settings'),
|
||||
getAdminOperationalSettings: () =>
|
||||
requestJson<AdminOperationalSettingsResponse>('/api/admin/operational-settings'),
|
||||
getAdminTeam: () => requestJson<AdminTeamResponse>('/api/admin/team'),
|
||||
createAdminTeamMember: (payload: CreateTeamMemberPayload) =>
|
||||
requestJson<TeamMemberPasswordResponse>('/api/admin/team/members', jsonRequest('POST', payload)),
|
||||
updateAdminTeamMember: (memberId: number, payload: UpdateTeamMemberPayload) =>
|
||||
requestJson<{ saved: boolean; member: AdminTeamResponse['members'][number] }>(
|
||||
`/api/admin/team/members/${memberId}`,
|
||||
jsonRequest('PUT', payload),
|
||||
),
|
||||
resetAdminTeamMemberPassword: (memberId: number) =>
|
||||
requestJson<TeamMemberPasswordResponse>(`/api/admin/team/members/${memberId}/reset-password`, jsonRequest('POST', {})),
|
||||
deleteAdminTeamMember: (memberId: number) =>
|
||||
requestJson<{ deleted: boolean; memberId: number }>(`/api/admin/team/members/${memberId}`, { method: 'DELETE' }),
|
||||
updateAdminTeamRoles: (payload: UpdateTeamRolesPayload) =>
|
||||
requestJson<{ saved: boolean; roles: AdminTeamResponse['roles'] }>('/api/admin/team/roles', jsonRequest('PUT', payload)),
|
||||
createAdminSeason: (payload: CreateSeasonPayload) =>
|
||||
requestJson<{ saved: boolean; seasonId: number }>('/api/admin/seasons', jsonRequest('POST', payload)),
|
||||
updateAdminSeason: (seasonId: number, payload: UpdateSeasonPayload) =>
|
||||
@@ -94,7 +113,7 @@ export const adminApi = {
|
||||
updateAdminSiteSettings: (payload: UpdateSiteSettingsPayload) =>
|
||||
requestJson<{ saved: boolean }>('/api/admin/site-settings', jsonRequest('PUT', payload)),
|
||||
updateAdminOperationalSettings: (payload: UpdateOperationalSettingsPayload) =>
|
||||
requestJson<{ saved: boolean; demoLoginPasswordSet: boolean }>('/api/admin/operational-settings', jsonRequest('PUT', payload)),
|
||||
requestJson<{ saved: boolean; demoLoginPasswordSet: boolean; twitchClientSecretSet: boolean }>('/api/admin/operational-settings', jsonRequest('PUT', payload)),
|
||||
createAdminCategory: (seasonId: number, payload: UpsertCategoryPayload) =>
|
||||
requestJson<{ saved: boolean; categoryId: number }>(`/api/admin/seasons/${seasonId}/categories`, jsonRequest('POST', payload)),
|
||||
updateAdminCategory: (categoryId: number, payload: UpsertCategoryPayload) =>
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import type { AuthSession, DemoLoginPayload, LoginPayload } from '../../types/awards'
|
||||
import type {
|
||||
AuthSession,
|
||||
BindTeamTwitchPayload,
|
||||
ChangePasswordPayload,
|
||||
DemoLoginPayload,
|
||||
LoginPayload,
|
||||
TeamLoginPayload,
|
||||
TeamTwitchLoginPayload,
|
||||
TwitchAuthorizePayload,
|
||||
TwitchAuthorizeResponse,
|
||||
} from '../../types/awards'
|
||||
import { requestJson } from '../http'
|
||||
import { jsonRequest } from './requestOptions'
|
||||
|
||||
@@ -8,6 +18,16 @@ export const authApi = {
|
||||
requestJson<AuthSession>('/api/auth/dev-login', jsonRequest('POST', payload)),
|
||||
demoLogin: (payload: DemoLoginPayload) =>
|
||||
requestJson<AuthSession>('/api/auth/demo-login', jsonRequest('POST', payload)),
|
||||
teamLogin: (payload: TeamLoginPayload) =>
|
||||
requestJson<AuthSession>('/api/auth/team-login', jsonRequest('POST', payload)),
|
||||
teamTwitchLogin: (payload: TeamTwitchLoginPayload) =>
|
||||
requestJson<AuthSession>('/api/auth/team-twitch-login', jsonRequest('POST', payload)),
|
||||
changePassword: (payload: ChangePasswordPayload) =>
|
||||
requestJson<AuthSession>('/api/auth/password/change', jsonRequest('POST', payload)),
|
||||
bindTeamTwitch: (payload: BindTeamTwitchPayload) =>
|
||||
requestJson<AuthSession>('/api/auth/team/twitch-binding', jsonRequest('POST', payload)),
|
||||
startTwitchAuthorization: (payload: TwitchAuthorizePayload) =>
|
||||
requestJson<TwitchAuthorizeResponse>('/api/auth/twitch/authorize', jsonRequest('POST', payload)),
|
||||
logout: () =>
|
||||
requestJson<{ loggedOut: boolean }>('/api/auth/logout', jsonRequest('POST', {})),
|
||||
deleteMyData: () =>
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
const allowedTags = new Set([
|
||||
'A',
|
||||
'B',
|
||||
'BR',
|
||||
'DIV',
|
||||
'EM',
|
||||
'FONT',
|
||||
'H2',
|
||||
'H3',
|
||||
'H4',
|
||||
'I',
|
||||
'LI',
|
||||
'OL',
|
||||
'P',
|
||||
'SPAN',
|
||||
'STRONG',
|
||||
'U',
|
||||
'UL',
|
||||
])
|
||||
|
||||
const allowedStyleProperties = new Set([
|
||||
'font-family',
|
||||
'font-size',
|
||||
'font-style',
|
||||
'font-weight',
|
||||
'text-align',
|
||||
'text-decoration',
|
||||
])
|
||||
|
||||
const dangerousTags = new Set(['IFRAME', 'OBJECT', 'SCRIPT', 'STYLE', 'TEMPLATE'])
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function hasHtmlMarkup(value: string) {
|
||||
return /<\/?[a-z][\s\S]*>/i.test(value)
|
||||
}
|
||||
|
||||
function normalizePlainText(value: string) {
|
||||
return value
|
||||
.split(/\n{2,}/)
|
||||
.map((block) => block.trim())
|
||||
.filter(Boolean)
|
||||
.map((block) => `<p>${escapeHtml(block).replace(/\n/g, '<br>')}</p>`)
|
||||
.join('')
|
||||
}
|
||||
|
||||
function isSafeStyleValue(property: string, value: string) {
|
||||
const normalizedValue = value.trim()
|
||||
if (!normalizedValue || /url\s*\(|expression\s*\(|javascript:/i.test(normalizedValue)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (property === 'text-align') {
|
||||
return /^(left|center|right|justify)$/i.test(normalizedValue)
|
||||
}
|
||||
|
||||
if (property === 'font-size') {
|
||||
return /^([1-4](\.\d)?rem|[1-3]?\dpx|[1-2]?\dpt|small|medium|large|x-large)$/i.test(normalizedValue)
|
||||
}
|
||||
|
||||
if (property === 'font-family') {
|
||||
return /^[a-z0-9\s"',.-]+$/i.test(normalizedValue)
|
||||
}
|
||||
|
||||
if (property === 'font-weight') {
|
||||
return /^(normal|bold|[1-9]00)$/i.test(normalizedValue)
|
||||
}
|
||||
|
||||
if (property === 'font-style') {
|
||||
return /^(normal|italic)$/i.test(normalizedValue)
|
||||
}
|
||||
|
||||
return /^(none|underline|line-through)$/i.test(normalizedValue)
|
||||
}
|
||||
|
||||
function sanitizeStyle(style: string) {
|
||||
return style
|
||||
.split(';')
|
||||
.map((declaration) => declaration.trim())
|
||||
.filter(Boolean)
|
||||
.map((declaration) => {
|
||||
const [property, ...valueParts] = declaration.split(':')
|
||||
const normalizedProperty = property?.trim().toLowerCase()
|
||||
const value = valueParts.join(':').trim()
|
||||
|
||||
if (!allowedStyleProperties.has(normalizedProperty) || !isSafeStyleValue(normalizedProperty, value)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return `${normalizedProperty}: ${value}`
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('; ')
|
||||
}
|
||||
|
||||
function unwrapElement(element: Element) {
|
||||
const parent = element.parentNode
|
||||
if (!parent) {
|
||||
return
|
||||
}
|
||||
|
||||
while (element.firstChild) {
|
||||
parent.insertBefore(element.firstChild, element)
|
||||
}
|
||||
parent.removeChild(element)
|
||||
}
|
||||
|
||||
function sanitizeElement(element: Element) {
|
||||
if (dangerousTags.has(element.tagName)) {
|
||||
element.remove()
|
||||
return
|
||||
}
|
||||
|
||||
if (!allowedTags.has(element.tagName)) {
|
||||
unwrapElement(element)
|
||||
return
|
||||
}
|
||||
|
||||
for (const attribute of Array.from(element.attributes)) {
|
||||
const name = attribute.name.toLowerCase()
|
||||
const value = attribute.value
|
||||
|
||||
if (name === 'style') {
|
||||
const safeStyle = sanitizeStyle(value)
|
||||
if (safeStyle) {
|
||||
element.setAttribute('style', safeStyle)
|
||||
} else {
|
||||
element.removeAttribute(attribute.name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (element.tagName === 'A' && name === 'href' && /^(https?:|mailto:)/i.test(value)) {
|
||||
element.setAttribute('href', value)
|
||||
element.setAttribute('rel', 'noopener noreferrer')
|
||||
continue
|
||||
}
|
||||
|
||||
if (element.tagName === 'FONT' && ['face', 'size'].includes(name) && /^[a-z0-9\s"',.-]+$/i.test(value)) {
|
||||
continue
|
||||
}
|
||||
|
||||
element.removeAttribute(attribute.name)
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizePrivacyHtml(html: string) {
|
||||
if (typeof document === 'undefined') {
|
||||
return hasHtmlMarkup(html) ? escapeHtml(stripPrivacyHtml(html)) : normalizePlainText(html)
|
||||
}
|
||||
|
||||
const template = document.createElement('template')
|
||||
template.innerHTML = html
|
||||
|
||||
const walker = document.createTreeWalker(template.content, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT)
|
||||
const nodes: Node[] = []
|
||||
while (walker.nextNode()) {
|
||||
nodes.push(walker.currentNode)
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.nodeType === Node.COMMENT_NODE) {
|
||||
node.parentNode?.removeChild(node)
|
||||
continue
|
||||
}
|
||||
|
||||
sanitizeElement(node as Element)
|
||||
}
|
||||
|
||||
return template.innerHTML.trim()
|
||||
}
|
||||
|
||||
export function stripPrivacyHtml(value: string) {
|
||||
if (typeof document === 'undefined') {
|
||||
return value.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
const template = document.createElement('template')
|
||||
template.innerHTML = value
|
||||
return (template.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
export function privacyContentToHtml(value: string) {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return hasHtmlMarkup(trimmed) ? sanitizePrivacyHtml(trimmed) : normalizePlainText(trimmed)
|
||||
}
|
||||
|
||||
export function privacyContentForStorage(value: string) {
|
||||
return privacyContentToHtml(value)
|
||||
}
|
||||
Reference in New Issue
Block a user