Merge branch 'chore/simplify-followups-and-backlog-consolidation'
Three small wins from the 2026-04-29 simplify-pass review: - STATUS_LABELS triplication in admin/members/index.vue replaced with v-for - ImageUpload alt-text input now has :focus styling via scoped CSS - paymentBridge → signupBridge rename (cookie + functions + JWT scope)
This commit is contained in:
commit
a949252915
10 changed files with 60 additions and 49 deletions
|
|
@ -77,12 +77,7 @@
|
||||||
<input
|
<input
|
||||||
:value="modelValue.alt || ''"
|
:value="modelValue.alt || ''"
|
||||||
placeholder="Describe this image..."
|
placeholder="Describe this image..."
|
||||||
class="w-full px-3 py-2"
|
class="w-full px-3 py-2 alt-text-input"
|
||||||
style="
|
|
||||||
background: var(--input-bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--text);
|
|
||||||
"
|
|
||||||
@input="updateAltText($event.target.value)"
|
@input="updateAltText($event.target.value)"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -225,3 +220,16 @@ const updateAltText = (altText) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.alt-text-input {
|
||||||
|
background: var(--input-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alt-text-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--candle);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -41,10 +41,11 @@
|
||||||
<div class="field" style="margin-bottom: 0">
|
<div class="field" style="margin-bottom: 0">
|
||||||
<select v-model="statusFilter" aria-label="Filter by status">
|
<select v-model="statusFilter" aria-label="Filter by status">
|
||||||
<option value="">All Statuses</option>
|
<option value="">All Statuses</option>
|
||||||
<option value="active">Active</option>
|
<option
|
||||||
<option value="pending_payment">Payment setup incomplete</option>
|
v-for="(label, value) in STATUS_LABELS"
|
||||||
<option value="suspended">Suspended</option>
|
:key="value"
|
||||||
<option value="cancelled">Cancelled</option>
|
:value="value"
|
||||||
|
>{{ label }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -371,10 +372,11 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Status</label>
|
<label>Status</label>
|
||||||
<select v-model="editingMember.status">
|
<select v-model="editingMember.status">
|
||||||
<option value="pending_payment">Payment setup incomplete</option>
|
<option
|
||||||
<option value="active">Active</option>
|
v-for="(label, value) in STATUS_LABELS"
|
||||||
<option value="suspended">Suspended</option>
|
:key="value"
|
||||||
<option value="cancelled">Cancelled</option>
|
:value="value"
|
||||||
|
>{{ label }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { connectDB } from '../../utils/mongoose.js'
|
||||||
import { createHelcimCustomer } from '../../utils/helcim.js'
|
import { createHelcimCustomer } from '../../utils/helcim.js'
|
||||||
import PreRegistration from '../../models/preRegistration.js'
|
import PreRegistration from '../../models/preRegistration.js'
|
||||||
import { sendMagicLink } from '../../utils/magicLink.js'
|
import { sendMagicLink } from '../../utils/magicLink.js'
|
||||||
import { setPaymentBridgeCookie } from '../../utils/auth.js'
|
import { setSignupBridgeCookie } from '../../utils/auth.js'
|
||||||
import { rateLimit } from '../../utils/rateLimit.js'
|
import { rateLimit } from '../../utils/rateLimit.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
|
|
@ -116,10 +116,10 @@ export default defineEventHandler(async (event) => {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Signup completes (paid checkout or free activation) before the magic
|
// Signup completes (paid checkout or free activation) before the magic
|
||||||
// link is clicked, so issue a short-lived, payment-only bridge cookie
|
// link is clicked, so issue a short-lived signup-bridge cookie that lets
|
||||||
// that lets /api/helcim/initialize-payment and /api/helcim/subscription
|
// /api/helcim/initialize-payment and /api/helcim/subscription identify
|
||||||
// identify the member without a verified auth session.
|
// the member without a verified auth session.
|
||||||
setPaymentBridgeCookie(event, member)
|
setSignupBridgeCookie(event, member)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import Member from '../../models/member.js'
|
||||||
import { loadPublicEvent } from '../../utils/loadEvent.js'
|
import { loadPublicEvent } from '../../utils/loadEvent.js'
|
||||||
import { loadPublicSeries } from '../../utils/loadSeries.js'
|
import { loadPublicSeries } from '../../utils/loadSeries.js'
|
||||||
import { calculateTicketPrice, calculateSeriesTicketPrice, hasMemberAccess } from '../../utils/tickets.js'
|
import { calculateTicketPrice, calculateSeriesTicketPrice, hasMemberAccess } from '../../utils/tickets.js'
|
||||||
import { requireAuth, getOptionalMember, getPaymentBridgeMember } from '../../utils/auth.js'
|
import { requireAuth, getOptionalMember, getSignupBridgeMember } from '../../utils/auth.js'
|
||||||
import { initializeHelcimPaySession } from '../../utils/helcim.js'
|
import { initializeHelcimPaySession } from '../../utils/helcim.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
|
|
@ -17,7 +17,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
if (!isTicket) {
|
if (!isTicket) {
|
||||||
if (isMembershipSignup) {
|
if (isMembershipSignup) {
|
||||||
const bridgeMember = await getPaymentBridgeMember(event)
|
const bridgeMember = await getSignupBridgeMember(event)
|
||||||
if (!bridgeMember) {
|
if (!bridgeMember) {
|
||||||
await requireAuth(event)
|
await requireAuth(event)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { getHelcimPlanId, requiresPayment } from '../../config/contributions.js'
|
||||||
import Member from '../../models/member.js'
|
import Member from '../../models/member.js'
|
||||||
import { connectDB } from '../../utils/mongoose.js'
|
import { connectDB } from '../../utils/mongoose.js'
|
||||||
import { getSlackService } from '../../utils/slack.ts'
|
import { getSlackService } from '../../utils/slack.ts'
|
||||||
import { requireAuth, getPaymentBridgeMember } from '../../utils/auth.js'
|
import { requireAuth, getSignupBridgeMember } from '../../utils/auth.js'
|
||||||
import { createHelcimSubscription, generateIdempotencyKey, listHelcimCustomerTransactions } from '../../utils/helcim.js'
|
import { createHelcimSubscription, generateIdempotencyKey, listHelcimCustomerTransactions } from '../../utils/helcim.js'
|
||||||
import { sendWelcomeEmail } from '../../utils/resend.js'
|
import { sendWelcomeEmail } from '../../utils/resend.js'
|
||||||
import { upsertPaymentFromHelcim } from '../../utils/payments.js'
|
import { upsertPaymentFromHelcim } from '../../utils/payments.js'
|
||||||
|
|
@ -11,8 +11,8 @@ import { upsertPaymentFromHelcim } from '../../utils/payments.js'
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
// Membership signup completes subscription before email verify; allow the
|
// Membership signup completes subscription before email verify; allow the
|
||||||
// payment-bridge cookie set by /api/helcim/customer to satisfy auth here.
|
// signup-bridge cookie set by /api/helcim/customer to satisfy auth here.
|
||||||
const bridgeMember = await getPaymentBridgeMember(event)
|
const bridgeMember = await getSignupBridgeMember(event)
|
||||||
if (!bridgeMember) {
|
if (!bridgeMember) {
|
||||||
await requireAuth(event)
|
await requireAuth(event)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,26 +23,27 @@ export function setAuthCookie(event, member) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Issue a 30-minute payment-bridge cookie scoped to membership-signup checkout.
|
* Issue a 30-minute signup-bridge cookie scoped to membership-signup flow.
|
||||||
*
|
*
|
||||||
* The signup flow (POST /api/helcim/customer) defers the full session cookie
|
* The signup flow (POST /api/helcim/customer) defers the full session cookie
|
||||||
* to email-verify (magic link). For paid tiers the user still needs to complete
|
* to email-verify (magic link). The bridge cookie lets the in-progress signup
|
||||||
* Helcim checkout in the same browser tab — this short-lived, payment-only
|
* complete its activation step (free or paid) before that magic link is
|
||||||
* token lets `/api/helcim/initialize-payment` accept the call without a full
|
* clicked: /api/helcim/subscription accepts it for $0 activation, and
|
||||||
* session. The cookie is NOT honored by requireAuth and grants nothing else.
|
* /api/helcim/initialize-payment accepts it for paid Helcim checkout.
|
||||||
|
* The cookie is NOT honored by requireAuth and grants nothing else.
|
||||||
*/
|
*/
|
||||||
export function setPaymentBridgeCookie(event, member) {
|
export function setSignupBridgeCookie(event, member) {
|
||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{
|
{
|
||||||
memberId: member._id.toString(),
|
memberId: member._id.toString(),
|
||||||
email: member.email,
|
email: member.email,
|
||||||
scope: 'payment_bridge'
|
scope: 'signup_bridge'
|
||||||
},
|
},
|
||||||
useRuntimeConfig(event).jwtSecret,
|
useRuntimeConfig(event).jwtSecret,
|
||||||
{ expiresIn: '30m' }
|
{ expiresIn: '30m' }
|
||||||
)
|
)
|
||||||
|
|
||||||
setCookie(event, 'payment-bridge', token, {
|
setCookie(event, 'signup-bridge', token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production',
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
|
|
@ -52,12 +53,12 @@ export function setPaymentBridgeCookie(event, member) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify a payment-bridge cookie and return the associated Member, or null.
|
* Verify a signup-bridge cookie and return the associated Member, or null.
|
||||||
* Used by /api/helcim/initialize-payment to allow the membership-signup
|
* Used by /api/helcim/subscription and /api/helcim/initialize-payment to
|
||||||
* checkout to proceed before email verification.
|
* let the in-progress signup complete activation before email verification.
|
||||||
*/
|
*/
|
||||||
export async function getPaymentBridgeMember(event) {
|
export async function getSignupBridgeMember(event) {
|
||||||
const token = getCookie(event, 'payment-bridge')
|
const token = getCookie(event, 'signup-bridge')
|
||||||
if (!token) return null
|
if (!token) return null
|
||||||
|
|
||||||
let decoded
|
let decoded
|
||||||
|
|
@ -67,7 +68,7 @@ export async function getPaymentBridgeMember(event) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (decoded.scope !== 'payment_bridge') return null
|
if (decoded.scope !== 'signup_bridge') return null
|
||||||
|
|
||||||
await connectDB()
|
await connectDB()
|
||||||
const member = await Member.findById(decoded.memberId)
|
const member = await Member.findById(decoded.memberId)
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ vi.mock('../../../server/models/preRegistration.js', () => ({
|
||||||
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
|
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
|
||||||
vi.mock('../../../server/utils/auth.js', () => ({
|
vi.mock('../../../server/utils/auth.js', () => ({
|
||||||
requireAuth: vi.fn(),
|
requireAuth: vi.fn(),
|
||||||
getPaymentBridgeMember: vi.fn().mockResolvedValue(null),
|
getSignupBridgeMember: vi.fn().mockResolvedValue(null),
|
||||||
setAuthCookie: vi.fn()
|
setAuthCookie: vi.fn()
|
||||||
}))
|
}))
|
||||||
vi.mock('../../../server/utils/slack.ts', () => ({
|
vi.mock('../../../server/utils/slack.ts', () => ({
|
||||||
|
|
|
||||||
|
|
@ -60,9 +60,9 @@ const SUBSCRIPTION_BODY = {
|
||||||
function extractBridgeCookie(event) {
|
function extractBridgeCookie(event) {
|
||||||
const setCookie = event.node.res.getHeader('set-cookie')
|
const setCookie = event.node.res.getHeader('set-cookie')
|
||||||
const cookies = Array.isArray(setCookie) ? setCookie : [setCookie].filter(Boolean)
|
const cookies = Array.isArray(setCookie) ? setCookie : [setCookie].filter(Boolean)
|
||||||
const match = cookies.find(c => typeof c === 'string' && c.startsWith('payment-bridge='))
|
const match = cookies.find(c => typeof c === 'string' && c.startsWith('signup-bridge='))
|
||||||
if (!match) return null
|
if (!match) return null
|
||||||
return match.match(/payment-bridge=([^;]+)/)[1]
|
return match.match(/signup-bridge=([^;]+)/)[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('signup → subscription bridge-cookie hand-off', () => {
|
describe('signup → subscription bridge-cookie hand-off', () => {
|
||||||
|
|
@ -104,7 +104,7 @@ describe('signup → subscription bridge-cookie hand-off', () => {
|
||||||
expect(result1.member.status).toBe('pending_payment')
|
expect(result1.member.status).toBe('pending_payment')
|
||||||
|
|
||||||
const bridgeToken = extractBridgeCookie(customerEvent)
|
const bridgeToken = extractBridgeCookie(customerEvent)
|
||||||
expect(bridgeToken, 'payment-bridge cookie missing on $0 signup').toBeTruthy()
|
expect(bridgeToken, 'signup-bridge cookie missing on $0 signup').toBeTruthy()
|
||||||
|
|
||||||
Member.findOneAndUpdate.mockResolvedValue({ _id: MEMBER_ID, status: 'pending_payment' })
|
Member.findOneAndUpdate.mockResolvedValue({ _id: MEMBER_ID, status: 'pending_payment' })
|
||||||
Member.findById.mockResolvedValue({
|
Member.findById.mockResolvedValue({
|
||||||
|
|
@ -120,7 +120,7 @@ describe('signup → subscription bridge-cookie hand-off', () => {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/api/helcim/subscription',
|
path: '/api/helcim/subscription',
|
||||||
headers: { origin: ALLOWED_ORIGIN },
|
headers: { origin: ALLOWED_ORIGIN },
|
||||||
cookies: { 'payment-bridge': bridgeToken },
|
cookies: { 'signup-bridge': bridgeToken },
|
||||||
body: SUBSCRIPTION_BODY
|
body: SUBSCRIPTION_BODY
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import Member from '../../../server/models/member.js'
|
import Member from '../../../server/models/member.js'
|
||||||
import { createHelcimCustomer } from '../../../server/utils/helcim.js'
|
import { createHelcimCustomer } from '../../../server/utils/helcim.js'
|
||||||
import { sendMagicLink } from '../../../server/utils/magicLink.js'
|
import { sendMagicLink } from '../../../server/utils/magicLink.js'
|
||||||
import { setAuthCookie, setPaymentBridgeCookie } from '../../../server/utils/auth.js'
|
import { setAuthCookie, setSignupBridgeCookie } from '../../../server/utils/auth.js'
|
||||||
import customerHandler from '../../../server/api/helcim/customer.post.js'
|
import customerHandler from '../../../server/api/helcim/customer.post.js'
|
||||||
import { resetRateLimit } from '../../../server/utils/rateLimit.js'
|
import { resetRateLimit } from '../../../server/utils/rateLimit.js'
|
||||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||||
|
|
@ -24,7 +24,7 @@ vi.mock('../../../server/utils/magicLink.js', () => ({
|
||||||
}))
|
}))
|
||||||
vi.mock('../../../server/utils/auth.js', () => ({
|
vi.mock('../../../server/utils/auth.js', () => ({
|
||||||
setAuthCookie: vi.fn(),
|
setAuthCookie: vi.fn(),
|
||||||
setPaymentBridgeCookie: vi.fn()
|
setSignupBridgeCookie: vi.fn()
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// helcimCustomerSchema is auto-imported in the handler — stub it to a passthrough
|
// helcimCustomerSchema is auto-imported in the handler — stub it to a passthrough
|
||||||
|
|
@ -303,7 +303,7 @@ describe('POST /api/helcim/customer', () => {
|
||||||
'guest@example.com',
|
'guest@example.com',
|
||||||
expect.objectContaining({ subject: 'Verify your Ghost Guild signup' })
|
expect.objectContaining({ subject: 'Verify your Ghost Guild signup' })
|
||||||
)
|
)
|
||||||
expect(setPaymentBridgeCookie).toHaveBeenCalled()
|
expect(setSignupBridgeCookie).toHaveBeenCalled()
|
||||||
expect(setAuthCookie).not.toHaveBeenCalled()
|
expect(setAuthCookie).not.toHaveBeenCalled()
|
||||||
|
|
||||||
// Response shape mirrors new-signup case AND surfaces the preserved _id.
|
// Response shape mirrors new-signup case AND surfaces the preserved _id.
|
||||||
|
|
@ -365,7 +365,7 @@ describe('POST /api/helcim/customer', () => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sets a payment-bridge cookie on paid-tier signup so checkout can proceed', async () => {
|
it('sets a signup-bridge cookie on paid-tier signup so checkout can proceed', async () => {
|
||||||
const event = build({
|
const event = build({
|
||||||
body: {
|
body: {
|
||||||
name: 'Paid User',
|
name: 'Paid User',
|
||||||
|
|
@ -376,7 +376,7 @@ describe('POST /api/helcim/customer', () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
await customerHandler(event)
|
await customerHandler(event)
|
||||||
expect(setPaymentBridgeCookie).toHaveBeenCalled()
|
expect(setSignupBridgeCookie).toHaveBeenCalled()
|
||||||
expect(sendMagicLink).toHaveBeenCalledWith(
|
expect(sendMagicLink).toHaveBeenCalledWith(
|
||||||
'paid@example.com',
|
'paid@example.com',
|
||||||
expect.objectContaining({ subject: 'Verify your Ghost Guild signup' })
|
expect.objectContaining({ subject: 'Verify your Ghost Guild signup' })
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ vi.mock('../../../server/models/member.js', () => ({
|
||||||
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
|
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
|
||||||
vi.mock('../../../server/utils/auth.js', () => ({
|
vi.mock('../../../server/utils/auth.js', () => ({
|
||||||
requireAuth: vi.fn(),
|
requireAuth: vi.fn(),
|
||||||
getPaymentBridgeMember: vi.fn().mockResolvedValue(null)
|
getSignupBridgeMember: vi.fn().mockResolvedValue(null)
|
||||||
}))
|
}))
|
||||||
vi.mock('../../../server/utils/slack.ts', () => ({
|
vi.mock('../../../server/utils/slack.ts', () => ({
|
||||||
getSlackService: vi.fn().mockReturnValue(null)
|
getSlackService: vi.fn().mockReturnValue(null)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue