auth: training notice at registration only, versioned; one train line per room
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled

Login no longer shows the notice. Registration shows the ruled sentence with a
plain tick, and records {noticeVersion, acceptedAt} on submit (newcomers only;
a changed TRAINING_NOTICE_VERSION is a fresh acceptance). Each room carries one
line above the composer. The Discord sentence and "on this server" are gone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Void 2026-11-06 19:52:07 +00:00
commit e38ebda1c5
7 changed files with 134 additions and 32 deletions

View file

@ -0,0 +1,15 @@
import { Text, color, config } from 'folds';
import React from 'react';
import { TRAINING_NOTICE_TEXT } from '../../utils/trainingNotice';
// One line per room, pinned above the composer.
export function RoomTrainingLine() {
return (
<Text
size="T200"
style={{ padding: `${config.space.S100} 0`, color: color.SurfaceVariant.OnContainer }}
>
{TRAINING_NOTICE_TEXT}
</Text>
);
}

View file

@ -13,6 +13,7 @@ import { RoomTimeline } from './RoomTimeline';
import { RoomViewTyping } from './RoomViewTyping';
import { RoomTombstone } from './RoomTombstone';
import { RoomInput } from './RoomInput';
import { RoomTrainingLine } from './RoomTrainingLine';
import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
import { Page } from '../../components/page';
import { useKeyDown } from '../../hooks/useKeyDown';
@ -104,6 +105,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
</Box>
<Box shrink="No" direction="Column">
<div style={{ padding: `0 ${config.space.S400}` }}>
<RoomTrainingLine />
{tombstoneEvent ? (
<RoomTombstone
roomId={roomId}

View file

@ -1,15 +1,14 @@
import { Box, Checkbox, Text, color, config } from 'folds';
import React from 'react';
export const TRAINING_NOTICE =
"Everything said on this server may be used to train GameCult's AI stewards. We never ingest Discord content.";
import { TRAINING_NOTICE_TEXT } from '../../utils/trainingNotice';
type TrainingNoticeProps = {
// Registration passes this to require a ticked acknowledgement; login shows the notice only.
acknowledge?: { checked: boolean; onToggle: () => void };
checked: boolean;
onToggle: () => void;
};
export function TrainingNotice({ acknowledge }: TrainingNoticeProps) {
// Registration only: ticking it is the newcomer's acceptance of the current notice version.
export function TrainingNotice({ checked, onToggle }: TrainingNoticeProps) {
return (
<Box
direction="Column"
@ -22,24 +21,21 @@ export function TrainingNotice({ acknowledge }: TrainingNoticeProps) {
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
}}
>
<Text size="T300">{TRAINING_NOTICE}</Text>
{acknowledge && (
<Text
as="label"
size="T300"
style={{ display: 'flex', alignItems: 'center', gap: config.space.S200 }}
>
<Checkbox
name="trainingAck"
size="300"
variant="Primary"
required
checked={acknowledge.checked}
onClick={acknowledge.onToggle}
/>
I understand and agree.
</Text>
)}
<Text
as="label"
size="T300"
style={{ display: 'flex', alignItems: 'center', gap: config.space.S200 }}
>
<Checkbox
name="trainingAck"
size="300"
variant="Primary"
required
checked={checked}
onClick={onToggle}
/>
{TRAINING_NOTICE_TEXT}
</Text>
</Box>
);
}

View file

@ -35,7 +35,6 @@ import {
} from './loginUtil';
import { PasswordInput } from '../../../components/password-input';
import { FieldError } from '../FiledError';
import { TrainingNotice } from '../TrainingNotice';
import { getResetPasswordPath } from '../../pathUtils';
import { stopPropagation } from '../../../utils/keyboard';
@ -256,7 +255,6 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
</Box>
</Box>
</Box>
<TrainingNotice />
<Button type="submit" variant="Primary" size="500">
<Text as="span" size="B500">
Login

View file

@ -33,6 +33,7 @@ import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
import { RegisterError, RegisterResult, register, useRegisterComplete } from './registerUtil';
import { FieldError } from '../FiledError';
import { TrainingNotice } from '../TrainingNotice';
import { recordNoticeAcceptance } from '../../../utils/trainingNotice';
import {
AutoDummyStageDialog,
AutoTermsStageDialog,
@ -242,6 +243,7 @@ export function PasswordRegisterForm({
terms,
clientSecret: mx.generateClientSecret(),
};
recordNoticeAcceptance(localStorage);
const pickedStages = pickStages(uiaFlows, fData);
const pickedFlow = getUIAFlowForStages(uiaFlows, pickedStages);
setOngoingFlow(pickedFlow);
@ -391,12 +393,7 @@ export function PasswordRegisterForm({
<FieldError message={registerError.data.error ?? 'Failed to register. Unknown Reason.'} />
)}
<span data-spacing-node />
<TrainingNotice
acknowledge={{
checked: trainingAck,
onToggle: () => setTrainingAck((ack) => !ack),
}}
/>
<TrainingNotice checked={trainingAck} onToggle={() => setTrainingAck((ack) => !ack)} />
<Button variant="Primary" size="500" type="submit" disabled={!trainingAck}>
<Text as="span" size="B500">
Register

View file

@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import {
TRAINING_NOTICE_TEXT,
TRAINING_NOTICE_VERSION,
getNoticeAcceptance,
needsFreshAcceptance,
recordNoticeAcceptance,
} from './trainingNotice';
const memoryStorage = () => {
const data = new Map<string, string>();
return {
getItem: (k: string) => data.get(k) ?? null,
setItem: (k: string, v: string) => void data.set(k, v),
};
};
describe('training notice', () => {
it('says exactly the ruled sentence and never mentions Discord', () => {
expect(TRAINING_NOTICE_TEXT).toBe(
"Everything said here may be used to train GameCult's AI stewards."
);
expect(TRAINING_NOTICE_TEXT.toLowerCase()).not.toContain('discord');
});
it('records the current version and time on acceptance', () => {
const storage = memoryStorage();
recordNoticeAcceptance(storage, new Date('2026-11-06T20:00:00Z'));
expect(getNoticeAcceptance(storage)).toEqual({
noticeVersion: TRAINING_NOTICE_VERSION,
acceptedAt: '2026-11-06T20:00:00.000Z',
});
expect(needsFreshAcceptance(storage)).toBe(false);
});
it('asks again when the notice version changes', () => {
const storage = memoryStorage();
recordNoticeAcceptance(storage, new Date(), TRAINING_NOTICE_VERSION);
expect(needsFreshAcceptance(storage, TRAINING_NOTICE_VERSION + 1)).toBe(true);
});
it('does not re-ask members who never registered here (no record)', () => {
expect(needsFreshAcceptance(memoryStorage())).toBe(false);
});
it('treats a corrupt record as none', () => {
const storage = memoryStorage();
storage.setItem('trainingNoticeAcceptance', '{nope');
expect(getNoticeAcceptance(storage)).toBeUndefined();
});
});

View file

@ -0,0 +1,43 @@
export const TRAINING_NOTICE_TEXT =
"Everything said here may be used to train GameCult's AI stewards.";
// Any change to the wording or scope is a new version, and a new version needs a fresh acceptance.
export const TRAINING_NOTICE_VERSION = 1;
const ACCEPTANCE_KEY = 'trainingNoticeAcceptance';
export type NoticeAcceptance = { noticeVersion: number; acceptedAt: string };
type NoticeStorage = Pick<Storage, 'getItem' | 'setItem'>;
/** Written at registration only, so only newcomers have one; earlier members are never re-asked. */
export const recordNoticeAcceptance = (
storage: NoticeStorage,
now: Date = new Date(),
noticeVersion: number = TRAINING_NOTICE_VERSION
): NoticeAcceptance => {
const acceptance = { noticeVersion, acceptedAt: now.toISOString() };
storage.setItem(ACCEPTANCE_KEY, JSON.stringify(acceptance));
return acceptance;
};
export const getNoticeAcceptance = (storage: NoticeStorage): NoticeAcceptance | undefined => {
try {
const parsed = JSON.parse(storage.getItem(ACCEPTANCE_KEY) ?? 'null');
if (typeof parsed?.noticeVersion === 'number' && typeof parsed?.acceptedAt === 'string') {
return parsed;
}
} catch {
// unreadable record counts as none
}
return undefined;
};
/** True when a newcomer's recorded acceptance is for an older notice version and must be renewed. */
export const needsFreshAcceptance = (
storage: NoticeStorage,
currentVersion: number = TRAINING_NOTICE_VERSION
): boolean => {
const accepted = getNoticeAcceptance(storage);
return accepted !== undefined && accepted.noticeVersion !== currentVersion;
};