Enforce recipient-bound single-use invites and fix issue card layout; clean release tooling
Magent CI/CD / verify (push) Successful in 10m31s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped

This commit is contained in:
2026-09-07 19:59:51 +12:00
parent 13edcb8136
commit a3b5759708
19 changed files with 439 additions and 305 deletions
+39 -1
View File
@@ -3631,13 +3631,51 @@ textarea:focus {
}
.issue-portal-page .portal-item-list {
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: 8px;
max-height: 100%;
padding-right: 4px;
}
.issue-portal-page .portal-item-row {
padding: 11px;
display: block;
flex: 0 0 auto;
width: 100%;
height: auto;
min-height: min-content;
max-height: none;
padding: 14px;
white-space: normal;
text-align: left;
line-height: 1.45;
}
.issue-portal-page .portal-item-row-main {
display: flex;
flex-direction: column;
min-width: 0;
}
.issue-portal-page .portal-item-row-title strong {
flex-basis: 100%;
min-width: 0;
font-size: 0.95rem;
line-height: 1.4;
overflow-wrap: anywhere;
}
.issue-portal-page .portal-item-row-meta {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--ops-line-soft);
line-height: 1.5;
overflow-wrap: anywhere;
}
.issue-portal-page .portal-item-row-meta > span:last-child {
flex-basis: 100%;
}
.issue-portal-page .portal-item-row p {
+11 -4
View File
@@ -7,6 +7,7 @@ import { clearToken, getApiBase, setToken } from '../lib/auth'
type InviteInfo = {
code: string
email_bound?: boolean
label?: string | null
description?: string | null
enabled: boolean
@@ -38,14 +39,15 @@ function SignupPageContent() {
const [inviteLoading, setInviteLoading] = useState(false)
const [loading, setLoading] = useState(false)
const [username, setUsername] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<string | null>(null)
const canSubmit = useMemo(() => {
return Boolean(invite?.is_usable && username.trim() && password && !loading)
}, [invite, username, password, loading])
return Boolean(invite?.is_usable && (invite.email_bound || email.trim()) && username.trim() && password && !loading && !inviteLoading)
}, [invite, email, username, password, loading, inviteLoading])
const lookupInvite = async (code: string) => {
const trimmed = code.trim()
@@ -110,6 +112,7 @@ function SignupPageContent() {
body: JSON.stringify({
invite_code: inviteCode,
username: username.trim(),
...(!invite.email_bound ? { email: email.trim() } : {}),
password,
}),
})
@@ -120,7 +123,7 @@ function SignupPageContent() {
const data = await response.json()
if (data?.authenticated) {
setToken('cookie')
window.location.href = '/'
window.location.href = '/welcome'
return
}
throw new Error('Sign-up did not complete')
@@ -140,7 +143,7 @@ function SignupPageContent() {
<div className="invite-lookup-row">
<input
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)}
onChange={(e) => { setInviteCode(e.target.value); setInvite(null); setEmail('') }}
placeholder="Paste your invite code"
autoCapitalize="characters"
/>
@@ -171,6 +174,10 @@ function SignupPageContent() {
</div></details>
</div>
)}
{invite?.email_bound ? <p className="account-hint">Your account will use the email address this invitation was sent to. This invitation can be used once.</p> : <label>
Email address
<input type="email" required value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" placeholder="you@example.com" />
</label>}
<label>
Username
<input
+1 -1
View File
@@ -10,7 +10,7 @@ export default function InviteDeliveryChoice({ value, onChange }: {
<span className="delivery-choice-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
{method === 'manual' ? <><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-2 2" /><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l2-2" /></> : <><rect x="3" y="5" width="18" height="14" rx="3" /><path d="m3 7 9 6 9-6" /></>}
</svg></span>
<span className="delivery-choice-copy"><strong>{method === 'manual' ? 'Copy a link' : 'Send an email'}</strong><small>{method === 'manual' ? 'Share it yourself. No email needed.' : 'Well send the invite. You get the link too.'}</small></span>
<span className="delivery-choice-copy"><strong>{method === 'manual' ? 'Copy a link' : 'Send an email'}</strong><small>{method === 'manual' ? 'They add their email when signing up.' : 'One use, tied to the recipients email. You get the link too.'}</small></span>
<span className="delivery-choice-check" aria-hidden="true">{value === method ? '✓' : ''}</span>
</button>)}
</div>
-59
View File
@@ -1,59 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
const STORAGE_KEY = 'magent_theme'
const getPreferredTheme = () => {
if (typeof window === 'undefined') return 'dark'
const stored = window.localStorage.getItem(STORAGE_KEY)
if (stored === 'light' || stored === 'dark') {
return stored
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
const applyTheme = (theme: string) => {
if (typeof document === 'undefined') return
document.documentElement.setAttribute('data-theme', theme)
}
export default function ThemeToggle() {
const [theme, setTheme] = useState<'light' | 'dark'>('dark')
useEffect(() => {
const preferred = getPreferredTheme()
setTheme(preferred)
applyTheme(preferred)
}, [])
const toggle = () => {
const next = theme === 'dark' ? 'light' : 'dark'
setTheme(next)
applyTheme(next)
if (typeof window !== 'undefined') {
window.localStorage.setItem(STORAGE_KEY, next)
}
}
return (
<button
type="button"
className="theme-toggle"
onClick={toggle}
aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
title={theme === 'dark' ? 'Light mode' : 'Dark mode'}
>
{theme === 'dark' ? (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v3M12 19v3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M2 12h3M19 12h3M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12" />
</svg>
) : (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 14.5A8.5 8.5 0 0 1 9.5 3a8.5 8.5 0 1 0 11.5 11.5z" />
</svg>
)}
</button>
)
}