Add team roles and content management updates

This commit is contained in:
AzuTear
2026-06-25 19:52:46 +02:00
parent 54293f4c45
commit 693769e5a8
126 changed files with 6966 additions and 738 deletions
+20 -1
View File
@@ -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) =>
+21 -1
View File
@@ -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: () =>
+201
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
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)
}