diff --git a/app/pages/accept-invite.vue b/app/pages/accept-invite.vue index 260d7dc..6a9aaa8 100644 --- a/app/pages/accept-invite.vue +++ b/app/pages/accept-invite.vue @@ -32,7 +32,7 @@ class="form-input" type="text" required - /> + >
@@ -42,7 +42,7 @@ class="form-input" type="email" disabled - /> + >

Email cannot be changed. Contact us if you need to use a different email.

@@ -53,7 +53,7 @@ class="form-input" type="text" placeholder="e.g. they/them, she/her" - /> + >
@@ -63,7 +63,7 @@ class="form-input" type="text" placeholder="e.g. Vancouver, BC" - /> + >
@@ -77,7 +77,7 @@ type="radio" name="circle" value="community" - /> + >
@@ -142,14 +142,12 @@
@@ -221,11 +219,11 @@ const form = reactive({ circle: "community", motivation: "", contributionTier: "15", - agreedToTerms: false, + agreedToGuidelines: false, }); const isFormValid = computed(() => { - return form.name && form.circle && form.contributionTier && form.agreedToTerms; + return form.name && form.circle && form.contributionTier && form.agreedToGuidelines; }); const needsPayment = computed(() => { @@ -283,7 +281,7 @@ const handleAccept = async () => { circle: form.circle, motivation: form.motivation || undefined, contributionTier: form.contributionTier, - agreedToTerms: form.agreedToTerms, + agreedToGuidelines: form.agreedToGuidelines, token: token.value, }, }); diff --git a/app/pages/community-guidelines.vue b/app/pages/community-guidelines.vue new file mode 100644 index 0000000..2708198 --- /dev/null +++ b/app/pages/community-guidelines.vue @@ -0,0 +1,377 @@ + + + + + diff --git a/app/pages/join.vue b/app/pages/join.vue index c928df8..fc1ae21 100644 --- a/app/pages/join.vue +++ b/app/pages/join.vue @@ -65,7 +65,7 @@
+
+ +

