Skip to content

Commit e5d900a

Browse files
Merge pull request #161 from stemlen/fixes
feat(auth): implement mandatory legal acceptance and organization compliance.
2 parents 1de56ce + ed79258 commit e5d900a

13 files changed

Lines changed: 668 additions & 230 deletions

src/components/account-lifecycle-provider.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ const INITIAL_STATE: AccountLifecycleState = {
2424
activeWorkspaceId: null,
2525
mustResetPassword: false,
2626
orgRole: null,
27+
mustAcceptLegal: false,
28+
legalBlocked: false,
2729
};
2830

2931
const INITIAL_ROUTING: LifecycleRouting = {

src/components/lifecycle-guard.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useAccountLifecycle } from "@/components/account-lifecycle-provider";
66
import { PUBLIC_ROUTES } from "@/features/auth/constants";
77
import { Loader2 } from "lucide-react";
88
import { ForcePasswordReset } from "@/features/auth/components/force-password-reset";
9+
import { LegalAcceptanceModal } from "@/features/auth/components/legal-acceptance-modal";
910

1011
interface LifecycleGuardProps {
1112
children: React.ReactNode;
@@ -119,11 +120,15 @@ export function LifecycleGuard({ children }: LifecycleGuardProps) {
119120
);
120121
}
121122

122-
// Special Component Interception: ForcePasswordReset
123123
if (lifecycleState.isAuthenticated && lifecycleState.mustResetPassword) {
124124
return <ForcePasswordReset onSuccess={() => refreshLifecycle()} />;
125125
}
126126

