Enforce recipient-bound single-use invites and fix issue card layout; clean release tooling
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.' : 'We’ll 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 recipient’s email. You get the link too.'}</small></span>
|
||||
<span className="delivery-choice-check" aria-hidden="true">{value === method ? '✓' : ''}</span>
|
||||
</button>)}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user