Production deploy completed (DigitalOcean Droplet, self-hosted Supabase, Caddy + Let's Encrypt, GitHub Actions auto-deploy on push to main, daily pg_dumpall backups). This commit lands the work that came out of the deep audit pass and the open-source release prep. Audit fixes: - Webhook signing secret is now displayed in the settings UI with show/hide and copy-to-clipboard; the broken external docs.hackerspace.sh link is replaced with a link to the new local docs. - Settings updateSpaceSettings parameter type now includes mission_statement so the field persists. - members page enforces member_directory_visibility for non-admin viewers (admin_only, board_visible, members_visible). - Mobile responsiveness pass: Projects Kanban grid, Members + Payments tables (column hiding on small screens), Proposal tally + voting buttons (2-col on mobile). Open-source release scaffolding: - LICENSE (MIT). - CONTRIBUTING.md with dev setup, conventions, PR flow. - SECURITY.md with disclosure policy. - CHANGELOG.md. - docs/WEBHOOKS.md with Node and Python signature verification examples. - README.md rewritten for an open-source audience. Removed v0/Vercel references: - @vercel/analytics dependency removed. - app/layout.tsx no longer imports Analytics, drops generator: 'v0.app'. - Canonical deployment docs (docs/DEPLOYMENT.md, docs/README.md, docs/ARCHITECTURE.md, DATABASE.md, CLAUDE.md, README.md) now describe the self-hosted Droplet target only. - e2e console-error check no longer filters [v0] noise. Landing page: - GitHub icon link to the public repo in the nav and footer.
146 lines
4.7 KiB
TypeScript
146 lines
4.7 KiB
TypeScript
'use server'
|
|
|
|
import { revalidatePath } from 'next/cache'
|
|
import { createClient } from '@/lib/supabase/server'
|
|
import { requireMemberWithRole, parseInput } from '@/lib/auth-helpers'
|
|
import {
|
|
updateSpaceSettingsSchema,
|
|
saveIntegrationSchema,
|
|
updateSpaceVisibilitySchema,
|
|
} from '@/lib/validations'
|
|
|
|
const ADMIN_ONLY = ['admin'] as const
|
|
|
|
export async function updateSpaceSettings(updates: {
|
|
name?: string
|
|
slug?: string
|
|
city?: string | null
|
|
require_approval?: boolean
|
|
public_member_directory?: boolean
|
|
mission_statement?: string | null
|
|
}) {
|
|
const v = parseInput(updateSpaceSettingsSchema, updates)
|
|
if (!v.ok) return { error: v.error }
|
|
|
|
const supabase = await createClient()
|
|
const auth = await requireMemberWithRole(supabase, ADMIN_ONLY, 'Admin access required')
|
|
if (!auth.ok) return { error: auth.error }
|
|
const { member } = auth
|
|
|
|
const { error } = await supabase
|
|
.from('spaces')
|
|
.update({ ...v.data, updated_at: new Date().toISOString() })
|
|
.eq('id', member.space_id)
|
|
|
|
if (error) return { error: error.message }
|
|
revalidatePath('/settings')
|
|
return { success: true as const }
|
|
}
|
|
|
|
export async function rotateWebhookSecret() {
|
|
const supabase = await createClient()
|
|
const auth = await requireMemberWithRole(supabase, ADMIN_ONLY, 'Admin access required')
|
|
if (!auth.ok) return { error: auth.error }
|
|
const { member } = auth
|
|
|
|
const bytes = crypto.getRandomValues(new Uint8Array(24))
|
|
const newSecret =
|
|
'whsec_' +
|
|
Array.from(bytes)
|
|
.map(b => b.toString(16).padStart(2, '0'))
|
|
.join('')
|
|
|
|
const { error } = await supabase
|
|
.from('spaces')
|
|
.update({ webhook_secret: newSecret, updated_at: new Date().toISOString() })
|
|
.eq('id', member.space_id)
|
|
|
|
if (error) return { error: error.message }
|
|
revalidatePath('/settings')
|
|
return { secret: newSecret }
|
|
}
|
|
|
|
export async function saveIntegration(platform: string, config: Record<string, string>) {
|
|
const v = parseInput(saveIntegrationSchema, { platform, config })
|
|
if (!v.ok) return { error: v.error }
|
|
|
|
const supabase = await createClient()
|
|
const auth = await requireMemberWithRole(supabase, ADMIN_ONLY, 'Admin access required')
|
|
if (!auth.ok) return { error: auth.error }
|
|
const { member } = auth
|
|
|
|
// Store provided values; also store boolean "_set" flags so the UI can show
|
|
// a "connected" state without leaking the secret back to the client.
|
|
const safeConfig: Record<string, string> = {}
|
|
for (const [key, value] of Object.entries(v.data.config)) {
|
|
safeConfig[key] = value ?? ''
|
|
safeConfig[`${key}_set`] = value && value.length > 0 ? 'true' : 'false'
|
|
}
|
|
|
|
const { error } = await supabase.from('integrations').upsert(
|
|
{
|
|
space_id: member.space_id,
|
|
platform: v.data.platform,
|
|
name: v.data.platform,
|
|
is_connected: Object.values(v.data.config).some(value => value && value.length > 0),
|
|
config: safeConfig,
|
|
updated_at: new Date().toISOString(),
|
|
},
|
|
{ onConflict: 'space_id,platform' },
|
|
)
|
|
|
|
if (error) return { error: error.message }
|
|
revalidatePath('/settings')
|
|
return { success: true as const }
|
|
}
|
|
|
|
/**
|
|
* Per-space visibility flags. Lets admins decide whether financial data is
|
|
* visible to all members, board only, or treasurer-only; and whether the
|
|
* member directory is fully visible, count-only, or board-only.
|
|
*/
|
|
export async function updateSpaceVisibility(updates: {
|
|
financial_visibility?: string
|
|
member_directory_visibility?: string
|
|
}) {
|
|
const v = parseInput(updateSpaceVisibilitySchema, updates)
|
|
if (!v.ok) return { error: v.error }
|
|
|
|
const supabase = await createClient()
|
|
const auth = await requireMemberWithRole(supabase, ADMIN_ONLY, 'Admin access required')
|
|
if (!auth.ok) return { error: auth.error }
|
|
const { member } = auth
|
|
|
|
const { error } = await supabase
|
|
.from('spaces')
|
|
.update({ ...v.data, updated_at: new Date().toISOString() })
|
|
.eq('id', member.space_id)
|
|
|
|
if (error) return { error: error.message }
|
|
revalidatePath('/settings')
|
|
revalidatePath('/financials')
|
|
revalidatePath('/members')
|
|
return { success: true as const }
|
|
}
|
|
|
|
export async function disconnectIntegration(platform: string) {
|
|
if (typeof platform !== 'string' || platform.length === 0 || platform.length > 50) {
|
|
return { error: 'Invalid platform' }
|
|
}
|
|
|
|
const supabase = await createClient()
|
|
const auth = await requireMemberWithRole(supabase, ADMIN_ONLY, 'Admin access required')
|
|
if (!auth.ok) return { error: auth.error }
|
|
const { member } = auth
|
|
|
|
const { error } = await supabase
|
|
.from('integrations')
|
|
.update({ is_connected: false, config: {}, updated_at: new Date().toISOString() })
|
|
.eq('space_id', member.space_id)
|
|
.eq('platform', platform)
|
|
|
|
if (error) return { error: error.message }
|
|
revalidatePath('/settings')
|
|
return { success: true as const }
|
|
}
|