Compare commits

..

9 commits

35 changed files with 104 additions and 116 deletions

View file

@ -273,14 +273,6 @@ p a, blockquote a {
min-width: 0;
}
/* ---- Nuxt UI placeholder contrast ----
Default Nuxt UI placeholder uses `text-dimmed` (#a6a09b) which fails WCAG
AA on cream and white backgrounds (2.4:1). Override globally to --text-dim
so USelect/USelectMenu placeholders meet the 4.5:1 ratio. */
[data-slot="placeholder"] {
color: var(--text-dim);
}
/* ---- SHARED USelectMenu STYLES ----
Apply via: <USelectMenu class="zine-select" :ui="{ content: 'tz-content', item: 'tz-item', input: 'tz-input' }" />
Classes are global (not scoped) because Nuxt UI portals the popup content to body. */

View file

@ -131,10 +131,12 @@ const DEFAULT_WIKI_FEATURE_TITLE = "What is a cooperative studio?";
const { data: wikiFeature } = await useFetch(
"/api/site-content/homepage.wiki_feature",
{ default: () => ({ title: "", body: "" }) },
{ default: () => ({ title: "", body: "" }) }
);
const hasCustomWikiFeature = computed(() => !!wikiFeature.value?.body?.trim());
const hasCustomWikiFeature = computed(
() => !!wikiFeature.value?.body?.trim()
);
const customWikiParagraphs = computed(() => {
const body = wikiFeature.value?.body?.trim() || "";
@ -164,7 +166,7 @@ const circleData = [
label: "Practitioner",
metaphor: "The alcove",
blurb:
"Where experience is shared and knowledge given back. You're here to support newcomers, help shape the Cooperative Foundations program, and find peers.",
"Where experience is shared and knowledge given back. You're here to teach, advise, mentor, and help shape the program itself. Alumni welcome.",
},
];

View file

@ -64,37 +64,26 @@
<!-- Left: Monthly Contribution -->
<div class="join-col">
<div class="section-label" style="margin-bottom: 12px">
{{
cadence === "annual"
? "Annual Contribution"
: "Monthly Contribution"
}}
{{ cadence === 'annual' ? 'Annual Contribution' : 'Monthly Contribution' }}
</div>
<h2>Pay what you can</h2>
<ul class="tier-list">
<li><span class="tier-amt">$0</span> I need support right now</li>
<li><span class="tier-amt">{{ formatContributionAmount(5) }}</span> I can contribute</li>
<li>
<span class="tier-amt">{{ formatContributionAmount(5) }}</span> I
can contribute
<span class="tier-amt">{{ formatContributionAmount(15) }}</span> I can sustain the community
(suggested)
</li>
<li><span class="tier-amt">{{ formatContributionAmount(30) }}</span> I can support others too</li>
<li>
<span class="tier-amt">{{ formatContributionAmount(15) }}</span> I
can sustain the community (suggested)
</li>
<li>
<span class="tier-amt">{{ formatContributionAmount(30) }}</span> I
can support others too
</li>
<li>
<span class="tier-amt">{{ formatContributionAmount(50) }}</span> I
want to sponsor multiple members
<span class="tier-amt">{{ formatContributionAmount(50) }}</span> I want to sponsor multiple
members
</li>
</ul>
<p class="charity-note">
Baby Ghosts Studio Development Fund is a registered Canadian
charity. Members who file Canadian taxes can claim their
contributions. We'll help you set up tax receipts once you've
joined.
Baby Ghosts Studio Development Fund is a registered Canadian charity.
Members who file Canadian taxes can claim their contributions.
We'll help you set up tax receipts once you've joined.
</p>
<p class="solidarity-note">
Pay what you can. If you can pay more, you're making room for
@ -129,7 +118,7 @@
type="text"
placeholder="Your name"
required
/>
>
</div>
<div class="form-group">
<label class="form-label" for="join-email">Email Address</label>
@ -140,7 +129,7 @@
type="email"
placeholder="you@example.com"
required
/>
>
</div>
<div class="form-group">
<label class="form-label">Circle</label>
@ -152,7 +141,7 @@
type="radio"
name="circle"
value="community"
/>
>
<label for="circle-community">
<span
class="circle-label-name"
@ -169,7 +158,7 @@
type="radio"
name="circle"
value="founder"
/>
>
<label for="circle-founder">
<span
class="circle-label-name"
@ -186,7 +175,7 @@
type="radio"
name="circle"
value="practitioner"
/>
>
<label for="circle-practitioner">
<span
class="circle-label-name"
@ -208,7 +197,7 @@
type="radio"
name="cadence"
value="monthly"
/>
>
<label for="cadence-monthly">
<span class="circle-label-name">Per Month</span>
</label>
@ -220,7 +209,7 @@
type="radio"
name="cadence"
value="annual"
/>
>
<label for="cadence-annual">
<span class="circle-label-name">Per Year</span>
</label>
@ -241,13 +230,9 @@
step="1"
inputmode="numeric"
class="contribution-input"
/>
</div>
<div
class="contribution-presets"
role="group"
aria-label="Suggested amounts"
>
</div>
<div class="contribution-presets" role="group" aria-label="Suggested amounts">
<button
v-for="preset in CONTRIBUTION_PRESETS"
:key="preset.amount"
@ -258,30 +243,24 @@
${{ preset.amount }}
</button>
</div>
<p v-if="guidanceLabel" class="contribution-guidance">
{{ guidanceLabel }}
</p>
<p v-if="guidanceLabel" class="contribution-guidance">{{ guidanceLabel }}</p>
</div>
<div v-if="form.contributionAmount > 0" class="form-group">
<div class="billing-summary">
<p class="billing-summary-line">
You'll be charged <strong>${{ firstCharge }} today</strong
><span v-if="cadence === 'annual'">
(${{ form.contributionAmount }}/month &times; 12)</span
>.
You'll be charged <strong>${{ firstCharge }} today</strong><span v-if="cadence === 'annual'"> (${{ form.contributionAmount }}/month &times; 12)</span>.
</p>
<p class="billing-summary-line">
Then
<strong
>${{ firstCharge }} every
{{ cadence === "annual" ? "year" : "month" }}</strong
>, until you cancel.
Then <strong>${{ firstCharge }} every {{ cadence === 'annual' ? 'year' : 'month' }}</strong>, until you cancel.
</p>
</div>
</div>
<div class="form-group full-width">
<label class="checkbox-label">
<input v-model="form.agreedToGuidelines" type="checkbox" />
<input
v-model="form.agreedToGuidelines"
type="checkbox"
>
<span>
I agree to the Ghost Guild
<NuxtLink to="/community-guidelines" target="_blank"
@ -359,11 +338,12 @@
<h2>Practicing</h2>
<p>
For those already running cooperative studios or with deep
experience in cooperative practice. You're here to support newcomers
and help shape the Cooperative Foundations program.
experience in cooperative practice. You are here to teach, advise,
mentor, and help shape the program itself. Alumni.
</p>
</div>
</div>
</template>
<!-- Flow overlay: covers the page from form submit through redirect.
@ -454,8 +434,7 @@ const isFormValid = computed(() => {
form.name &&
form.email &&
form.circle &&
Number.isInteger(form.contributionAmount) &&
form.contributionAmount >= 0 &&
Number.isInteger(form.contributionAmount) && form.contributionAmount >= 0 &&
form.agreedToGuidelines
);
});
@ -851,7 +830,7 @@ onUnmounted(() => {
padding: 0.5rem 0.75rem;
background: var(--input-bg);
border: 1px solid var(--parch);
font-family: "Commit Mono", monospace;
font-family: 'Commit Mono', monospace;
font-size: 1rem;
}
.contribution-input:focus {
@ -868,7 +847,7 @@ onUnmounted(() => {
padding: 0.25rem 0.75rem;
background: transparent;
border: 1px dashed var(--parch);
font-family: "Commit Mono", monospace;
font-family: 'Commit Mono', monospace;
font-size: 0.875rem;
cursor: pointer;
}
@ -1038,7 +1017,6 @@ onUnmounted(() => {
.checkbox-label a,
.checkbox-label :deep(a) {
color: var(--candle);
text-decoration: underline;
}
/* ---- ERROR & SUCCESS BOXES ---- */
@ -1148,4 +1126,5 @@ onUnmounted(() => {
align-items: stretch;
}
}
</style>

View file

@ -712,6 +712,10 @@ useHead({
.posts-empty-link {
color: var(--candle);
text-decoration: none;
}
.posts-empty-link:hover {
text-decoration: underline;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 315 KiB

After

Width:  |  Height:  |  Size: 323 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 KiB

After

Width:  |  Height:  |  Size: 247 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

After

Width:  |  Height:  |  Size: 279 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

After

Width:  |  Height:  |  Size: 236 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

After

Width:  |  Height:  |  Size: 285 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

After

Width:  |  Height:  |  Size: 280 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

After

Width:  |  Height:  |  Size: 297 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 251 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 343 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

After

Width:  |  Height:  |  Size: 203 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 195 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Before After
Before After

View file

@ -11,7 +11,6 @@ test.describe('Admin board channels page', () => {
test('create, edit, and delete a channel', async ({ adminPage }) => {
await adminPage.goto('/admin/board-channels')
await adminPage.waitForLoadState('networkidle')
await expect(adminPage.getByRole('heading', { name: 'Board Channels' })).toBeVisible({
timeout: 15000,
})
@ -19,14 +18,14 @@ test.describe('Admin board channels page', () => {
const suffix = Date.now().toString().slice(-6)
const channelName = `e2e-channel-${suffix}`
const editedName = `e2e-channel-${suffix}-edited`
const slackId = `C${suffix}XYZ`
// --- Create ---
// Create flow only takes a name; the Slack channel ID is auto-assigned on
// creation and only becomes editable in the Edit modal.
await adminPage.getByRole('button', { name: '+ New Channel' }).click()
await expect(adminPage.getByRole('heading', { name: 'New Channel' })).toBeVisible()
await adminPage.locator('input[placeholder="e.g., coop-formation"]').fill(channelName)
await adminPage.locator('input[placeholder="e.g., #coop-formation"]').fill(channelName)
await adminPage.locator('input[placeholder="C0123456789"]').fill(slackId)
// Select the first available cooperative tag if any are present
const firstTagCheckbox = adminPage.locator('.tag-select input[type="checkbox"]').first()
@ -45,7 +44,7 @@ test.describe('Admin board channels page', () => {
await row.getByRole('button', { name: 'Edit' }).click()
await expect(adminPage.getByRole('heading', { name: 'Edit Channel' })).toBeVisible()
const nameInput = adminPage.locator('input[placeholder="e.g., coop-formation"]')
const nameInput = adminPage.locator('input[placeholder="e.g., #coop-formation"]')
await nameInput.fill(editedName)
await adminPage.getByRole('button', { name: 'Save Changes' }).click()

View file

@ -44,7 +44,6 @@ test.describe('Authentication flows', () => {
test('logout clears auth', async ({ page }) => {
await loginAsAdmin(page)
await page.goto('/admin')
await page.waitForLoadState('networkidle')
await expect(page.locator('.admin-tag')).toBeVisible({ timeout: 15000 })
// Set up response listener BEFORE clicking to avoid race

View file

@ -9,7 +9,6 @@ test.describe('Board page', () => {
test('clicking New Post reveals the form', async ({ memberPage }) => {
await memberPage.goto('/board')
await memberPage.waitForLoadState('networkidle')
await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible({
timeout: 15000,
})
@ -41,7 +40,6 @@ test.describe('Board page', () => {
test('create, edit, and delete own post', async ({ memberPage }) => {
await memberPage.goto('/board')
await memberPage.waitForLoadState('networkidle')
await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible({
timeout: 15000,
})
@ -57,7 +55,7 @@ test.describe('Board page', () => {
await memberPage.locator('#post-title').fill(originalTitle)
await memberPage.locator('#post-seeking').fill('Playwright test seeking text')
await memberPage.getByRole('button', { name: 'Post', exact: true }).click()
await memberPage.getByRole('button', { name: 'Post' }).click()
await expect(memberPage.getByRole('heading', { name: originalTitle })).toBeVisible({
timeout: 10000,
@ -77,10 +75,10 @@ test.describe('Board page', () => {
timeout: 10000,
})
// --- Delete (in-card two-step confirm; not a native dialog) ---
// --- Delete (confirm dialog) ---
memberPage.once('dialog', (dialog) => dialog.accept())
const editedCard = memberPage.locator('article.board-post', { hasText: editedTitle })
await editedCard.getByRole('button', { name: 'Delete' }).click()
await editedCard.getByRole('button', { name: 'Confirm' }).click()
await expect(memberPage.getByRole('heading', { name: editedTitle })).not.toBeVisible({
timeout: 10000,

View file

@ -68,12 +68,8 @@ test.describe('Join page — member signup flow', () => {
await page.locator('#join-name').fill('Test User')
await expect(page.locator('.form-submit')).toBeDisabled()
// Fill email — agreement still unchecked, so still disabled
// Fill email too — now all fields are populated and button should be enabled
await page.locator('#join-email').fill('incomplete-test@example.com')
await expect(page.locator('.form-submit')).toBeDisabled()
// Check the Community Guidelines agreement — now all required fields satisfied
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
await expect(page.locator('.form-submit')).toBeEnabled()
})
@ -87,9 +83,8 @@ test.describe('Join page — member signup flow', () => {
await page.locator('#join-name').fill('E2E Test User')
await page.locator('#join-email').fill(uniqueEmail)
await page.locator('#circle-community').check({ force: true })
// Contribution is now a numeric input with preset chips, not a select
await page.locator('#join-contribution').fill('0')
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
await page.locator('#join-contribution').click()
await page.getByRole('option', { name: '$0/mo' }).click()
await expect(page.locator('.form-submit')).toBeEnabled()
@ -98,10 +93,8 @@ test.describe('Join page — member signup flow', () => {
await page.locator('.form-submit').click()
// Free tier flips the SignupFlowOverlay into its success state
await expect(
page.getByRole('heading', { name: 'Welcome to Ghost Guild!' })
).toBeVisible({ timeout: 15000 })
// Free tier creates subscription then shows confirmation (step 3)
await expect(page.locator('.success-box')).toBeVisible({ timeout: 15000 })
})
test('duplicate email shows error', async ({ page }) => {
@ -116,13 +109,12 @@ test.describe('Join page — member signup flow', () => {
await page.locator('#join-name').fill('Dup Test User')
await page.locator('#join-email').fill(duplicateEmail)
await page.locator('#circle-community').check({ force: true })
await page.locator('#join-contribution').fill('0')
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
await page.locator('#join-contribution').click()
await page.getByRole('option', { name: '$0/mo' }).click()
await page.locator('.form-submit').click()
// Helcim 409 puts SignupFlowOverlay into its error state
const overlayError = page.locator('.signup-flow-overlay .error-box')
await expect(overlayError).toBeVisible({ timeout: 10000 })
await expect(overlayError).toContainText(/already/i)
// Should show an error about the email already existing
await expect(page.locator('.error-box')).toBeVisible({ timeout: 10000 })
await expect(page.locator('.error-box')).toContainText(/already/i)
})
})

View file

@ -3,11 +3,9 @@ import { test, expect } from './helpers/fixtures.js'
test.describe('Member profile page', () => {
test('profile page loads', async ({ adminPage }) => {
await adminPage.goto('/member/profile')
await adminPage.waitForLoadState('networkidle')
// Auth is checked client-side in onMounted — wait for profile form to render
await expect(adminPage.getByText('Edit Profile')).toBeVisible({ timeout: 15000 })
// Verify a stable structural section label, not transient marketing copy
await expect(adminPage.getByText('Show in Member Directory')).toBeVisible()
await expect(adminPage.getByText('How you appear to other members')).toBeVisible()
})
test('form fields are present', async ({ adminPage }) => {
@ -26,7 +24,6 @@ test.describe('Member profile page', () => {
test('bio field accepts input', async ({ adminPage }) => {
await adminPage.goto('/member/profile')
await adminPage.waitForLoadState('networkidle')
await expect(adminPage.getByText('Edit Profile')).toBeVisible({ timeout: 15000 })
const bio = adminPage.locator('textarea[placeholder*="Share your background"]')

View file

@ -1,5 +1,12 @@
import { RateLimiterMemory } from 'rate-limiter-flexible'
// Strict rate limit for auth endpoints
const authLimiter = new RateLimiterMemory({
points: 5, // 5 requests
duration: 300, // per 5 minutes
keyPrefix: 'rl_auth'
})
// Moderate rate limit for payment endpoints
const paymentLimiter = new RateLimiterMemory({
points: 10,
@ -28,6 +35,7 @@ function getClientIp(event) {
|| 'unknown'
}
const AUTH_PATHS = new Set(['/api/auth/login', '/api/auth/verify'])
const PAYMENT_PREFIXES = ['/api/helcim/']
const UPLOAD_PATHS = new Set(['/api/upload/image'])
@ -35,15 +43,12 @@ export default defineEventHandler(async (event) => {
const path = getRequestURL(event).pathname
if (!path.startsWith('/api/')) return
// Bypass rate limiting in test/dev opt-in mode so parallel E2E runs from a
// single IP (127.0.0.1) do not exhaust the per-IP budget. Mirrors the gate
// used by /api/dev/* endpoints — only set in development and by Playwright.
if (process.env.ALLOW_DEV_TEST_ENDPOINTS === 'true') return
const ip = getClientIp(event)
try {
if (PAYMENT_PREFIXES.some(p => path.startsWith(p))) {
if (AUTH_PATHS.has(path)) {
await authLimiter.consume(ip)
} else if (PAYMENT_PREFIXES.some(p => path.startsWith(p))) {
await paymentLimiter.consume(ip)
} else if (UPLOAD_PATHS.has(path)) {
await uploadLimiter.consume(ip)

View file

@ -4,11 +4,6 @@
const buckets = new Map()
export function rateLimit(key, { max, windowMs }) {
// Bypass in test/dev opt-in mode so parallel E2E runs from a single IP
// (127.0.0.1) don't exhaust per-IP/email budgets. Mirrors the gate used by
// /api/dev/* endpoints and server/middleware/03.rate-limit.js.
if (process.env.ALLOW_DEV_TEST_ENDPOINTS === 'true') return true
const now = Date.now()
// Probabilistic sweep: ~1% of calls evict keys whose newest entry has fully

View file

@ -18,6 +18,35 @@ describe('rate-limit middleware', () => {
})
})
describe('auth endpoint limiting (5 per 5 min)', () => {
it('allows 5 requests then blocks the 6th', async () => {
const ip = '10.0.1.1'
// First 5 should succeed
for (let i = 0; i < 5; i++) {
const event = createMockEvent({
method: 'POST',
path: '/api/auth/login',
remoteAddress: ip
})
await expect(rateLimitMiddleware(event)).resolves.toBeUndefined()
}
// 6th should be rate limited
const event = createMockEvent({
method: 'POST',
path: '/api/auth/login',
remoteAddress: ip
})
await expect(rateLimitMiddleware(event)).rejects.toMatchObject({
statusCode: 429
})
// Check Retry-After header was set
expect(event._testSetHeaders['retry-after']).toBeDefined()
})
})
describe('payment endpoint limiting (10 per min)', () => {
it('allows 10 requests then blocks the 11th', async () => {
const ip = '10.0.2.1'
@ -39,19 +68,16 @@ describe('rate-limit middleware', () => {
await expect(rateLimitMiddleware(event)).rejects.toMatchObject({
statusCode: 429
})
// Check Retry-After header was set
expect(event._testSetHeaders['retry-after']).toBeDefined()
})
})
describe('IP isolation', () => {
it('different IPs have separate rate limit counters', async () => {
// Exhaust payment limit for IP A
for (let i = 0; i < 10; i++) {
// Exhaust limit for IP A
for (let i = 0; i < 5; i++) {
const event = createMockEvent({
method: 'POST',
path: '/api/helcim/initialize-payment',
path: '/api/auth/login',
remoteAddress: '10.0.3.1'
})
await rateLimitMiddleware(event)
@ -60,7 +86,7 @@ describe('rate-limit middleware', () => {
// IP B should still be able to make requests
const event = createMockEvent({
method: 'POST',
path: '/api/helcim/initialize-payment',
path: '/api/auth/login',
remoteAddress: '10.0.3.2'
})
await expect(rateLimitMiddleware(event)).resolves.toBeUndefined()