127+
// Special Component Interception: Legal Acceptance
128+
if (lifecycleState.isAuthenticated && (lifecycleState.mustAcceptLegal || lifecycleState.legalBlocked)) {
129+
return <LegalAcceptanceModal />;
130+
}
131+
127132
// If redirecting, show spinner (but with timeout protection)
128133
if (redirectingRef.current) {
129134
return (
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { useMutation, useQueryClient } from "@tanstack/react-query";
2+
import { InferRequestType, InferResponseType } from "hono";
3+
import { client } from "@/lib/rpc";
4+
import { toast } from "sonner";
5+
6+
type ResponseType = InferResponseType<typeof client.api.auth["accept-legal"]["$post"]>;
7+
type RequestType = InferRequestType<typeof client.api.auth["accept-legal"]["$post"]>["json"];
8+
9+
export const useAcceptLegal = () => {
10+
const queryClient = useQueryClient();
11+
12+
const mutation = useMutation<ResponseType, Error, RequestType>({
13+
mutationFn: async (json) => {
14+
const response = await client.api.auth["accept-legal"]["$post"]({ json });
15+
16+
if (!response.ok) {
17+
const errorData = await response.json() as { error?: string };
18+
throw new Error(errorData.error || "Failed to accept legal terms");
19+
}
20+
21+
return await response.json();
22+
},
23+
onSuccess: () => {
24+
toast.success("Legal terms accepted");
25+
queryClient.invalidateQueries({ queryKey: ["account-lifecycle"] });
26+
},
27+
onError: (error) => {
28+
toast.error(error.message || "Something went wrong");
29+
},
30+
});
31+
32+
return mutation;
33+
};

src/features/auth/api/use-account-lifecycle.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ const INITIAL_LIFECYCLE_STATE: AccountLifecycleState = {
4040
activeWorkspaceId: null,
4141
mustResetPassword: false,
4242
orgRole: null,
43+
mustAcceptLegal: false,
44+
legalBlocked: false,
4345
};
4446

4547
/**
@@ -91,9 +93,9 @@ export const useGetAccountLifecycle = () => {
9193
const result = await response.json();
9294
return result as LifecycleQueryResult;
9395
},
94-
staleTime: 1000 * 60 * 10, // 10 minutes — lifecycle rarely changes
95-
refetchOnWindowFocus: false, // DISABLED — was triggering 6 DB reads on every alt-tab
96-
refetchInterval: 5 * 60 * 1000, // Poll every 5 minutes (was 60s — way too aggressive)
96+
staleTime: 1000 * 30, // 30 seconds - critical for security status
97+
refetchOnWindowFocus: true, // Re-verify status when coming back to app
98+
refetchInterval: 2 * 60 * 1000, // Poll every 2 minutes
9799
refetchIntervalInBackground: false, // Don't poll when tab is not focused
98100
retry: 1,
99101
// Disable query during SSR to prevent hydration mismatch
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import Image from "next/image";
5+
import { Loader2, ShieldCheck, AlertCircle, Building2, UserCircle } from "lucide-react";
6+
7+
import { Button } from "@/components/ui/button";
8+
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card";
9+
import { Checkbox } from "@/components/ui/checkbox";
10+
import { Label } from "@/components/ui/label";
11+
import { Separator } from "@/components/ui/separator";
12+
13+
import { LegalAcceptance } from "./legal-acceptance";
14+
import { useAcceptLegal } from "../api/use-accept-legal";
15+
import { useGetAccountLifecycle } from "../api/use-account-lifecycle";
16+
import { OrganizationRole } from "@/features/organizations/types";
17+
18+
/**
19+
* Legal Acceptance Modal
20+
*
21+
* GUARD SCREEN:
22+
* Blocks user interaction until the latest legal policies are accepted.
23+
* - For PERSONAL users: Must accept personally.
24+
* - For ORG Admins/Owners: Must accept on behalf of organization.
25+
* - For ORG Members: Blocked if organization hasn't accepted.
26+
*
27+
* Design: Premium glassmorphism with vibrant gradients, consistent with onboarding.
28+
*/
29+
export function LegalAcceptanceModal() {
30+
const { lifecycleState, refreshLifecycle } = useGetAccountLifecycle();
31+
const mutation = useAcceptLegal();
32+
33+
const [acceptedTerms, setAcceptedTerms] = useState(false);
34+
const [acceptedDPA, setAcceptedDPA] = useState(false);
35+
const [applyToOrg, setApplyToOrg] = useState(false);
36+
37+
const isManagement =
38+
lifecycleState.orgRole === OrganizationRole.OWNER ||
39+
lifecycleState.orgRole === OrganizationRole.ADMIN;
40+
41+
const mustAccept = lifecycleState.mustAcceptLegal;
42+
const isBlocked = lifecycleState.legalBlocked;
43+
44+
const handleAccept = () => {
45+
mutation.mutate({
46+
acceptedTerms: true,
47+
acceptedDPA: true,
48+
applyToOrg: applyToOrg && isManagement,
49+
}, {
50+
onSuccess: () => {
51+
refreshLifecycle();
52+
}
53+
});
54+
};
55+
56+
// If neither blocked nor must accept, don't show anything (LifecycleGuard should handle this)
57+
if (!mustAccept && !isBlocked) {
58+
return null;
59+
}
60+
61+
return (
62+
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-background/80 backdrop-blur-xl p-4 overflow-y-auto">
63+
{/* Background Decorative Elements */}
64+
<div className="absolute top-1/4 -left-1/4 w-96 h-96 bg-primary/10 rounded-full blur-[120px] pointer-events-none" />
65+
<div className="absolute bottom-1/4 -right-1/4 w-96 h-96 bg-primary/20 rounded-full blur-[120px] pointer-events-none" />
66+
67+
{/* Fairlx Logo */}
68+
<div className="mb-6 z-10">
69+
<Image
70+
src="/Logo.png"
71+
alt="Fairlx"
72+
width={56}
73+
height={44}
74+
priority
75+
className="drop-shadow-sm"
76+
/>
77+
</div>
78+
79+
<Card className="w-full max-w-lg shadow-2xl border-primary/10 bg-card/50 backdrop-blur-sm z-10 transition-all duration-300">
80+
<CardHeader className="text-center pb-2">
81+
<div className="mx-auto mb-4 bg-primary/10 w-16 h-16 rounded-full flex items-center justify-center">
82+
{isBlocked ? (
83+
<AlertCircle className="h-8 w-8 text-destructive animate-pulse" />
84+
) : (
85+
<ShieldCheck className="h-8 w-8 text-primary" />
86+
)}
87+
</div>
88+
<CardTitle className="text-2xl font-bold tracking-tight">
89+
{isBlocked ? "Action Required" : "Legal Policy Update"}
90+
</CardTitle>
91+
<CardDescription className="text-base mt-2">
92+
{isBlocked
93+
? "Policy acceptance pending for your organization."
94+
: "Please review and accept our updated legal terms to continue using Fairlx."}
95+
</CardDescription>
96+
</CardHeader>
97+
98+
<CardContent className="pt-4 space-y-6">
99+
{/* Member Blocked State */}
100+
{isBlocked && (
101+
<div className="rounded-2xl bg-destructive/5 border border-destructive/10 p-6 text-center space-y-4">
102+
<Building2 className="h-10 w-10 text-destructive/40 mx-auto" />
103+
<div className="space-y-2">
104+
<p className="text-sm font-medium text-foreground">
105+
Your organization has not yet accepted the latest Service Agreement.
106+
</p>
107+
<p className="text-sm text-muted-foreground">
108+
Please contact your Organization Owner or Administrator to accept the updated terms and unblock your access.
109+
</p>
110+
</div>
111+
<Separator />
112+
<p className="text-xs text-muted-foreground italic">
113+
Note: Only Workspace Owners and Admins can accept terms for the entire organization.
114+
</p>
115+
</div>
116+
)}
117+
118+
{/* Must Accept State */}
119+
{mustAccept && (
120+
<div className="space-y-6">
121+
<div className="rounded-2xl bg-muted/50 p-4 border border-border/50">
122+
<div className="flex items-center gap-2 mb-4 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
123+
<UserCircle className="h-4 w-4" />
124+
Personal Acceptance
125+
</div>
126+
<LegalAcceptance
127+
acceptedTerms={acceptedTerms}
128+
acceptedDPA={acceptedDPA}
129+
onAcceptedTermsChange={setAcceptedTerms}
130+
onAcceptedDPAChange={setAcceptedDPA}
131+
disabled={mutation.isPending}
132+
/>
133+
</div>
134+
135+
{/* Org Admin/Owner specific checkbox */}
136+
{isManagement && (
137+
<div className="rounded-2xl bg-primary/5 p-4 border border-primary/10 transition-all hover:bg-primary/10">
138+
<div className="flex items-center gap-2 mb-3 text-xs font-semibold uppercase tracking-wider text-primary/70">
139+
<Building2 className="h-4 w-4" />
140+
Organization Compliance
141+
</div>
142+
<div className="flex items-start space-x-3">
143+
<Checkbox
144+
id="applyToOrg"
145+
checked={applyToOrg}
146+
onCheckedChange={(checked) => setApplyToOrg(!!checked)}
147+
disabled={mutation.isPending}
148+
className="mt-1"
149+
/>
150+
<div className="grid gap-1.5 leading-none">
151+
<Label
152+
htmlFor="applyToOrg"
153+
className="text-sm font-medium leading-normal cursor-pointer"
154+
>
155+
Apply these terms to my entire organization <span className="text-destructive">(Mandatory)</span>
156+
</Label>
157+
<p className="text-xs text-muted-foreground">
158+
By checking this, you accept these policies on behalf of all current and future members of your organization ({lifecycleState.activeOrgName || "Your Org"}).
159+
</p>
160+
</div>
161+
</div>
162+
</div>
163+
)}
164+
</div>
165+
)}
166+
</CardContent>
167+
168+
<CardFooter className="flex flex-col gap-3 pt-2">
169+
{!isBlocked && (
170+
<Button
171+
className="w-full h-12 text-base font-semibold transition-all hover:shadow-lg hover:shadow-primary/20 active:scale-[0.98]"
172+
size="lg"
173+
disabled={
174+
mutation.isPending ||
175+
!acceptedTerms ||
176+
!acceptedDPA ||
177+
(isManagement && !applyToOrg)
178+
}
179+
onClick={handleAccept}
180+
>
181+
{mutation.isPending ? (
182+
<>
183+
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
184+
Saving preference...
185+
</>
186+
) : (
187+
"Accept and Continue"
188+
)}
189+
</Button>
190+
)}
191+
192+
<footer className="w-full text-center text-xs text-muted-foreground">
193+
Questions about our policies? Check our{" "}
194+
<a
195+
href="https://fairlx.com/legal"
196+
target="_blank"
197+
rel="noopener noreferrer"
198+
className="underline underline-offset-2 hover:text-foreground transition-colors"
199+
>
200+
Legal Center
201+
</a>
202+
</footer>
203+
</CardFooter>
204+
</Card>
205+
206+
{/* Logout button for people who don't want to accept or are stuck */}
207+
<div className="mt-8 z-10">
208+
<button
209+
onClick={() => window.location.href = "/api/auth/logout"}
210+
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
211+
>
212+
Log out of my account
213+
</button>
214+
</div>
215+
</div>
216+
);
217+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import Link from "next/link";
2+
import { Checkbox } from "@/components/ui/checkbox";
3+
import { Label } from "@/components/ui/label";
4+
5+
interface LegalAcceptanceProps {
6+
acceptedTerms: boolean;
7+
acceptedDPA: boolean;
8+
onAcceptedTermsChange: (checked: boolean) => void;
9+
onAcceptedDPAChange: (checked: boolean) => void;
10+
disabled?: boolean;
11+
}
12+
13+
export const LegalAcceptance = ({
14+
acceptedTerms,
15+
acceptedDPA,
16+
onAcceptedTermsChange,
17+
onAcceptedDPAChange,
18+
disabled,
19+
}: LegalAcceptanceProps) => {
20+
return (
21+
<div className="space-y-4 py-2">
22+
<div className="flex items-start space-x-3">
23+
<Checkbox
24+
id="acceptedTerms"
25+
checked={acceptedTerms}
26+
onCheckedChange={(checked) => onAcceptedTermsChange(!!checked)}
27+
disabled={disabled}
28+
className="mt-1"
29+
/>
30+
<div className="grid gap-1.5 leading-none">
31+
<Label
32+
htmlFor="acceptedTerms"
33+
className="text-sm font-medium leading-normal peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
34+
>
35+
I accept the{" "}
36+
<Link
37+
href="https://fairlx.com/terms"
38+
target="_blank"
39+
rel="noopener noreferrer"
40+
className="text-blue-600 hover:underline"
41+
onClick={(e) => e.stopPropagation()}
42+
>
43+
Terms of Service
44+
</Link>
45+
</Label>
46+
</div>
47+
</div>
48+
49+
<div className="flex items-start space-x-3">
50+
<Checkbox
51+
id="acceptedDPA"
52+
checked={acceptedDPA}
53+
onCheckedChange={(checked) => onAcceptedDPAChange(!!checked)}
54+
disabled={disabled}
55+
className="mt-1"
56+
/>
57+
<div className="grid gap-1.5 leading-none">
58+
<Label
59+
htmlFor="acceptedDPA"
60+
className="text-sm font-medium leading-normal peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
61+
>
62+
I accept the{" "}
63+
<Link
64+
href="https://fairlx.com/dpa"
65+
target="_blank"
66+
rel="noopener noreferrer"
67+
className="text-blue-600 hover:underline"
68+
onClick={(e) => e.stopPropagation()}
69+
>
70+
Data Processing Agreement (DPA)
71+
</Link>
72+
</Label>
73+
</div>
74+
</div>
75+
</div>
76+
);
77+
};

0 commit comments

Comments
 (0)