auth: training notice at registration only, versioned; one train line per room
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:
parent
4e2aeddb69
commit
e38ebda1c5
7 changed files with 134 additions and 32 deletions
15
src/app/features/room/RoomTrainingLine.tsx
Normal file
15
src/app/features/room/RoomTrainingLine.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
51
src/app/utils/trainingNotice.test.ts
Normal file
51
src/app/utils/trainingNotice.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
43
src/app/utils/trainingNotice.ts
Normal file
43
src/app/utils/trainingNotice.ts
Normal 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;
|
||||
};
|
||||
Loading…
Reference in a new issue