feat(privacy): per-session consent modal on first authenticated entry
Sprint 5. Pops a non-dismissable modal once per browser tab session
after the user lands on any /_authenticated/* route. Covers two
user-visible policy points the product team called out:
1. Project data: Heicode does NOT guarantee against project loss.
Users must back up to their own Git / local storage.
2. Model privacy: when calling third-party models (OpenAI /
Anthropic / Google etc.), each vendor's privacy, retention,
and training-use terms apply. Heicode does NOT modify those
terms and makes no privacy promises on the vendors' behalf.
Design:
- Pure frontend, no schema migration, no backend endpoint. The
"show every login" requirement is satisfied by sessionStorage
(cleared when the tab closes); persisting acceptance server-
side would force a forced-consent log we don't need yet.
- Modal is intentionally non-dismissable (no overlay close, no
Escape key, no X button). User must explicitly Agree or
Decline.
- Decline triggers auth.reset() + redirect to /sign-in — same
logout path the sidebar uses.
- Agree button stays disabled until the acknowledgement checkbox
is ticked.
- i18n localized en + zh.
If we later need an auditable consent trail (e.g. regulator asks
"prove user X clicked agree on date Y"), promote this to a DB-backed
flow with a users.accepted_privacy_at column and a POST endpoint.
Until then sessionStorage is the right scope.
Verification:
- tsc --noEmit clean
- mounted at AuthenticatedLayout — every authenticated route hits
it; sign-in / sign-up / public pages do not
- sessionStorage flag survives navigation within a tab, clears on
tab close — matches "弹一次每次登录" requirement
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import { AnimatedOutlet } from '@/components/page-transition'
|
||||
import { SkipToMain } from '@/components/skip-to-main'
|
||||
import { WorkspaceProvider } from '../context/workspace-context'
|
||||
import { AppSidebar } from './app-sidebar'
|
||||
import { PrivacyAcknowledgeDialog } from './privacy-acknowledge-dialog'
|
||||
|
||||
type AuthenticatedLayoutProps = {
|
||||
children?: React.ReactNode
|
||||
@@ -33,6 +34,10 @@ export function AuthenticatedLayout(props: AuthenticatedLayoutProps) {
|
||||
>
|
||||
{props.children ?? <AnimatedOutlet />}
|
||||
</SidebarInset>
|
||||
{/* Per-session privacy reminder. Mounted at the layout
|
||||
level so every authenticated route shows it exactly
|
||||
once per browser tab session. */}
|
||||
<PrivacyAcknowledgeDialog />
|
||||
</SidebarProvider>
|
||||
</WorkspaceProvider>
|
||||
</SearchProvider>
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AlertTriangle, ShieldCheck } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
// Per-session privacy reminder. Pops once after the user lands on any
|
||||
// /_authenticated/* route and isn't dismissed for the rest of the
|
||||
// browser tab's lifetime. Closing + reopening the tab triggers it
|
||||
// again, which is the intentional "every login = one acknowledge"
|
||||
// behaviour the team wanted.
|
||||
//
|
||||
// We deliberately don't persist acceptance server-side. The product
|
||||
// requirement is "show every login" — sessionStorage matches that
|
||||
// without the schema migration + endpoint surface a real consent log
|
||||
// would need. If we later need an auditable consent trail we can
|
||||
// graduate this to a DB-backed flow.
|
||||
const SESSION_KEY = 'heicode_privacy_acknowledged_v1'
|
||||
|
||||
function hasAcknowledgedThisSession(): boolean {
|
||||
if (typeof window === 'undefined') return true
|
||||
try {
|
||||
return window.sessionStorage.getItem(SESSION_KEY) === '1'
|
||||
} catch {
|
||||
// sessionStorage may throw in privacy modes / SSR. Fail open so
|
||||
// the modal doesn't loop infinitely; user can still click Agree.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function markAcknowledgedThisSession(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.sessionStorage.setItem(SESSION_KEY, '1')
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
export function PrivacyAcknowledgeDialog() {
|
||||
const { t } = useTranslation()
|
||||
const auth = useAuthStore((s) => s.auth)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [checked, setChecked] = useState(false)
|
||||
|
||||
// Wait until we actually have an authenticated user before opening.
|
||||
// Otherwise the modal can flash during the sign-in redirect.
|
||||
useEffect(() => {
|
||||
if (!auth.user) {
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
if (hasAcknowledgedThisSession()) {
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
setOpen(true)
|
||||
}, [auth.user])
|
||||
|
||||
const handleAgree = () => {
|
||||
if (!checked) return
|
||||
markAcknowledgedThisSession()
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Decline = sign the user out. Same path the sidebar logout uses —
|
||||
// auth.reset() clears the store + localStorage 'user' key; the next
|
||||
// navigation will fail the _authenticated guard and redirect to
|
||||
// /sign-in.
|
||||
const handleDecline = () => {
|
||||
markAcknowledgedThisSession() // suppress re-pop during the redirect
|
||||
auth.reset()
|
||||
setOpen(false)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/sign-in'
|
||||
}
|
||||
}
|
||||
|
||||
if (!auth.user) return null
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
// Modal is intentionally non-dismissable via overlay click or
|
||||
// Escape key — user must explicitly choose Agree or Decline.
|
||||
onOpenChange={(next) => {
|
||||
if (!next) return // ignore "request close" events
|
||||
setOpen(true)
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className='max-w-lg'
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='flex items-center gap-2'>
|
||||
<ShieldCheck className='h-5 w-5 text-primary' />
|
||||
{t('User notice and consent')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
'Please read the following two points carefully before continuing.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='space-y-3 text-sm'>
|
||||
<div className='rounded-lg border border-amber-500/30 bg-amber-500/5 p-3'>
|
||||
<p className='flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.12em] text-amber-400'>
|
||||
<AlertTriangle className='h-3.5 w-3.5' />
|
||||
{t('Project data')}
|
||||
</p>
|
||||
<p className='mt-2 leading-relaxed text-foreground'>
|
||||
{t(
|
||||
'Heicode is a development platform, not a data backup service. We do our best to keep your project content available, but we DO NOT guarantee against loss of code, conversations, configurations or other data. Please back up important content to your own Git repository or local storage.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='rounded-lg border border-sky-500/30 bg-sky-500/5 p-3'>
|
||||
<p className='flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.12em] text-sky-400'>
|
||||
<ShieldCheck className='h-3.5 w-3.5' />
|
||||
{t('Model privacy')}
|
||||
</p>
|
||||
<p className='mt-2 leading-relaxed text-foreground'>
|
||||
{t(
|
||||
'When the platform calls third-party models (OpenAI / Anthropic / Google etc.), your inputs and the model outputs are processed by the corresponding model vendor. Each vendor sets its own privacy, retention and training-use terms. Heicode does NOT modify those terms and cannot make any privacy promises on behalf of the vendors. Please review the vendor terms before submitting sensitive content.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className='flex items-start gap-2 rounded-lg border p-3 text-sm'>
|
||||
<Checkbox
|
||||
id='privacy-ack-checkbox'
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => setChecked(v === true)}
|
||||
className='mt-0.5'
|
||||
/>
|
||||
<span className='leading-relaxed text-muted-foreground'>
|
||||
{t(
|
||||
'I have read and understood both notices above. I am responsible for backing up my own data and reviewing the model vendor terms.'
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={handleDecline}
|
||||
>
|
||||
{t('Decline and sign out')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
disabled={!checked}
|
||||
onClick={handleAgree}
|
||||
>
|
||||
{t('I agree, continue')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+10
-1
@@ -4099,6 +4099,15 @@
|
||||
"Max cost": "Max cost",
|
||||
"Max duration": "Max duration",
|
||||
"min": "min",
|
||||
"Show raw payload (advanced)": "Show raw payload (advanced)"
|
||||
"Show raw payload (advanced)": "Show raw payload (advanced)",
|
||||
"User notice and consent": "User notice and consent",
|
||||
"Please read the following two points carefully before continuing.": "Please read the following two points carefully before continuing.",
|
||||
"Project data": "Project data",
|
||||
"Heicode is a development platform, not a data backup service. We do our best to keep your project content available, but we DO NOT guarantee against loss of code, conversations, configurations or other data. Please back up important content to your own Git repository or local storage.": "Heicode is a development platform, not a data backup service. We do our best to keep your project content available, but we DO NOT guarantee against loss of code, conversations, configurations or other data. Please back up important content to your own Git repository or local storage.",
|
||||
"Model privacy": "Model privacy",
|
||||
"When the platform calls third-party models (OpenAI / Anthropic / Google etc.), your inputs and the model outputs are processed by the corresponding model vendor. Each vendor sets its own privacy, retention and training-use terms. Heicode does NOT modify those terms and cannot make any privacy promises on behalf of the vendors. Please review the vendor terms before submitting sensitive content.": "When the platform calls third-party models (OpenAI / Anthropic / Google etc.), your inputs and the model outputs are processed by the corresponding model vendor. Each vendor sets its own privacy, retention and training-use terms. Heicode does NOT modify those terms and cannot make any privacy promises on behalf of the vendors. Please review the vendor terms before submitting sensitive content.",
|
||||
"I have read and understood both notices above. I am responsible for backing up my own data and reviewing the model vendor terms.": "I have read and understood both notices above. I am responsible for backing up my own data and reviewing the model vendor terms.",
|
||||
"Decline and sign out": "Decline and sign out",
|
||||
"I agree, continue": "I agree, continue"
|
||||
}
|
||||
}
|
||||
|
||||
+10
-1
@@ -4099,6 +4099,15 @@
|
||||
"Max cost": "费用上限",
|
||||
"Max duration": "时长上限",
|
||||
"min": "分钟",
|
||||
"Show raw payload (advanced)": "查看原始负载(高级)"
|
||||
"Show raw payload (advanced)": "查看原始负载(高级)",
|
||||
"User notice and consent": "用户须知与同意",
|
||||
"Please read the following two points carefully before continuing.": "继续使用前,请仔细阅读以下两条说明。",
|
||||
"Project data": "项目数据",
|
||||
"Heicode is a development platform, not a data backup service. We do our best to keep your project content available, but we DO NOT guarantee against loss of code, conversations, configurations or other data. Please back up important content to your own Git repository or local storage.": "Heicode 是开发平台,不是数据备份服务。我们会尽力保障项目内容的可用性,但**不保证**代码、对话内容、配置等数据不会丢失,**对此造成的损失不承担责任**。请把重要内容定期备份到你自己的代码仓库或本地。",
|
||||
"Model privacy": "模型隐私",
|
||||
"When the platform calls third-party models (OpenAI / Anthropic / Google etc.), your inputs and the model outputs are processed by the corresponding model vendor. Each vendor sets its own privacy, retention and training-use terms. Heicode does NOT modify those terms and cannot make any privacy promises on behalf of the vendors. Please review the vendor terms before submitting sensitive content.": "平台调用各家模型(OpenAI / Anthropic / Google 等)时,你输入的内容和模型返回的输出会经过对应原厂处理。**这些数据的隐私、保留期、是否用于训练等条款由各模型原厂自行决定**,Heicode 不修改原厂条款,也无法代表原厂做任何承诺。提交敏感内容前请先阅读对应原厂的条款。",
|
||||
"I have read and understood both notices above. I am responsible for backing up my own data and reviewing the model vendor terms.": "我已阅读并理解上述两点。我会自行备份数据,并自行阅读相关模型原厂的条款。",
|
||||
"Decline and sign out": "拒绝并退出",
|
||||
"I agree, continue": "我已知悉,继续使用"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user