Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/community/app/(auth)/register/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AuthPage } from "@/components/auth/auth-page"
import { RegisterForm } from "@/components/auth/register-form"
import { issueChallenge } from "@/server/antispam"
import { boardAuthConfig } from "@/server/auth-config"
import { termsAcceptance } from "@/server/legal"
import { registrationFields } from "@/server/profile-fields"

export const metadata: Metadata = { title: "Create account" }
Expand All @@ -13,6 +14,8 @@ export default async function RegisterPage() {

const { minPasswordLength, usernameMin, usernameMax } = await boardAuthConfig()

const terms = await termsAcceptance()

const customFields = (await registrationFields()).map((field) => ({
...field,
value: "",
Expand All @@ -28,6 +31,7 @@ export default async function RegisterPage() {
<RegisterForm
customFields={customFields}
limits={{ minPasswordLength, usernameMin, usernameMax }}
terms={terms}
challenge={{
prompt: issued.challenge?.prompt ?? null,
token: issued.challenge?.token ?? '',
Expand Down
9 changes: 9 additions & 0 deletions apps/community/app/(board)/privacy/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { Metadata } from 'next'

import { LegalDocument } from '@/components/legal/legal-document'

export const metadata: Metadata = { title: 'Privacy policy' }

export default async function PrivacyPage() {
return <LegalDocument slug="privacy" />
}
9 changes: 9 additions & 0 deletions apps/community/app/(board)/terms/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { Metadata } from 'next'

import { LegalDocument } from '@/components/legal/legal-document'

export const metadata: Metadata = { title: 'Terms of service' }

export default async function TermsPage() {
return <LegalDocument slug="terms" />
}
32 changes: 32 additions & 0 deletions apps/community/src/components/auth/register-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,21 @@ export interface RegistrationLimits {
readonly usernameMax: number
}

export interface TermsInput {
readonly label: string
readonly href: string
}

export function RegisterForm({
customFields = [],
challenge,
limits,
terms = null,
}: {
customFields?: readonly CustomFieldInput[]
challenge?: ChallengeInput
limits: RegistrationLimits
terms?: TermsInput | null | undefined
}) {
const [state, action] = useActionState(registerAction, EMPTY_STATE)
return (
Expand Down Expand Up @@ -95,6 +102,31 @@ export function RegisterForm({
</>
)}

{terms !== null && (
<label className="flex items-start gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
name="terms"
value="1"
required
defaultChecked={state.values?.terms === "1"}
className="mt-0.5 size-4 rounded border-input accent-primary"
/>
<span>
I have read and accept the{" "}
<a
href={terms.href}
target="_blank"
rel="noreferrer"
className="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
>
{terms.label}
</a>
.
</span>
</label>
)}

<SubmitButton>Create account</SubmitButton>
</form>
)
Expand Down
25 changes: 25 additions & 0 deletions apps/community/src/components/legal/legal-document.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { notFound } from 'next/navigation'

import { Card, CardContent } from '@meith/ui'

import { PanelPage } from '@/components/shell/panel-page'
import { legalDocument } from '@/server/legal'
import type { LegalSlug } from '@/view/legal'

export async function LegalDocument({ slug }: { slug: LegalSlug }) {
const document = await legalDocument(slug)
if (document === null) notFound()

return (
<PanelPage frame="standalone" title={document.title}>
<Card>
<CardContent className="p-4">
<div
className="prose-md"
dangerouslySetInnerHTML={{ __html: document.bodyHtml }}
/>
</CardContent>
</Card>
</PanelPage>
)
}
3 changes: 2 additions & 1 deletion apps/community/src/components/shell/page-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { LogoutForm } from '@/components/account/logout-form'
import { currentLogo } from '@/server/branding'
import { ThemeSwitcher } from '@/components/shell/theme-switcher'
import { getContainer } from '@/server/container'
import { legalFooterLinks } from '@/server/legal'
import { unreadMessageCount } from '@/server/messages'
import { touchActivity } from '@/server/relations'
import { touchCurrentLocation } from '@/server/presence'
Expand Down Expand Up @@ -97,7 +98,7 @@ export async function PageShell({
)
const footerModel = await filterView(
'view.footer',
buildFooterModel([], boardTitle, preferences.timezone),
buildFooterModel(await legalFooterLinks(), boardTitle, preferences.timezone),
pluginContext,
)

Expand Down
30 changes: 29 additions & 1 deletion apps/community/src/server/auth-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ vi.mock('./antispam', async (importOriginal) => {
}
})

const legal = vi.hoisted(() => ({ terms: 'Be nice to each other.' }))

vi.mock('./legal', () => ({
termsAcceptance: async () =>
legal.terms.trim() === '' ? null : { label: 'Terms of service', href: '/terms' },
}))

vi.mock('./auth-config', async (importOriginal) => {
const actual = await importOriginal<typeof AuthConfigModule>()
return {
Expand Down Expand Up @@ -112,7 +119,12 @@ async function redirectOf(run: Promise<unknown>): Promise<string> {
throw new Error('expected the action to redirect, but it returned')
}

const CREDS = { username: 'ivan', email: 'ivan@example.com', password: 'correct-horse' }
const CREDS = {
username: 'ivan',
email: 'ivan@example.com',
password: 'correct-horse',
terms: '1',
}

async function registerUser(over: Partial<typeof CREDS> = {}): Promise<void> {
await redirectOf(registerAction(EMPTY_STATE, form({ ...CREDS, ...over })))
Expand All @@ -127,6 +139,7 @@ beforeEach(() => {
mail.failReset = false
policy.activationMethod = 'none'
limiter.refuse = false
legal.terms = 'Be nice to each other.'
})

describe('registerAction', () => {
Expand Down Expand Up @@ -154,6 +167,21 @@ describe('registerAction', () => {
expect(state.error).toBeTruthy()
expect(JSON.stringify(state)).not.toContain(CREDS.password)
})

it('refuses an account when the terms were not accepted', async () => {
const state = await registerAction(EMPTY_STATE, form({ ...CREDS, terms: '' }))

expect(state.error).toContain('terms of service')
expect(state.values?.terms).toBeUndefined()
})

it('creates the account when the board publishes no terms', async () => {
legal.terms = ''

expect(
await redirectOf(registerAction(EMPTY_STATE, form({ ...CREDS, terms: '' }))),
).toBe('/login?registered=1')
})
})

describe('loginAction', () => {
Expand Down
12 changes: 11 additions & 1 deletion apps/community/src/server/auth-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { spendResendLimit, verifyChallenge } from './antispam'
import { sendPasswordResetEmail, sendVerificationEmail } from './auth-mail'
import { configuredIdentity, getContainer } from './container'
import { formStateReporter } from './form-state-reporter'
import { termsAcceptance } from './legal'
import {
profileFieldService,
registrationFieldContext,
Expand Down Expand Up @@ -51,13 +52,22 @@ export async function registerAction(
const username = field(form, 'username')
const email = field(form, 'email')
const password = field(form, 'password')
const values = { username, email }
const accepted = field(form, 'terms') !== ''
const values = { username, email, ...(accepted ? { terms: '1' } : {}) }

const identity = await configuredIdentity()

let verification: { token: string; email: string; username: string } | null = null

try {
const terms = await termsAcceptance()
if (terms !== null && !accepted) {
return {
error: `Please read the ${terms.label.toLowerCase()} and tick the box to accept it.`,
values,
}
}

const challenge = await verifyChallenge(form)
if (!challenge.ok) return { error: challenge.reason, values }

Expand Down
48 changes: 48 additions & 0 deletions apps/community/src/server/legal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import 'server-only'

import { renderMarkdown } from '@meith/markdown'
import type { LinkModel } from '@meith/theme-kit'

import { getSettings } from './settings'
import {
TERMS_PAGE,
buildLegalLinks,
isPublished,
legalPage,
type LegalPage,
} from '../view/legal'

export interface LegalDocument {
readonly title: string
readonly href: string
readonly bodyHtml: string
}

async function bodyOf(page: LegalPage): Promise<string> {
return (await getSettings()).get(page.settingKey)
}

export async function legalDocument(slug: string): Promise<LegalDocument | null> {
const page = legalPage(slug)
if (page === null) return null

const body = await bodyOf(page)
if (!isPublished(body)) return null

return {
title: page.title,
href: page.href,
bodyHtml: renderMarkdown(body, { headingOffset: 0 }).html,
}
}

export async function legalFooterLinks(): Promise<readonly LinkModel[]> {
const settings = await getSettings()
return buildLegalLinks((page) => settings.get(page.settingKey))
}

export async function termsAcceptance(): Promise<LinkModel | null> {
return isPublished(await bodyOf(TERMS_PAGE))
? { label: TERMS_PAGE.title, href: TERMS_PAGE.href }
: null
}
48 changes: 48 additions & 0 deletions apps/community/src/view/legal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'

import { LEGAL_PAGES, buildLegalLinks, isPublished, legalPage } from './legal'

const bodies = (values: Record<string, string>) =>
buildLegalLinks((page) => values[page.settingKey] ?? '')

describe('legalPage', () => {
it('answers for the slugs the board publishes', () => {
expect(legalPage('terms')?.href).toBe('/terms')
expect(legalPage('privacy')?.href).toBe('/privacy')
})

it('answers with nothing for anything else', () => {
expect(legalPage('cookies')).toBeNull()
})

it('names a setting for every page', () => {
for (const page of LEGAL_PAGES) expect(page.settingKey).toMatch(/^legal\./)
})
})

describe('isPublished', () => {
it('treats an empty body, and one that is only whitespace, as unpublished', () => {
expect(isPublished('')).toBe(false)
expect(isPublished(' \n ')).toBe(false)
expect(isPublished('## Terms')).toBe(true)
})
})

describe('buildLegalLinks', () => {
it('links the pages that have a body', () => {
expect(bodies({ 'legal.terms': 'terms', 'legal.privacy': 'privacy' })).toEqual([
{ label: 'Terms', href: '/terms' },
{ label: 'Privacy', href: '/privacy' },
])
})

it('leaves out a page whose body was emptied', () => {
expect(bodies({ 'legal.terms': 'terms' })).toEqual([
{ label: 'Terms', href: '/terms' },
])
})

it('links nothing when the board publishes neither', () => {
expect(bodies({})).toEqual([])
})
})
47 changes: 47 additions & 0 deletions apps/community/src/view/legal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { LinkModel } from '@meith/theme-kit'

export type LegalSlug = 'terms' | 'privacy'

export interface LegalPage {
readonly slug: LegalSlug
readonly settingKey: 'legal.terms' | 'legal.privacy'
readonly title: string
readonly href: string
readonly footerLabel: string
}

export const LEGAL_PAGES: readonly LegalPage[] = [
{
slug: 'terms',
settingKey: 'legal.terms',
title: 'Terms of service',
href: '/terms',
footerLabel: 'Terms',
},
{
slug: 'privacy',
settingKey: 'legal.privacy',
title: 'Privacy policy',
href: '/privacy',
footerLabel: 'Privacy',
},
]

export const TERMS_PAGE: LegalPage = LEGAL_PAGES[0]!

export function legalPage(slug: string): LegalPage | null {
return LEGAL_PAGES.find((page) => page.slug === slug) ?? null
}

export function isPublished(body: string): boolean {
return body.trim() !== ''
}

export function buildLegalLinks(
bodyOf: (page: LegalPage) => string,
): readonly LinkModel[] {
return LEGAL_PAGES.filter((page) => isPublished(bodyOf(page))).map((page) => ({
label: page.footerLabel,
href: page.href,
}))
}
2 changes: 2 additions & 0 deletions apps/community/src/view/setting-groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const GROUP_LABELS: Record<SettingGroup, string> = {
reputation: 'Reputation',
security: 'Security',
antispam: 'Anti-spam',
legal: 'Legal',
}

export const GROUP_ORDER: readonly SettingGroup[] = [
Expand All @@ -22,6 +23,7 @@ export const GROUP_ORDER: readonly SettingGroup[] = [
'mail',
'security',
'antispam',
'legal',
]

export const DEFAULT_SETTING_GROUP: SettingGroup = GROUP_ORDER[0]!
Expand Down
Loading
Loading