- By joining you agree to our - community guidelines. You - can change your circle or contribution at any time from your + You can change your circle or contribution at any time from your dashboard. Payment is handled securely through Helcim { - return form.name && form.email && form.circle && form.contributionTier; + return ( + form.name && + form.email && + form.circle && + form.contributionTier && + form.agreedToGuidelines + ); }); // Check if payment is required @@ -471,6 +490,7 @@ const handleSubmit = async () => { email: form.email, circle: form.circle, contributionTier: form.contributionTier, + agreedToGuidelines: form.agreedToGuidelines, billingAddress: form.billingAddress, }, }); @@ -961,6 +981,25 @@ onUnmounted(() => { color: var(--candle-dim); } +/* ---- CHECKBOX ---- */ +.checkbox-label { + display: flex; + align-items: flex-start; + gap: 8px; + cursor: pointer; + font-size: 12px; + color: var(--text-dim); + line-height: 1.5; +} +.checkbox-label input { + margin-top: 3px; + flex-shrink: 0; +} +.checkbox-label a, +.checkbox-label :deep(a) { + color: var(--candle); +} + /* ---- ERROR & SUCCESS BOXES ---- */ .error-box { border: 1px dashed var(--ember); diff --git a/app/pages/policies/[slug].vue b/app/pages/policies/[slug].vue new file mode 100644 index 0000000..17e3ac9 --- /dev/null +++ b/app/pages/policies/[slug].vue @@ -0,0 +1,79 @@ + + + + + diff --git a/app/pages/policies/privacy.vue b/app/pages/policies/privacy.vue new file mode 100644 index 0000000..97934fa --- /dev/null +++ b/app/pages/policies/privacy.vue @@ -0,0 +1,328 @@ + + + + + diff --git a/app/pages/policies/terms.vue b/app/pages/policies/terms.vue new file mode 100644 index 0000000..e46a684 --- /dev/null +++ b/app/pages/policies/terms.vue @@ -0,0 +1,357 @@ + + + + + diff --git a/nuxt.config.ts b/nuxt.config.ts index ed13830..b3190cf 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -36,6 +36,20 @@ export default defineNuxtConfig({ build: { transpile: ["vue-cal"], }, + routeRules: { + "/policies/code-of-conduct": { + redirect: { + to: "https://publish.obsidian.md/baby-ghosts-corp-docs/Public/Policies/Code+of+Conduct", + statusCode: 302, + }, + }, + "/policies/conflict-resolution": { + redirect: { + to: "https://publish.obsidian.md/baby-ghosts-corp-docs/Public/Policies/Conflict+Resolution+Policy", + statusCode: 302, + }, + }, + }, plausible: { domain: "ghostguild.org", }, diff --git a/server/api/helcim/customer.post.js b/server/api/helcim/customer.post.js index 2120053..db22b2a 100644 --- a/server/api/helcim/customer.post.js +++ b/server/api/helcim/customer.post.js @@ -1,5 +1,4 @@ // Create a Helcim customer -import jwt from 'jsonwebtoken' import Member from '../../models/member.js' import { connectDB } from '../../utils/mongoose.js' import { createHelcimCustomer } from '../../utils/helcim.js' @@ -7,7 +6,6 @@ import { createHelcimCustomer } from '../../utils/helcim.js' export default defineEventHandler(async (event) => { try { await connectDB() - const config = useRuntimeConfig(event) const body = await validateBody(event, helcimCustomerSchema) // Check if member already exists @@ -33,34 +31,16 @@ export default defineEventHandler(async (event) => { circle: body.circle, contributionTier: body.contributionTier, helcimCustomerId: customerData.id, - status: 'pending_payment' + status: 'pending_payment', + agreement: { acceptedAt: new Date() } }) - // Generate JWT token for the session - const token = jwt.sign( - { - memberId: member._id, - email: body.email, - helcimCustomerId: customerData.id - }, - config.jwtSecret, - { expiresIn: '7d' } - ) + setAuthCookie(event, member) - // Set the session cookie server-side - setCookie(event, 'auth-token', token, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 60 * 24 * 7, // 7 days (matches verify.get.js and refresh.post.js) - path: '/', - domain: undefined // Let browser set domain automatically - }) return { success: true, customerId: customerData.id, customerCode: customerData.customerCode, - token, member: { id: member._id, email: member.email, diff --git a/server/api/invite/accept.post.js b/server/api/invite/accept.post.js index 278c1c3..6497709 100644 --- a/server/api/invite/accept.post.js +++ b/server/api/invite/accept.post.js @@ -46,6 +46,7 @@ export default defineEventHandler(async (event) => { contributionTier: body.contributionTier, bio: body.motivation || undefined, status: body.contributionTier === '0' ? 'active' : 'pending_payment', + agreement: { acceptedAt: new Date() }, }) await assignMemberNumber(member._id) diff --git a/server/models/member.js b/server/models/member.js index 1480eb8..f34abd6 100644 --- a/server/models/member.js +++ b/server/models/member.js @@ -83,6 +83,10 @@ const memberSchema = new mongoose.Schema({ inviteEmailSent: { type: Boolean, default: false }, inviteEmailSentAt: Date, + agreement: { + acceptedAt: Date, + }, + // Magic link single-use enforcement magicLinkJti: String, magicLinkJtiUsed: { type: Boolean, default: false }, diff --git a/server/utils/schemas.js b/server/utils/schemas.js index 24a8ff8..6e1abc4 100644 --- a/server/utils/schemas.js +++ b/server/utils/schemas.js @@ -57,7 +57,8 @@ export const helcimCustomerSchema = z.object({ name: z.string().min(1).max(200), email: z.string().trim().toLowerCase().email(), circle: z.enum(['community', 'founder', 'practitioner']).optional(), - contributionTier: z.enum(['0', '5', '15', '30', '50']).optional() + contributionTier: z.enum(['0', '5', '15', '30', '50']).optional(), + agreedToGuidelines: z.literal(true) }) export const helcimInitializePaymentSchema = z.object({ @@ -147,7 +148,7 @@ export const seriesTicketPurchaseSchema = z.object({ name: z.string().min(1).max(200), email: z.string().trim().toLowerCase().email(), paymentId: z.string().max(500).optional(), - ticketType: z.enum(['member', 'public', 'guest']).optional(), + ticketType: z.enum(['member', 'public', 'guest']), }) export const seriesTicketEligibilitySchema = z.object({ @@ -345,7 +346,7 @@ export const inviteAcceptSchema = z.object({ circle: z.enum(['community', 'founder', 'practitioner']), motivation: z.string().max(5000).optional(), contributionTier: z.enum(['0', '5', '15', '30', '50']), - agreedToTerms: z.literal(true), + agreedToGuidelines: z.literal(true), token: z.string().min(1) }) diff --git a/tests/server/api/validation-phase3.test.js b/tests/server/api/validation-phase3.test.js index a707fee..fd63e78 100644 --- a/tests/server/api/validation-phase3.test.js +++ b/tests/server/api/validation-phase3.test.js @@ -80,7 +80,8 @@ describe('helcimCustomerSchema', () => { it('accepts valid customer data', () => { const result = helcimCustomerSchema.safeParse({ name: 'Jane Doe', - email: 'jane@example.com' + email: 'jane@example.com', + agreedToGuidelines: true }) expect(result.success).toBe(true) }) @@ -88,7 +89,8 @@ describe('helcimCustomerSchema', () => { it('lowercases email', () => { const result = helcimCustomerSchema.safeParse({ name: 'Jane', - email: 'JANE@Example.COM' + email: 'JANE@Example.COM', + agreedToGuidelines: true }) expect(result.success).toBe(true) expect(result.data.email).toBe('jane@example.com') @@ -97,7 +99,8 @@ describe('helcimCustomerSchema', () => { it('rejects invalid email', () => { const result = helcimCustomerSchema.safeParse({ name: 'Jane', - email: 'not-an-email' + email: 'not-an-email', + agreedToGuidelines: true }) expect(result.success).toBe(false) }) @@ -106,11 +109,29 @@ describe('helcimCustomerSchema', () => { const result = helcimCustomerSchema.safeParse({ name: 'Jane', email: 'jane@example.com', + agreedToGuidelines: true, role: 'admin' }) expect(result.success).toBe(true) expect(result.data).not.toHaveProperty('role') }) + + it('rejects missing agreedToGuidelines', () => { + const result = helcimCustomerSchema.safeParse({ + name: 'Jane', + email: 'jane@example.com' + }) + expect(result.success).toBe(false) + }) + + it('rejects agreedToGuidelines:false', () => { + const result = helcimCustomerSchema.safeParse({ + name: 'Jane', + email: 'jane@example.com', + agreedToGuidelines: false + }) + expect(result.success).toBe(false) + }) }) describe('helcimSubscriptionSchema', () => { @@ -297,14 +318,16 @@ describe('seriesTicketPurchaseSchema', () => { it('accepts valid series ticket purchase', () => { const result = seriesTicketPurchaseSchema.safeParse({ name: 'Buyer', - email: 'buyer@example.com' + email: 'buyer@example.com', + ticketType: 'member' }) expect(result.success).toBe(true) }) it('rejects missing name', () => { const result = seriesTicketPurchaseSchema.safeParse({ - email: 'buyer@example.com' + email: 'buyer@example.com', + ticketType: 'member' }) expect(result.success).toBe(false) }) diff --git a/tests/server/api/validation.test.js b/tests/server/api/validation.test.js index 9433a5c..c8d45ba 100644 --- a/tests/server/api/validation.test.js +++ b/tests/server/api/validation.test.js @@ -6,7 +6,8 @@ import { memberProfileUpdateSchema, eventRegistrationSchema, paymentVerifySchema, - adminEventCreateSchema + adminEventCreateSchema, + seriesTicketPurchaseSchema } from '../../../server/utils/schemas.js' import { validateBody } from '../../../server/utils/validateBody.js' @@ -183,6 +184,42 @@ describe('memberProfileUpdateSchema', () => { }) }) +describe('seriesTicketPurchaseSchema', () => { + const validBody = { + name: 'Test User', + email: 'test@example.com', + ticketType: 'member' + } + + it('accepts member, public, and guest ticket types', () => { + for (const ticketType of ['member', 'public', 'guest']) { + const result = seriesTicketPurchaseSchema.safeParse({ ...validBody, ticketType }) + expect(result.success).toBe(true) + } + }) + + it('rejects a body missing ticketType (closes pricing-mismatch gap)', () => { + const { ticketType, ...rest } = validBody + const result = seriesTicketPurchaseSchema.safeParse(rest) + expect(result.success).toBe(false) + }) + + it('rejects null ticketType', () => { + const result = seriesTicketPurchaseSchema.safeParse({ ...validBody, ticketType: null }) + expect(result.success).toBe(false) + }) + + it('rejects an unknown ticketType value', () => { + const result = seriesTicketPurchaseSchema.safeParse({ ...validBody, ticketType: 'vip' }) + expect(result.success).toBe(false) + }) + + it('rejects empty-string ticketType', () => { + const result = seriesTicketPurchaseSchema.safeParse({ ...validBody, ticketType: '' }) + expect(result.success).toBe(false) + }) +}) + // --- validateBody integration tests --- describe('validateBody', () => {