security: harden data auth and deployment
This commit is contained in:
@@ -36,6 +36,7 @@ type Profile = {
|
||||
type Invite = {
|
||||
id: number
|
||||
code: string
|
||||
code_available?: boolean
|
||||
label?: string | null
|
||||
description?: string | null
|
||||
profile_id?: number | null
|
||||
@@ -501,17 +502,30 @@ export default function AdminInviteManagementPage() {
|
||||
}
|
||||
|
||||
const copyInviteLink = async (invite: Invite) => {
|
||||
const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}`
|
||||
try {
|
||||
let usableInvite = invite
|
||||
if (!invite.code_available) {
|
||||
const response = await authFetch(`${getApiBase()}/admin/invites/${invite.id}/rotate`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!response.ok) {
|
||||
if (handleAuthResponse(response)) return
|
||||
throw new Error((await response.text()) || 'Could not generate a replacement link.')
|
||||
}
|
||||
const data = await response.json()
|
||||
usableInvite = data.invite as Invite
|
||||
setInvites((current) => current.map((item) => item.id === invite.id ? usableInvite : item))
|
||||
}
|
||||
const url = `${signupBaseUrl}?code=${encodeURIComponent(usableInvite.code)}`
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(url)
|
||||
setStatus(`Copied invite link for ${invite.code}.`)
|
||||
setStatus(`Copied the invite link. Keep it safe; Magent will not display it again after this page reloads.`)
|
||||
} else {
|
||||
window.prompt('Copy invite link', url)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
window.prompt('Copy invite link', url)
|
||||
setError(err instanceof Error ? err.message : 'Could not generate or copy the invite link.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1666,7 +1680,7 @@ export default function AdminInviteManagementPage() {
|
||||
</div>
|
||||
<div className="admin-inline-actions">
|
||||
<button type="button" className="ghost-button" onClick={() => copyInviteLink(invite)}>
|
||||
Copy link
|
||||
{invite.code_available ? 'Copy link' : 'Generate replacement link'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -11,6 +11,7 @@ import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
|
||||
type ProfileInfo = { username: string; role: string; invite_management_enabled?: boolean }
|
||||
type OwnedInvite = {
|
||||
id: number; code: string; label?: string | null; description?: string | null
|
||||
code_available?: boolean
|
||||
recipient_email?: string | null; max_uses?: number | null; use_count: number
|
||||
remaining_uses?: number | null; enabled: boolean; expires_at?: string | null
|
||||
is_usable?: boolean; created_at?: string | null
|
||||
@@ -212,12 +213,22 @@ export default function ProfileInvitesPage() {
|
||||
}
|
||||
|
||||
const copyInviteLink = async (invite: OwnedInvite) => {
|
||||
const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}`
|
||||
try {
|
||||
let usableInvite = invite
|
||||
if (!invite.code_available) {
|
||||
const response = await authFetch(`${getApiBase()}/auth/profile/invites/${invite.id}/rotate`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!response.ok) throw new Error((await response.text()) || 'Could not generate a replacement link.')
|
||||
const data = await response.json()
|
||||
usableInvite = data.invite as OwnedInvite
|
||||
setInvites((current) => current.map((item) => item.id === invite.id ? usableInvite : item))
|
||||
}
|
||||
const url = `${signupBaseUrl}?code=${encodeURIComponent(usableInvite.code)}`
|
||||
await navigator.clipboard.writeText(url)
|
||||
setStatus(`Copied the link for ${invite.label || invite.code}.`)
|
||||
setStatus(`Copied the link for ${invite.label || usableInvite.code}. Keep it safe; Magent will not display it again after this page reloads.`)
|
||||
} catch {
|
||||
window.prompt('Copy invite link', url)
|
||||
setError('Could not generate or copy the invite link.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,7 +308,7 @@ export default function ProfileInvitesPage() {
|
||||
|
||||
<div className="profile-invites-list">
|
||||
<div className="invite-flow-heading"><div><span className="eyebrow">Your invites</span><h2>Created invites</h2><p className="lede">Copy, edit, disable, or remove invitations you have made.</p></div></div>
|
||||
{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"><strong>{invite.label || 'Unnamed invite'}</strong><code className="invite-code">{invite.code}</code><span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>{invite.is_usable ? 'Ready' : 'Unavailable'}</span></div>{invite.description && <p className="admin-list-item-text admin-list-item-text--muted">{invite.description}</p>}<div className="admin-meta-row"><span>Delivery: {invite.recipient_email || 'Manual link'}</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={() => void copyInviteLink(invite)}>Copy link</button><button type="button" className="ghost-button" onClick={() => editInvite(invite)}>Edit</button><button type="button" onClick={() => void deleteInvite(invite)}>Delete</button></div></div>)}</div>}
|
||||
{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"><strong>{invite.label || 'Unnamed invite'}</strong><code className="invite-code">{invite.code}</code><span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>{invite.is_usable ? 'Ready' : 'Unavailable'}</span></div>{invite.description && <p className="admin-list-item-text admin-list-item-text--muted">{invite.description}</p>}<div className="admin-meta-row"><span>Delivery: {invite.recipient_email || 'Manual link'}</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={() => void copyInviteLink(invite)}>{invite.code_available ? 'Copy link' : 'Generate replacement link'}</button><button type="button" className="ghost-button" onClick={() => editInvite(invite)}>Edit</button><button type="button" onClick={() => void deleteInvite(invite)}>Delete</button></div></div>)}</div>}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,27 @@ const backendUrl = process.env.BACKEND_INTERNAL_URL || 'http://backend:8000'
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
poweredByHeader: false,
|
||||
compress: true,
|
||||
experimental: { proxyTimeout: 180000 },
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/:path*',
|
||||
headers: [
|
||||
{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' },
|
||||
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||
{ key: 'X-Frame-Options', value: 'DENY' },
|
||||
{ key: 'Referrer-Policy', value: 'no-referrer' },
|
||||
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
||||
],
|
||||
},
|
||||
{
|
||||
source: '/login',
|
||||
headers: [{ key: 'Cache-Control', value: 'private, no-store, max-age=0' }],
|
||||
},
|
||||
]
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
|
||||
const developmentEval = process.env.NODE_ENV === 'development' ? " 'unsafe-eval'" : ''
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
"base-uri 'self'",
|
||||
"object-src 'none'",
|
||||
"frame-ancestors 'none'",
|
||||
"form-action 'self'",
|
||||
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${developmentEval}`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob: https:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self'",
|
||||
"worker-src 'self' blob:",
|
||||
"manifest-src 'self'",
|
||||
"upgrade-insecure-requests",
|
||||
].join('; ')
|
||||
|
||||
const requestHeaders = new Headers(request.headers)
|
||||
requestHeaders.set('x-nonce', nonce)
|
||||
requestHeaders.set('Content-Security-Policy', csp)
|
||||
const response = NextResponse.next({ request: { headers: requestHeaders } })
|
||||
response.headers.set('Content-Security-Policy', csp)
|
||||
return response
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
{
|
||||
source: '/((?!api|_next/static|_next/image|favicon.ico|branding/).*)',
|
||||
missing: [
|
||||
{ type: 'header', key: 'next-router-prefetch' },
|
||||
{ type: 'header', key: 'purpose', value: 'prefetch' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user