Skip to content

Commit 1b00856

Browse files
authored
feat: Invite system (#1079)
### TL;DR Added an invite code screen that gates access to the main application based on a feature flag. ### What changed? - Added a new `InviteCodeScreen` component with a form for entering invite codes - Introduced `hasTwigAccess` state to the auth store that checks the "tasks" feature flag - Added `checkTwigAccess()` method that evaluates feature flag status with timeout handling - Added `redeemInviteCode()` method that calls the invite code redemption API endpoint - Added `reloadFeatureFlags()` function to refresh feature flags after invite code redemption - Modified the App component to render a four-phase flow: auth → access gate → onboarding → main app - Added loading state while checking access permissions
1 parent f842b0f commit 1b00856

5 files changed

Lines changed: 322 additions & 3 deletions

File tree

apps/twig/src/renderer/App.tsx

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { LoginTransition } from "@components/LoginTransition";
33
import { MainLayout } from "@components/MainLayout";
44
import { UpdatePrompt } from "@components/UpdatePrompt";
55
import { AuthScreen } from "@features/auth/components/AuthScreen";
6+
import { InviteCodeScreen } from "@features/auth/components/InviteCodeScreen";
67
import { useAuthStore } from "@features/auth/stores/authStore";
78
import { OnboardingFlow } from "@features/onboarding/components/OnboardingFlow";
89
import { useWorkspaceStore } from "@features/workspace/stores/workspaceStore";
@@ -21,7 +22,8 @@ import { Toaster } from "sonner";
2122
const log = logger.scope("app");
2223

2324
function App() {
24-
const { isAuthenticated, hasCompletedOnboarding } = useAuthStore();
25+
const { isAuthenticated, hasCompletedOnboarding, hasTwigAccess } =
26+
useAuthStore();
2527
const isDarkMode = useThemeStore((state) => state.isDarkMode);
2628
const [isLoading, setIsLoading] = useState(true);
2729
const [showTransition, setShowTransition] = useState(false);
@@ -166,7 +168,7 @@ function App() {
166168
);
167169
}
168170

169-
// Three-phase rendering: auth → onboarding → main app
171+
// Four-phase rendering: auth → access gate → onboarding → main app
170172
const renderContent = () => {
171173
if (!isAuthenticated) {
172174
return (
@@ -181,6 +183,41 @@ function App() {
181183
);
182184
}
183185

186+
// Access check loading state
187+
if (hasTwigAccess === null) {
188+
return (
189+
<motion.div
190+
key="access-check"
191+
initial={{ opacity: 0 }}
192+
animate={{ opacity: 1 }}
193+
exit={{ opacity: 0 }}
194+
transition={{ duration: 0.3 }}
195+
>
196+
<Flex align="center" justify="center" minHeight="100vh">
197+
<Flex align="center" gap="3">
198+
<Spinner size="3" />
199+
<Text color="gray">Checking access...</Text>
200+
</Flex>
201+
</Flex>
202+
</motion.div>
203+
);
204+
}
205+
206+
// Access gate: show invite code screen if flag is not enabled
207+
if (!hasTwigAccess) {
208+
return (
209+
<motion.div
210+
key="invite-code"
211+
initial={{ opacity: 0 }}
212+
animate={{ opacity: 1 }}
213+
exit={{ opacity: 0 }}
214+
transition={{ duration: 0.5 }}
215+
>
216+
<InviteCodeScreen />
217+
</motion.div>
218+
);
219+
}
220+
184221
if (!hasCompletedOnboarding) {
185222
return (
186223
<motion.div
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { DraggableTitleBar } from "@components/DraggableTitleBar";
2+
import { useAuthStore } from "@features/auth/stores/authStore";
3+
import { Callout, Flex, Spinner, Text } from "@radix-ui/themes";
4+
import treeBg from "@renderer/assets/images/tree-bg.svg";
5+
import twigLogo from "@renderer/assets/images/twig-logo.svg";
6+
import { useMutation } from "@tanstack/react-query";
7+
import { useState } from "react";
8+
9+
export function InviteCodeScreen() {
10+
const [code, setCode] = useState("");
11+
const { redeemInviteCode, logout } = useAuthStore();
12+
13+
const redeemMutation = useMutation({
14+
mutationFn: async () => {
15+
await redeemInviteCode(code.trim());
16+
},
17+
});
18+
19+
const handleSubmit = (e: React.FormEvent) => {
20+
e.preventDefault();
21+
if (!code.trim()) return;
22+
redeemMutation.mutate();
23+
};
24+
25+
const errorMessage = redeemMutation.error?.message ?? null;
26+
27+
return (
28+
<Flex height="100vh" style={{ position: "relative", overflow: "hidden" }}>
29+
<DraggableTitleBar />
30+
31+
{/* Background */}
32+
<div
33+
style={{
34+
position: "absolute",
35+
inset: 0,
36+
backgroundColor: "#FAEEDE",
37+
}}
38+
/>
39+
<div
40+
style={{
41+
position: "absolute",
42+
top: 0,
43+
right: 0,
44+
bottom: 0,
45+
width: "50%",
46+
backgroundImage: `url(${treeBg})`,
47+
backgroundSize: "cover",
48+
backgroundPosition: "left center",
49+
backgroundRepeat: "no-repeat",
50+
}}
51+
/>
52+
53+
{/* Left side with card */}
54+
<Flex
55+
width="50%"
56+
align="center"
57+
justify="center"
58+
style={{ position: "relative", zIndex: 1 }}
59+
>
60+
{/* Scrim behind card area */}
61+
<div
62+
style={{
63+
position: "absolute",
64+
inset: 0,
65+
background:
66+
"radial-gradient(ellipse 80% 60% at 50% 50%, rgba(247, 237, 223, 0.7) 0%, rgba(247, 237, 223, 0.3) 70%, transparent 100%)",
67+
pointerEvents: "none",
68+
}}
69+
/>
70+
71+
{/* Invite code card */}
72+
<Flex
73+
direction="column"
74+
gap="5"
75+
style={{
76+
position: "relative",
77+
width: "360px",
78+
padding: "32px",
79+
backgroundColor: "rgba(247, 237, 223, 0.7)",
80+
backdropFilter: "blur(20px)",
81+
WebkitBackdropFilter: "blur(20px)",
82+
borderRadius: "16px",
83+
border: "1px solid rgba(255, 255, 255, 0.3)",
84+
boxShadow:
85+
"0 8px 32px rgba(0, 0, 0, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)",
86+
}}
87+
>
88+
{/* Logo */}
89+
<img
90+
src={twigLogo}
91+
alt="Twig"
92+
style={{
93+
height: "48px",
94+
objectFit: "contain",
95+
alignSelf: "center",
96+
}}
97+
/>
98+
99+
<Text
100+
size="2"
101+
align="center"
102+
style={{ color: "var(--cave-charcoal)", opacity: 0.7 }}
103+
>
104+
Enter your invite code to get started
105+
</Text>
106+
107+
{/* Error */}
108+
{errorMessage && (
109+
<Callout.Root color="red" size="1">
110+
<Callout.Text>{errorMessage}</Callout.Text>
111+
</Callout.Root>
112+
)}
113+
114+
{/* Form */}
115+
<form onSubmit={handleSubmit}>
116+
<Flex direction="column" gap="3">
117+
<input
118+
type="text"
119+
value={code}
120+
onChange={(e) => setCode(e.target.value)}
121+
placeholder="Invite code"
122+
disabled={redeemMutation.isPending}
123+
style={{
124+
width: "100%",
125+
height: "44px",
126+
padding: "0 12px",
127+
border: "1px solid rgba(0, 0, 0, 0.15)",
128+
borderRadius: "10px",
129+
fontSize: "15px",
130+
backgroundColor: "rgba(255, 255, 255, 0.5)",
131+
color: "var(--cave-charcoal)",
132+
outline: "none",
133+
boxSizing: "border-box",
134+
}}
135+
/>
136+
<button
137+
type="submit"
138+
disabled={redeemMutation.isPending || !code.trim()}
139+
style={{
140+
display: "flex",
141+
alignItems: "center",
142+
justifyContent: "center",
143+
gap: "8px",
144+
width: "100%",
145+
height: "44px",
146+
border: "none",
147+
borderRadius: "10px",
148+
fontSize: "15px",
149+
fontWeight: 500,
150+
cursor:
151+
redeemMutation.isPending || !code.trim()
152+
? "not-allowed"
153+
: "pointer",
154+
backgroundColor: "var(--cave-charcoal)",
155+
color: "var(--cave-cream)",
156+
opacity: redeemMutation.isPending || !code.trim() ? 0.5 : 1,
157+
transition: "opacity 150ms ease",
158+
}}
159+
>
160+
{redeemMutation.isPending ? <Spinner size="1" /> : "Redeem"}
161+
</button>
162+
</Flex>
163+
</form>
164+
165+
{/* Log out link */}
166+
<Flex justify="center">
167+
<button
168+
type="button"
169+
onClick={logout}
170+
style={{
171+
background: "none",
172+
border: "none",
173+
padding: 0,
174+
color: "var(--cave-charcoal)",
175+
opacity: 0.5,
176+
cursor: "pointer",
177+
fontSize: "13px",
178+
}}
179+
>
180+
Log out
181+
</button>
182+
</Flex>
183+
</Flex>
184+
</Flex>
185+
186+
{/* Right side - shows background */}
187+
<div style={{ width: "50%" }} />
188+
</Flex>
189+
);
190+
}

apps/twig/src/renderer/features/auth/stores/authStore.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ vi.mock("@renderer/lib/analytics", () => ({
4040
identifyUser: vi.fn(),
4141
resetUser: vi.fn(),
4242
track: vi.fn(),
43+
isFeatureFlagEnabled: vi.fn().mockReturnValue(false),
44+
onFeatureFlagsLoaded: vi.fn(),
45+
reloadFeatureFlags: vi.fn(),
4346
}));
4447

4548
vi.mock("@renderer/lib/logger", () => ({

0 commit comments

Comments
 (0)